mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-22 10:38:08 +02:00
- Find: text search, grouped search, undo, went up/down by, where to search, before/after compare - Debug tab: breakpoints, stepping, registers, stack, threads, what code touches, structure compare, pointer re-check - Engine tab: Unreal, Unity, Godot, GameMaker and C++ class names; hidden settings and switches - Code cheats with labels, variables, references and shared code; rule builder; script commands - Cheats kept on by the PS5 without the browser - Learn section with twelve lessons and an in-page practice game - Section (master code) community cheats; SHN and MC4 export - Groups, dropdowns, hotkeys, first-cheat guide, re-find after update, restore everything - MCP server: 25 tools - Quick start, wiki, contributor and security guides, changelog
516 lines
26 KiB
JavaScript
516 lines
26 KiB
JavaScript
// The workbench: locations, own tables, code injection, tracing, pointer
|
|
// paths, unknown-value scans and the script API, against the mock console.
|
|
|
|
import test, { after, before } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createRequire } from 'node:module';
|
|
|
|
import { startMock, GAME } from '../mock/mock-ps5.mjs';
|
|
import { startBridge } from '../bridge/server.mjs';
|
|
import { openWebSocketStream } from '../src/lib/transport.ts';
|
|
import { Ps5Debug } from '../src/lib/ps5debug.ts';
|
|
import { createX86, asAsync, instructionEndingAt, parsePattern, signatureFromCode } from '../src/lib/x86.ts';
|
|
import { createGameContext, describeLocation, findPattern, imageRange, parseAddress, resolveLocation } from '../src/lib/locations.ts';
|
|
import { MismatchError, TableRunner, describeEntry, exportCommunityTable, exportOwnTable, parseOwnTable, emptyTable } from '../src/lib/table.ts';
|
|
import { Tracer } from '../src/lib/tracer.ts';
|
|
import { findPointerPaths } from '../src/lib/pointerscan.ts';
|
|
import { SnapshotScan, scannableRegions, valueKind, compareKind, encodeValue, decodeValue } from '../src/lib/scanner.ts';
|
|
import { handleScriptCall } from '../src/lib/script.ts';
|
|
import { bytesToHex, hexToBytes } from '../src/lib/bytes.ts';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const sync = createX86(require('iced-x86'));
|
|
const x86 = asAsync(sync);
|
|
|
|
// Lets several copies of the suite run at once on one machine.
|
|
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
|
const TCP = 17460 + PORT_OFFSET;
|
|
const TCP_FAR = 17461 + PORT_OFFSET;
|
|
const EVENTS = 17765 + PORT_OFFSET;
|
|
let mock, mockFar, bridge, bridgeFar, port, portFar;
|
|
|
|
const HEALTH = GAME.heapBase + BigInt(GAME.vars.health);
|
|
const SITE = GAME.imageBase + BigInt(GAME.code.healthDrain.offset);
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const until = async (cond, what) => {
|
|
for (let i = 0; i < 100; i++) {
|
|
if (cond()) return;
|
|
await wait(20);
|
|
}
|
|
throw new Error(`timed out waiting for ${what}`);
|
|
};
|
|
|
|
const url = (p, tcp, path = 'ws') => `ws://127.0.0.1:${p}/${path}?host=127.0.0.1&port=${tcp}`;
|
|
const connect = async (p = port, tcp = TCP) => new Ps5Debug(await openWebSocketStream(url(p, tcp)), { timeoutMs: 4000 });
|
|
const context = async (p = port, tcp = TCP) => {
|
|
const dbg = await connect(p, tcp);
|
|
// As in the app: assembling gets a connection of its own, because the console drops it on a syntax error.
|
|
const assemble = async (base, text) => {
|
|
const tmp = await connect(p, tcp);
|
|
try {
|
|
return await tmp.assemble(base, text);
|
|
} finally {
|
|
if (!tmp.closed) tmp.close();
|
|
}
|
|
};
|
|
return { dbg, ctx: createGameContext({ pid: GAME.pid, dbg: () => dbg, x86, assemble }) };
|
|
};
|
|
|
|
before(async () => {
|
|
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false, eventPort: EVENTS });
|
|
mockFar = startMock({ tcpPort: TCP_FAR, udpPort: 0, tick: false, noNearAlloc: true });
|
|
await Promise.all([mock.ready, mockFar.ready]);
|
|
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: EVENTS, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 't' } });
|
|
bridgeFar = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP_FAR, udpPort: 0, label: 't' } });
|
|
port = await bridge.ready;
|
|
portFar = await bridgeFar.ready;
|
|
});
|
|
|
|
after(async () => {
|
|
await bridge.close();
|
|
await bridgeFar.close();
|
|
await mock.close();
|
|
await mockFar.close();
|
|
});
|
|
|
|
test('x86: decode, relocate, find the previous instruction, build a signature', () => {
|
|
const code = hexToBytes('898190030000 FF4B28 8B0512340000 E800100000 C3');
|
|
const insns = sync.disassemble(code, 0x1000n);
|
|
assert.deepEqual(insns.map((i) => i.text), ['mov [rcx+0x390], eax', 'dec dword ptr [rbx+0x28]', 'mov eax, [0x4421]', 'call 0x2014', 'ret']);
|
|
assert.equal(insns[0].writesMemory, true);
|
|
assert.equal(insns[2].ripTarget, 0x4421n);
|
|
assert.equal(insns[3].flow, 'call');
|
|
assert.equal(insns[3].target, 0x2014n);
|
|
assert.deepEqual(insns[2].variable, [{ offset: 2, size: 4 }]);
|
|
|
|
// Moving RIP-relative code keeps it pointing at the same data.
|
|
const moved = sync.relocate(hexToBytes('8B0512340000'), 0x1000n, 0x900000n);
|
|
assert.equal(sync.disassemble(moved, 0x900000n)[0].ripTarget, 0x1000n + 6n + 0x3412n);
|
|
|
|
assert.equal(instructionEndingAt(sync, code.subarray(0, 9), 0x1009n).text, 'dec dword ptr [rbx+0x28]');
|
|
assert.equal(instructionEndingAt(sync, code.subarray(0, 6), 0x1006n).text, 'mov [rcx+0x390], eax');
|
|
|
|
// The displacement and the call target are wildcarded; the rest is literal.
|
|
assert.equal(signatureFromCode(insns.slice(2), 10).text, '8B 05 ?? ?? ?? ?? E8');
|
|
assert.equal(signatureFromCode(insns, 8).text, '89 81 90 03 00 00 FF 4B 28');
|
|
|
|
assert.deepEqual([...parsePattern('89 81 ?? 03').mask], [1, 1, 0, 1]);
|
|
assert.throws(() => parsePattern('89 8'), /not a byte/);
|
|
assert.throws(() => parsePattern('?? ??'), /at least one fixed byte/);
|
|
});
|
|
|
|
test('locations: module, signature, pointer path, typed addresses', async () => {
|
|
const { dbg, ctx } = await context();
|
|
assert.equal(await resolveLocation(ctx, { kind: 'module', offset: '1000' }), SITE);
|
|
assert.equal(await parseAddress(ctx, 'game+1000'), SITE);
|
|
assert.equal(await parseAddress(ctx, '0x880012340'), HEALTH);
|
|
await assert.rejects(parseAddress(ctx, 'hello'), /not a hex number/);
|
|
assert.deepEqual(await imageRange(ctx), { start: GAME.imageBase, end: GAME.dataBase + 0x4000n });
|
|
|
|
assert.deepEqual(await findPattern(ctx, '89 81 90 03 00 00'), { address: SITE, unique: true });
|
|
assert.equal((await findPattern(ctx, 'CC CC CC CC')).unique, false);
|
|
assert.equal(await findPattern(ctx, 'DE AD BE EF 01 02'), null);
|
|
await assert.rejects(findPattern(ctx, '00 00 ?? 00'), /only of 00 bytes/);
|
|
|
|
assert.equal(await resolveLocation(ctx, { kind: 'signature', pattern: '89 81 ?? ?? 00 00 C3', offset: 0 }), SITE);
|
|
assert.equal(await resolveLocation(ctx, { kind: 'signature', pattern: 'FF 4B 28', offset: 3 }), GAME.imageBase + 0x2043n);
|
|
await assert.rejects(resolveLocation(ctx, { kind: 'signature', pattern: 'CC CC CC CC', offset: 0 }), /more than one place/);
|
|
await assert.rejects(resolveLocation(ctx, { kind: 'signature', pattern: 'DE AD BE EF 01', offset: 0 }), /not found/);
|
|
|
|
const p1 = { kind: 'pointer', base: '10100', offsets: ['0x340'] };
|
|
const p2 = { kind: 'pointer', base: '10200', offsets: ['0x8', '0x340'] };
|
|
assert.equal(await resolveLocation(ctx, p1), HEALTH);
|
|
assert.equal(await resolveLocation(ctx, p2), HEALTH);
|
|
assert.equal(describeLocation(p2), '[[game+0x10200]+0x8]+0x340');
|
|
await assert.rejects(resolveLocation(ctx, { kind: 'pointer', base: '10300', offsets: ['0x10'] }), /leads nowhere/);
|
|
dbg.close();
|
|
});
|
|
|
|
test('runner: patch by signature, mismatch refusal, undo', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const runner = new TableRunner(ctx);
|
|
const patch = { id: 'a', type: 'patch', name: 'Infinite health', at: { kind: 'signature', pattern: '89 81 90 03 00 00', offset: 0 }, on: '909090909090', off: '898190030000' };
|
|
await runner.enable(patch);
|
|
assert.equal(runner.isOn('a'), true);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '909090909090');
|
|
await runner.disable(patch);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '898190030000');
|
|
|
|
const wrong = { id: 'b', type: 'patch', name: 'Wrong', at: { kind: 'module', offset: '2040' }, on: '909090', off: 'AABBCC' };
|
|
await assert.rejects(runner.enable(wrong), MismatchError);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), 'FF4B28');
|
|
// Forced, it applies, and undo puts back what was really there rather than the table's guess.
|
|
await runner.enable(wrong, { force: true });
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), '909090');
|
|
await runner.disable(wrong);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), 'FF4B28');
|
|
|
|
// No "off" bytes recorded: the original is captured when it is switched on.
|
|
const bare = { id: 'c', type: 'patch', name: 'Bare', at: { kind: 'module', offset: '30A0' }, on: '90909090' };
|
|
await runner.enable(bare);
|
|
await runner.disable(bare);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x30a0, 0x30a4)), 'F30F5CC1');
|
|
runner.dispose();
|
|
dbg.close();
|
|
});
|
|
|
|
test('runner: set and hold a value through a pointer path, call a function', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const runner = new TableRunner(ctx);
|
|
const hold = { id: 'v', type: 'value', name: 'Health', at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, valueType: 'u32', value: '999', freeze: true };
|
|
await runner.enable(hold);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 999);
|
|
mock.game.heap.writeUInt32LE(5, GAME.vars.health);
|
|
await until(() => mock.game.heap.readUInt32LE(GAME.vars.health) === 999, 'the freeze to rewrite the value');
|
|
assert.equal(await runner.readValue(hold), '999');
|
|
await runner.disable(hold);
|
|
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
|
|
await wait(350);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
|
|
|
const add = { id: 'f', type: 'call', name: 'add', fn: { kind: 'module', offset: '4000' }, args: ['40', '0x2'] };
|
|
await runner.enable(add);
|
|
assert.match(runner.result('f'), /Returned 0x2A \(42\)/);
|
|
await runner.enable({ id: 'g', type: 'call', name: 'gold', fn: { kind: 'module', offset: '4100' }, args: ['777'] });
|
|
assert.equal(mock.game.heap.readFloatLE(GAME.vars.gold), 777);
|
|
mock.game.heap.writeFloatLE(2500, GAME.vars.gold);
|
|
await assert.rejects(runner.enable({ id: 'h', type: 'call', name: 'bad', fn: { kind: 'module', offset: '5000' }, args: [] }), /Calling the function failed/);
|
|
await assert.rejects(runner.enable({ ...add, id: 'i', args: ['x'] }), /not a number/);
|
|
runner.dispose();
|
|
dbg.close();
|
|
});
|
|
|
|
for (const far of [false, true]) {
|
|
test(`runner: inject code through a ${far ? 'far (14-byte)' : 'near (5-byte)'} hook`, async () => {
|
|
const m = far ? mockFar : mock;
|
|
const { dbg, ctx } = await context(far ? portFar : port, far ? TCP_FAR : TCP);
|
|
const runner = new TableRunner(ctx);
|
|
const before = Buffer.from(m.game.image.subarray(0x1000, 0x1020));
|
|
|
|
for (const placement of ['before', 'after', 'replace']) {
|
|
const entry = { id: 'inj-' + placement, type: 'inject', name: placement, at: { kind: 'module', offset: '1000' }, code: 'nop\nnop\ndb 0x90', placement };
|
|
await runner.enable(entry);
|
|
|
|
const site = m.game.image.subarray(0x1000, 0x1020);
|
|
const hook = sync.disassemble(site.subarray(0, 16), SITE, 1)[0];
|
|
assert.equal(hook.flow, 'jump');
|
|
// Far hooks are "jmp [rip+0]" (6 bytes) followed by the 8-byte destination.
|
|
assert.equal(hook.length, far ? 6 : 5);
|
|
const cave = far ? site.readBigUInt64LE(6) : hook.target;
|
|
assert.equal(cave - SITE < 0x7fffffffn && cave > SITE, !far);
|
|
|
|
// What the hook overwrote: whole instructions, at least as long as the jump.
|
|
const covered = far ? 14 : 6;
|
|
if (!far) assert.equal(site[5], 0x90, 'leftover byte is padded with a nop');
|
|
|
|
const body = sync.disassemble(Buffer.from(m.game.read(cave, 64)), cave, 16).map((i) => i.text);
|
|
const orig = far
|
|
? ['mov [rcx+0x390], eax', 'ret', 'int3', 'int3', 'int3', 'int3', 'int3', 'int3', 'int3']
|
|
: ['mov [rcx+0x390], eax'];
|
|
const user = ['nop', 'nop', 'nop'];
|
|
const expected = placement === 'before' ? [...user, ...orig] : placement === 'after' ? [...orig, ...user] : user;
|
|
assert.deepEqual(body.slice(0, expected.length), expected, placement);
|
|
// ...followed by the jump back to just past the overwritten instructions.
|
|
const tail = sync.disassemble(Buffer.from(m.game.read(cave, 64)), cave, expected.length + 1)[expected.length];
|
|
assert.equal(tail.flow, 'jump', placement);
|
|
const backTo = tail.target ?? Buffer.from(m.game.read(tail.address + 6n, 8)).readBigUInt64LE(0);
|
|
assert.equal(backTo, SITE + BigInt(covered), placement);
|
|
|
|
// Hooking the same spot twice is refused rather than stacking jumps.
|
|
await assert.rejects(runner.enable({ ...entry, id: 'again' }), /already a jump/);
|
|
|
|
await runner.disable(entry);
|
|
assert.deepEqual(Buffer.from(m.game.image.subarray(0x1000, 0x1020)), before, 'original code restored');
|
|
}
|
|
|
|
// A syntax error surfaces with the assembler's message and leaves the game untouched.
|
|
await assert.rejects(
|
|
runner.enable({ id: 'bad', type: 'inject', name: 'bad', at: { kind: 'module', offset: '1000' }, code: 'frobnicate rax', placement: 'before' }),
|
|
/Assembler: invalid mnemonic/i,
|
|
);
|
|
assert.deepEqual(Buffer.from(m.game.image.subarray(0x1000, 0x1020)), before);
|
|
await dbg.ping(); // the typo did not cost us the main connection
|
|
// Sent on a shared connection instead, the same error ends that connection.
|
|
const shared = await connect(far ? portFar : port, far ? TCP_FAR : TCP);
|
|
await assert.rejects(shared.assemble(0x1000n, 'frobnicate'), /Assembler/);
|
|
await until(() => shared.closed, 'the console to drop the connection');
|
|
runner.dispose();
|
|
dbg.close();
|
|
});
|
|
}
|
|
|
|
test('tracer: finds the instruction that writes a value, then lets go of the game', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const tracer = new Tracer(ctx, {
|
|
openDebugger: () => connect(),
|
|
openEvents: () => openWebSocketStream(`ws://127.0.0.1:${port}/events?host=127.0.0.1`),
|
|
});
|
|
let latest = [];
|
|
await tracer.start(HEALTH, 4, 'write', (hits) => {
|
|
latest = hits;
|
|
});
|
|
assert.equal(mock.debug.owner !== null, true);
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const seen = tracer.total;
|
|
mock.tick(); // the game writes health and stops
|
|
await until(() => tracer.total === seen + 1 && !mock.debug.stopped, 'the hit to be handled and the game resumed');
|
|
}
|
|
assert.equal(latest.length, 1);
|
|
assert.equal(latest[0].count, 3);
|
|
assert.equal(latest[0].insn.text, 'mov [rcx+0x390], eax');
|
|
assert.equal(latest[0].insn.address, SITE);
|
|
assert.equal(latest[0].regs.rcx, HEALTH - 0x390n);
|
|
|
|
// A second debugger is refused while this one is attached.
|
|
const other = await connect();
|
|
await assert.rejects(other.debugAttach(GAME.pid), /already attached/);
|
|
|
|
await tracer.stop();
|
|
assert.equal(tracer.running, false);
|
|
assert.equal(mock.debug.owner, null);
|
|
assert.equal(mock.state.detachedWithWatchArmed, false, 'the watchpoint is disarmed before letting go');
|
|
const h = mock.game.heap.readUInt32LE(GAME.vars.health);
|
|
mock.tick();
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), h - 1, 'the game runs freely again');
|
|
assert.equal(mock.debug.stopped, false);
|
|
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
|
|
dbg.close();
|
|
});
|
|
|
|
test('tracer: stops by itself after the hit limit and still detaches', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const tracer = new Tracer(ctx, {
|
|
openDebugger: () => connect(),
|
|
openEvents: () => openWebSocketStream(`ws://127.0.0.1:${port}/events?host=127.0.0.1`),
|
|
});
|
|
tracer.maxHits = 2;
|
|
let reason;
|
|
await tracer.start(GAME.heapBase + BigInt(GAME.vars.score), 4, 'write', (_hits, ended) => {
|
|
if (ended) reason = ended;
|
|
});
|
|
mock.tick();
|
|
await until(() => tracer.total === 1 && !mock.debug.stopped, 'first hit');
|
|
mock.tick();
|
|
await until(() => !tracer.running, 'the tracer to stop');
|
|
assert.match(reason, /Stopped after 2 hits/);
|
|
assert.equal(tracer.list()[0].insn.text, 'add dword ptr [rdi+0x38880], 0xA');
|
|
assert.equal(mock.debug.owner, null);
|
|
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
|
|
mock.game.heap.writeUInt32LE(30, GAME.vars.ammo);
|
|
mock.game.heap.writeFloatLE(2500, GAME.vars.gold);
|
|
dbg.close();
|
|
});
|
|
|
|
test('tracer: steps aside when the game gets a signal of its own, or the session ends under it', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const deps = { openDebugger: () => connect(), openEvents: () => openWebSocketStream(`ws://127.0.0.1:${port}/events?host=127.0.0.1`) };
|
|
|
|
let reason;
|
|
const tracer = new Tracer(ctx, deps);
|
|
await tracer.start(HEALTH, 4, 'write', (_h, ended) => ended && (reason = ended));
|
|
mock.raise(11);
|
|
await until(() => !tracer.running, 'the tracer to end');
|
|
assert.match(reason, /received signal 11/);
|
|
assert.equal(tracer.total, 0, 'not counted as a hit');
|
|
assert.equal(mock.debug.owner, null);
|
|
|
|
// An unaligned address is watched as the aligned block that contains it.
|
|
reason = undefined;
|
|
const t2 = new Tracer(ctx, deps);
|
|
await t2.start(HEALTH + 1n, 4, 'write', (_h, ended) => ended && (reason = ended));
|
|
assert.equal(mock.debug.watch[0].address, HEALTH);
|
|
// The console ends the session by itself (as when the game quits).
|
|
mock.debug.events.destroy();
|
|
await until(() => !t2.running, 'the second tracer to notice');
|
|
assert.match(reason, /ended the debugging session/);
|
|
mock.debug.owner?.destroy();
|
|
await until(() => mock.debug.owner === null, 'teardown');
|
|
dbg.close();
|
|
});
|
|
|
|
test('tracer: a bridge without the event port explains itself', async () => {
|
|
await assert.rejects(
|
|
(async () => {
|
|
const s = await openWebSocketStream(`ws://127.0.0.1:${portFar}/events?host=127.0.0.1`);
|
|
await s.read(1);
|
|
})(),
|
|
/turned off/,
|
|
);
|
|
});
|
|
|
|
test('pointer scan: finds static paths to a heap value', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const paths = await findPointerPaths(ctx, HEALTH, { maxDepth: 2 }, () => {});
|
|
const texts = paths.map((p) => describeLocation(p.location));
|
|
assert.ok(texts.includes('[game+0x10100]+0x340'), texts.join(' | '));
|
|
assert.ok(texts.includes('[[game+0x10200]+0x8]+0x340'), texts.join(' | '));
|
|
assert.equal(paths[0].depth, 1, 'shortest first');
|
|
for (const p of paths) assert.equal(await resolveLocation(ctx, p.location), HEALTH);
|
|
dbg.close();
|
|
});
|
|
|
|
test('unknown-value scan: snapshot on the console, narrow by change', async () => {
|
|
const dbg = await connect();
|
|
const kind = valueKind('u32');
|
|
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
|
|
const regions = scannableRegions(await dbg.maps(GAME.pid));
|
|
assert.ok(((await dbg.turboCaps()) & 0x18) === 0x18);
|
|
|
|
const scan = new SnapshotScan(() => dbg, GAME.pid, kind);
|
|
await scan.first(regions, compareKind('unknown'), new Uint8Array(0), () => {});
|
|
const start = scan.matches;
|
|
assert.ok(start > 8, 'every non-zero value is a candidate');
|
|
|
|
mock.game.heap.writeUInt32LE(90, GAME.vars.health);
|
|
await scan.next(compareKind('decreased'), new Uint8Array(0), () => {});
|
|
assert.equal(scan.matches, 1);
|
|
const [hit] = await scan.hits(10);
|
|
assert.equal(hit.address, HEALTH);
|
|
assert.equal(decodeValue(kind, hit.value), '90');
|
|
|
|
await scan.next(compareKind('unchanged'), new Uint8Array(0), () => {});
|
|
assert.equal(scan.matches, 1);
|
|
await scan.next(compareKind('exact'), encodeValue(kind, '90'), () => {});
|
|
assert.equal(scan.matches, 1);
|
|
mock.game.heap.writeUInt32LE(95, GAME.vars.health);
|
|
await scan.next(compareKind('decreased'), new Uint8Array(0), () => {});
|
|
assert.equal(scan.matches, 0);
|
|
assert.deepEqual(await scan.hits(10), []);
|
|
await scan.end();
|
|
|
|
// A value that is zero when the snapshot is taken can still be found.
|
|
mock.game.heap.writeUInt32LE(0, GAME.vars.score);
|
|
const zero = new SnapshotScan(() => dbg, GAME.pid, kind);
|
|
await zero.first(regions, compareKind('unknown'), new Uint8Array(0), () => {});
|
|
mock.game.heap.writeUInt32LE(50, GAME.vars.score);
|
|
await zero.next(compareKind('increased'), new Uint8Array(0), () => {});
|
|
assert.equal(zero.matches, 1);
|
|
assert.equal((await zero.hits(1))[0].address, GAME.heapBase + BigInt(GAME.vars.score));
|
|
await zero.end();
|
|
mock.game.heap.writeUInt32LE(0, GAME.vars.score);
|
|
|
|
// The snapshot lives on one connection; a different one is told plainly.
|
|
const scan2 = new SnapshotScan(() => dbg, GAME.pid, kind);
|
|
await scan2.first(regions, compareKind('unknown'), new Uint8Array(0), () => {});
|
|
const dbg2 = await connect();
|
|
const moved = new SnapshotScan(() => dbg2, GAME.pid, kind);
|
|
await assert.rejects(moved.next(compareKind('changed'), new Uint8Array(0), () => {}), /Start a new search/);
|
|
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
|
|
dbg.close();
|
|
dbg2.close();
|
|
});
|
|
|
|
test('script API: read, write, addresses, patterns, pointers, calls, other entries', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const toggled = [];
|
|
const api = { setEntry: async (name, on) => void toggled.push([name, on]) };
|
|
const call = (method, ...args) => handleScriptCall(ctx, api, method, args);
|
|
|
|
const health = await call('pointer', 'game+10100', [0x340]);
|
|
assert.equal(health, HEALTH);
|
|
assert.equal(await call('read', health, 'u32'), 100);
|
|
await call('write', health, 'u32', 250);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 250);
|
|
await call('write', health, 'u32', 100);
|
|
assert.equal(await call('read', GAME.heapBase + BigInt(GAME.vars.gold), 'float'), 2500);
|
|
assert.equal(typeof (await call('read', GAME.dataBase + 0x100n, 'u64')), 'bigint');
|
|
|
|
assert.equal(await call('address', 'game+1000'), SITE);
|
|
assert.equal(await call('address', { kind: 'module', offset: '1000' }), SITE);
|
|
assert.equal(await call('find', '89 81 90 03 00 00'), SITE);
|
|
assert.equal(await call('find', 'DE AD BE EF 00 11'), null);
|
|
assert.equal(await call('readBytes', SITE, 6), '89 81 90 03 00 00');
|
|
await call('writeBytes', SITE, '90 90 90 90 90 90');
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '909090909090');
|
|
await call('writeBytes', SITE, '898190030000');
|
|
assert.equal(await call('call', 'game+4000', [40, 2n]), 42n);
|
|
await call('enable', 'Infinite health');
|
|
await call('disable', 'Infinite health');
|
|
assert.deepEqual(toggled, [['Infinite health', true], ['Infinite health', false]]);
|
|
await call('notify', 'hello from a script');
|
|
assert.equal(mock.state.notifications.at(-1), 'hello from a script');
|
|
await assert.rejects(call('readBytes', SITE, 0), /between 1 and/);
|
|
await assert.rejects(call('launchMissiles'), /Unknown script function/);
|
|
dbg.close();
|
|
});
|
|
|
|
test('own tables: export, import, and what gets flagged or left out', () => {
|
|
const table = emptyTable('PPSA99999', '01.000.000', 'Demo');
|
|
table.entries.push(
|
|
{ id: '1', type: 'patch', name: 'P', at: { kind: 'module', offset: '1000' }, on: '9090', off: '8981' },
|
|
{ id: '2', type: 'patch', name: 'Sig', at: { kind: 'signature', pattern: '89 81', offset: 0 }, on: '9090' },
|
|
{ id: '3', type: 'value', name: 'Temp', at: { kind: 'address', address: '880012340' }, valueType: 'u32', value: '1', freeze: false },
|
|
{ id: '4', type: 'script', name: 'S', code: 'cs.log(1)' },
|
|
{ id: '5', type: 'inject', name: 'I', at: { kind: 'module', offset: '1000' }, code: 'nop', placement: 'before' },
|
|
);
|
|
const text = exportOwnTable(table);
|
|
assert.ok(!text.includes('"id"'));
|
|
const back = parseOwnTable(text);
|
|
assert.deepEqual(back.entries.map((e) => e.name), ['P', 'Sig', 'S', 'I'], 'raw-address entries are not exported');
|
|
assert.deepEqual(back.entries.map((e) => !!e.unreviewed), [false, false, true, true], 'anything that runs code needs a look first');
|
|
assert.ok(back.entries.every((e) => e.id && e.id.length >= 6));
|
|
|
|
const community = exportCommunityTable(table);
|
|
assert.equal(community.included, 1);
|
|
assert.equal(community.skipped, 4);
|
|
assert.deepEqual(JSON.parse(community.text).mods[0].memory[0], { offset: '1000', on: '9090', off: '8981' });
|
|
|
|
assert.throws(() => parseOwnTable('{"mods":[]}'), /not a CheatSpecter table/);
|
|
assert.throws(() => parseOwnTable('nope'), /not valid JSON/);
|
|
});
|
|
|
|
test('a patch already in the game is shown as on and can be switched off', async () => {
|
|
const { dbg, ctx } = await context();
|
|
const entry = { id: 'adopt1', name: 'Ammo', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'FF4B28' };
|
|
await dbg.write(GAME.pid, GAME.imageBase + 0x2040n, hexToBytes('909090'));
|
|
const runner = new TableRunner(ctx);
|
|
assert.equal(runner.isOn('adopt1'), false);
|
|
assert.equal(await runner.adopt([entry]), true);
|
|
assert.equal(runner.isOn('adopt1'), true);
|
|
assert.equal(await runner.adopt([entry]), false, 'nothing new the second time');
|
|
await runner.disable(entry);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), 'FF4B28');
|
|
assert.equal(await new TableRunner(ctx).adopt([entry]), false, 'off stays off');
|
|
});
|
|
|
|
test('one switch can change several places, all or nothing', async () => {
|
|
const { ctx } = await context();
|
|
const runner = new TableRunner(ctx);
|
|
const multi = {
|
|
id: 'multi1', name: 'Two places', type: 'patch',
|
|
at: { kind: 'module', offset: '0x1000' }, on: '909090909090', off: '898190030000',
|
|
more: [{ at: { kind: 'signature', pattern: 'FF 4B 28', offset: 0 }, on: '909090', off: 'FF4B28' }],
|
|
};
|
|
assert.match(describeEntry(multi), /and 1 more place$/);
|
|
await runner.enable(multi);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '909090909090');
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), '909090');
|
|
await runner.disable(multi);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '898190030000');
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), 'FF4B28');
|
|
|
|
// The second place does not fit: nothing is written, not even the first.
|
|
const bad = { ...multi, id: 'multi2', more: [{ at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'AABBCC' }] };
|
|
await assert.rejects(runner.enable(bad), MismatchError);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '898190030000');
|
|
|
|
// Shown as on only when every place is already applied.
|
|
const both = { ...multi, id: 'multi3', more: [{ at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'FF4B28' }] };
|
|
mock.game.image.set(hexToBytes('909090909090'), 0x1000);
|
|
assert.equal(await new TableRunner(ctx).adopt([both]), false);
|
|
mock.game.image.set(hexToBytes('909090'), 0x2040);
|
|
const fresh = new TableRunner(ctx);
|
|
assert.equal(await fresh.adopt([both]), true);
|
|
await fresh.disable(both);
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '898190030000');
|
|
assert.equal(bytesToHex(mock.game.image.subarray(0x2040, 0x2043)), 'FF4B28');
|
|
|
|
// The GoldHEN export carries every place.
|
|
const out = JSON.parse(exportCommunityTable({ ...emptyTable('PPSA99999', '01.000.000'), entries: [both] }).text);
|
|
assert.equal(out.mods[0].memory.length, 2);
|
|
// And the file format keeps them.
|
|
const back = parseOwnTable(exportOwnTable({ ...emptyTable('PPSA99999', '01.000.000'), entries: [multi] }));
|
|
assert.equal(back.entries[0].more.length, 1);
|
|
});
|