Files
Tiago Valente 814e5bdfc5 CheatSpecter 0.4.0
- 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
2026-10-10 12:06:50 -07:00

663 lines
31 KiB
JavaScript

// What the debugger screens are built on, against the mock console: who holds
// the one debugger attach (attach.ts), the desk that opens and closes a
// debugging session for the screens (debugdesk.ts), the helpers that turn a
// stopped game into rows (debugview.ts), the structure viewer's helpers, and
// pointer candidates kept between restarts (pointerstore.ts).
import test, { after, afterEach, 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 } from '../src/lib/x86.ts';
import { createGameContext } from '../src/lib/locations.ts';
import { AttachBusyError, AttachGate, attachGate } from '../src/lib/attach.ts';
import { DebugDesk, debugDesk } from '../src/lib/debugdesk.ts';
import { describeStop, fmtNumber, kindForInsn, parseRegisterValue, stackWords, xmmWithNumber } from '../src/lib/debugview.ts';
import { Tracer } from '../src/lib/tracer.ts';
import { canBeWide, guessAsValue, guessLabel, readStructure, withPinnedKinds } from '../src/lib/structure.ts';
import { pathsLeadingTo, recheckPointerPaths } from '../src/lib/pointerscan.ts';
import { MAX_SAVED_PATHS, MAX_SAVED_SETS, forgetPaths, loadSavedPaths, savePaths } from '../src/lib/pointerstore.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 = 17540 + PORT_OFFSET;
const EVENTS = 17840 + PORT_OFFSET;
let mock, bridge, port;
const P = (off) => GAME.heapBase + BigInt(off);
const CODE = (name) => GAME.imageBase + BigInt(GAME.code[name].offset);
const HEALTH = P(GAME.vars.health);
const CHAR = GAME.characters.map(P);
const F = GAME.character;
const health = () => mock.game.heap.readUInt32LE(GAME.vars.health);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (cond, what) => {
for (let i = 0; i < 150; i++) {
if (cond()) return;
await wait(20);
}
throw new Error(`timed out waiting for ${what}`);
};
const url = (path = 'ws') => `ws://127.0.0.1:${port}/${path}?host=127.0.0.1&port=${TCP}`;
const connect = async () => new Ps5Debug(await openWebSocketStream(url()), { timeoutMs: 4000 });
const deps = { openDebugger: () => connect(), openEvents: () => openWebSocketStream(url('events')) };
/** A game context, a gate and a desk, as one tab of the app would have them. */
const desks = [];
const conns = [];
const setup = async () => {
const dbg = await connect();
conns.push(dbg);
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 });
const gate = new AttachGate();
const desk = new DebugDesk(ctx, { ...deps, gate });
desks.push(desk);
return { dbg, ctx, gate, desk };
};
const codeIntact = () => Object.values(GAME.code).every((c) => mock.game.image.subarray(c.offset, c.offset + c.bytes.length).equals(c.bytes));
before(async () => {
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false, eventPort: EVENTS });
await mock.ready;
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: EVENTS, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 't' } });
port = await bridge.ready;
});
after(async () => {
await bridge.close();
await mock.close();
});
// Whatever a test did, the game must come out of it alive, unpatched, let go
// of, running, and without anything unsafe having been sent to the console.
afterEach(async () => {
for (const d of desks.splice(0)) await d.dispose();
for (const c of conns.splice(0)) if (!c.closed) c.close();
await until(() => mock.debug.owner === null, 'the debugger to be detached');
assert.equal(mock.state.crashed, null, 'the game did not crash');
assert.deepEqual(mock.state.hazards, [], 'nothing unsafe was sent to the console');
assert.ok(codeIntact(), 'no breakpoint byte was left in the game');
assert.equal(mock.debug.stopped, false, 'the game is running');
const h = health();
mock.tick();
assert.notEqual(health(), h, 'the game still runs');
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
});
// ---- the gate ---------------------------------------------------------------------------------
test('gate: one holder at a time, named when busy, and stoppable', async () => {
const gate = new AttachGate();
const seen = [];
const off = gate.subscribe(() => seen.push(gate.current?.id ?? null));
assert.equal(gate.current, null);
let stopped = 0;
const trace = { id: 'trace', label: 'What touches a value', stop: async () => void stopped++ };
const release = gate.claim(trace);
assert.equal(gate.current, trace);
// A second part of the app is refused at once, with the holder's name in words for the user.
assert.throws(
() => gate.claim({ id: 'debug', label: 'Pause and step', stop() {} }),
(e) => e instanceof AttachBusyError && e.holder === trace && /What touches a value is using the debugger/.test(e.message),
);
// The same part claiming again (a second trace) just carries on.
const again = { ...trace };
const release2 = gate.claim(again);
assert.equal(gate.current, again);
release(); // the old handle no longer counts
assert.equal(gate.current, again);
release2();
assert.equal(gate.current, null);
// Stopping the holder asks it to stop and frees the gate even if it does not do so itself, or fails.
gate.claim(trace);
await gate.stopCurrent();
assert.equal(stopped, 1);
assert.equal(gate.current, null);
gate.claim({ id: 'x', label: 'X', stop: () => Promise.reject(new Error('gone')) });
await gate.stopCurrent();
assert.equal(gate.current, null);
await gate.stopCurrent(); // nothing to stop
off();
assert.deepEqual(seen, ['trace', 'trace', null, 'trace', null, 'x', null]);
// One gate per console connection, whoever asks.
const session = {};
assert.equal(attachGate(session), attachGate(session));
assert.notEqual(attachGate(session), attachGate({}));
});
// ---- the desk ---------------------------------------------------------------------------------
test('desk: pausing opens a session, resuming with nothing left closes it', async () => {
const { gate, desk } = await setup();
let changes = 0;
desk.subscribe(() => changes++);
assert.equal(desk.state, 'idle');
assert.deepEqual(desk.breakpoints(), []);
assert.equal(mock.debug.owner, null, 'nothing is attached until something needs it');
const stop = await desk.pause();
assert.equal(stop.cause.kind, 'pause');
assert.equal(desk.state, 'stopped');
assert.equal(desk.stop, stop);
assert.equal(mock.debug.stopped, true);
assert.equal(gate.current?.id, 'debug');
assert.equal(gate.current?.label, 'Pause and step');
assert.ok(changes > 0, 'screens are told');
// Stepping keeps it paused.
const stepped = await desk.step();
assert.equal(stepped.cause.kind, 'step');
assert.equal(desk.state, 'stopped');
await desk.resume();
assert.equal(desk.state, 'idle', 'no breakpoint, nothing collected: the game is let go of');
assert.equal(desk.session, null);
assert.equal(gate.current, null);
await until(() => mock.debug.owner === null, 'detach');
assert.equal(mock.debug.stopped, false);
// And it can be done again.
await desk.pause();
assert.equal(desk.state, 'stopped');
await desk.resume();
assert.equal(desk.state, 'idle');
});
test('desk: breakpoints keep the session open; code is shown without the breakpoint byte', async () => {
const { dbg, desk, gate } = await setup();
const at = CODE('healthDrain');
const bp = await desk.addBreakpoint(at);
assert.equal(desk.state, 'running');
assert.deepEqual(desk.breakpoints(), [bp]);
assert.equal(bp.where, 'game+0x1000');
// Any connection sees the INT3; the desk puts the game's own byte back for display.
const raw = await dbg.read(GAME.pid, at - 2n, 8);
assert.equal(raw[2], 0xcc);
const shown = desk.unpatch(at - 2n, raw);
assert.equal(shown[2], GAME.code.healthDrain.bytes[0]);
assert.notEqual(shown, raw);
assert.equal(desk.unpatch(at + 0x100n, raw), raw, 'untouched bytes are handed back as they are');
assert.deepEqual(desk.breakpointsIn(at - 2n, 8), [bp]);
assert.deepEqual(desk.breakpointsIn(at + 1n, 8), []);
mock.tick();
await until(() => desk.state === 'stopped', 'the breakpoint to be reached');
assert.equal(desk.stop.cause.kind, 'breakpoint');
assert.equal(desk.stop.cause.breakpoint, bp);
assert.equal(bp.hits, 1);
assert.match(describeStop(desk.stop), /reached your breakpoint at game\+0x1000/);
// Resuming with the breakpoint still there keeps the session.
await desk.resume();
assert.equal(desk.state, 'running');
assert.equal(gate.current?.id, 'debug');
// Switched off, it stays listed, so the session stays.
await desk.setBreakpointEnabled(bp, false);
assert.equal(bp.enabled, false);
const stale = raw.subarray(2);
assert.equal(desk.unpatch(at, stale), stale, 'nothing to put back once it is off');
assert.equal(desk.state, 'running');
mock.tick();
await wait(60);
assert.equal(desk.state, 'running', 'a switched-off breakpoint does not stop the game');
// Removing the last one while the game runs lets go of the game.
await desk.removeBreakpoint(bp);
assert.equal(desk.state, 'idle');
assert.equal(gate.current, null);
// A breakpoint that cannot be set does not leave a session behind holding the game.
await assert.rejects(desk.addBreakpoint(HEALTH), /not inside the game's code/);
assert.equal(desk.state, 'idle');
await until(() => mock.debug.owner === null, 'detach');
});
test('desk: switching breakpoints off and removing them while stopped never sends the sequence that crashed a game', async () => {
// On a console, disarming a breakpoint through the debugger while the game
// is stopped with another one armed crashed the game. The mock records that
// sequence in `state.hazards`; everything a screen can do must leave it empty.
const { desk } = await setup();
const a = await desk.addBreakpoint(CODE('healthDrain'));
const b = await desk.addBreakpoint(CODE('scoreAdd'));
const c = await desk.addBreakpoint(CODE('ammoSpend'));
mock.tick();
await until(() => desk.state === 'stopped', 'the first breakpoint');
assert.equal(desk.stop.cause.breakpoint, a);
// Stopped at A with B and C armed: switch another off, then the one it stands on, then remove one.
await desk.setBreakpointEnabled(b, false);
await desk.setBreakpointEnabled(a, false);
await desk.removeBreakpoint(b);
assert.deepEqual(mock.state.hazards, []);
assert.equal(mock.debug.stopped, true, 'none of that set the game running behind our back');
assert.deepEqual(desk.breakpoints(), [a, c]);
// Resume: C is still armed and is reached next.
await desk.resume();
await until(() => desk.state === 'stopped', 'the remaining breakpoint');
assert.equal(desk.stop.cause.breakpoint, c);
// Back on, while stopped at another.
await desk.setBreakpointEnabled(a, true);
await desk.removeBreakpoint(c);
await desk.resume();
mock.tick();
await until(() => desk.state === 'stopped', 'the re-enabled breakpoint');
assert.equal(desk.stop.cause.breakpoint, a);
assert.equal(a.hits, 2);
// Closing from a stop with a breakpoint armed is the last way to get it wrong.
await desk.close();
assert.equal(desk.state, 'idle');
assert.deepEqual(mock.state.hazards, []);
assert.equal(mock.state.crashed, null);
});
test('desk: registers can be changed after a breakpoint, not after a pause', async () => {
const { desk } = await setup();
await desk.pause();
await assert.rejects(desk.setRegisters({ rax: 1n }), /not after a pause/);
await desk.resume();
await desk.addBreakpoint(CODE('healthDrain'));
mock.tick();
await until(() => desk.state === 'stopped', 'the breakpoint');
const before = desk.stop;
const next = await desk.setRegisters({ r15: 0x1234n });
assert.equal(next.regs.r15, 0x1234n);
assert.equal(desk.stop, next, 'the screens see the new registers');
assert.notEqual(next, before);
assert.equal(next.regs.rax, before.regs.rax, 'the others are untouched');
});
test('coexistence: the Tracer and the desk never both attach, and neither hangs', async () => {
const { ctx, gate, desk } = await setup();
// The desk has the debugger; a trace is refused with a message, not left waiting.
await desk.addBreakpoint(CODE('scoreAdd'));
let stopTrace = async () => {};
const startTrace = async () => {
const release = gate.claim({ id: 'trace', label: 'What touches a value', stop: () => stopTrace() });
const tracer = new Tracer(ctx, deps);
stopTrace = async () => {
await tracer.stop();
release();
};
await tracer.start(HEALTH, 4, 'write', () => {});
return tracer;
};
await assert.rejects(startTrace(), (e) => e instanceof AttachBusyError && e.holder.id === 'debug' && e.holder.label === 'Pause and step');
// The user says "stop it": the desk lets go completely, and the trace starts.
await gate.stopCurrent();
assert.equal(desk.state, 'idle');
assert.deepEqual(desk.breakpoints(), []);
assert.equal(gate.current, null);
const tracer = await startTrace();
assert.equal(tracer.running, true);
assert.equal(gate.current?.id, 'trace');
mock.tick();
await until(() => tracer.list().length > 0, 'a traced write');
// The other way round: every desk action is refused at once while the trace has it.
const t0 = Date.now();
for (const attempt of [() => desk.pause(), () => desk.addBreakpoint(CODE('healthDrain')), () => desk.startAccess(CODE('staminaTick'))]) {
await assert.rejects(attempt(), (e) => e instanceof AttachBusyError && e.holder.id === 'trace');
}
assert.ok(Date.now() - t0 < 1000, 'refused immediately');
assert.equal(desk.state, 'idle');
assert.equal(tracer.running, true, 'the trace was not disturbed');
// Stop the trace through the gate, and the desk works.
await gate.stopCurrent();
assert.equal(tracer.running, false);
assert.equal(gate.current, null);
const stop = await desk.pause();
assert.equal(stop.cause.kind, 'pause');
await desk.resume();
});
test('coexistence: something outside the app holding the debugger gives a message, and the gate is not left taken', async () => {
const { desk, gate } = await setup();
// Another program (the MCP server, a second browser) attached first.
const other = await connect();
const events = await openWebSocketStream(url('events'));
await other.debugAttach(GAME.pid);
await assert.rejects(desk.pause(), (e) => e instanceof Error && e.message.length > 0 && !(e instanceof AttachBusyError));
assert.equal(desk.state, 'idle');
assert.equal(gate.current, null, 'a failed open gives the attach back');
await other.debugDetach();
other.close();
events.close();
await until(() => mock.debug.owner === null, 'detach');
// And afterward it works.
await desk.pause();
await desk.resume();
});
test('desk: what an instruction touches is collected inside the same session as the user\'s breakpoints', async () => {
const { desk, gate } = await setup();
const mine = await desk.addBreakpoint(CODE('loadPlayer'));
await desk.setBreakpointEnabled(mine, false);
await desk.startAccess(CODE('staminaTick'), { maxHits: 6, maxSeconds: 20 });
assert.equal(desk.collecting, true);
assert.deepEqual(desk.breakpoints(), [mine], 'the collector\'s own breakpoint is not shown as the user\'s');
assert.equal(gate.current?.label, 'Pause and step', 'the user has a breakpoint, so that is what stopping would cost');
await assert.rejects(desk.addBreakpoint(CODE('staminaTick')), /being watched for the addresses it touches/);
// Two frames of the game, three characters each.
mock.tick();
await until(() => (desk.access?.hits ?? 0) >= 3 && !mock.debug.stopped, 'the first frame');
mock.tick();
await until(() => !desk.collecting, 'the hit limit');
const result = desk.access;
assert.equal(result.done, true);
assert.match(result.ended, /Stopped after 6 hits/);
assert.deepEqual(result.addresses.map((a) => a.address).sort(), CHAR.map((c) => c + BigInt(F.stamina)).sort());
assert.deepEqual(result.addresses.map((a) => a.count), [2, 2, 2]);
assert.deepEqual(result.addresses.map((a) => a.base).sort(), [...CHAR].sort());
// The session stays for the user's breakpoint.
assert.equal(desk.state, 'running');
assert.deepEqual(desk.breakpoints(), [mine]);
// A breakpoint of the user's on the instruction is refused in plain words.
const onIt = await desk.addBreakpoint(CODE('slotRead'));
await assert.rejects(desk.startAccess(CODE('slotRead')), /You have a breakpoint on this instruction/);
await desk.removeBreakpoint(onIt);
await desk.removeBreakpoint(mine);
assert.equal(desk.state, 'idle');
});
test('desk: collecting on its own opens a session and lets go when it ends or is stopped', async () => {
const { desk, gate } = await setup();
await desk.startAccess(CODE('slotRead'), { maxHits: 100, maxSeconds: 20 });
assert.equal(desk.state, 'running');
assert.equal(gate.current?.label, 'What this code touches');
mock.tick();
await until(() => (desk.access?.hits ?? 0) >= 3, 'three hits');
await desk.stopAccess();
assert.equal(desk.collecting, false);
assert.equal(desk.access.done, true);
assert.equal(desk.access.addresses.length, 3, 'what was found stays');
assert.equal(desk.state, 'idle');
assert.equal(gate.current, null);
// Something that cannot be collected says why and leaves nothing attached.
await assert.rejects(desk.startAccess(CODE('loadPlayer')), /always uses the same address/);
assert.equal(desk.state, 'idle');
assert.equal(gate.current, null);
await until(() => mock.debug.owner === null, 'detach');
// Ending at the hit limit lets go by itself.
await desk.startAccess(CODE('staminaTick'), { maxHits: 3, maxSeconds: 20 });
mock.tick();
await until(() => desk.state === 'idle', 'the desk to let go');
assert.equal(desk.access.addresses.length, 3);
});
test('desk: going away while the game is paused resumes it', async () => {
const { desk, gate } = await setup();
await desk.addBreakpoint(CODE('healthDrain'));
mock.tick();
await until(() => desk.state === 'stopped', 'the breakpoint');
assert.equal(mock.debug.stopped, true);
await desk.dispose();
assert.equal(desk.state, 'idle');
assert.equal(gate.current, null);
await until(() => mock.debug.owner === null, 'detach');
assert.equal(mock.debug.stopped, false, 'the game runs again');
assert.ok(codeIntact());
await assert.rejects(desk.pause(), /no longer the one on screen/);
desk.revive();
await desk.pause();
await desk.resume();
});
test('desk: the console ending the session is reported, and the gate is freed', async () => {
const { desk, gate } = await setup();
await desk.addBreakpoint(CODE('healthDrain'));
// The game quits: the console drops the debugger connection.
mock.debug.owner.destroy();
await until(() => desk.state === 'idle', 'the desk to notice');
assert.match(desk.ended, /ended the debugging session|connection/i);
assert.equal(gate.current, null);
assert.deepEqual(desk.breakpoints(), []);
await until(() => mock.debug.owner === null, 'teardown');
// A new session clears the message.
await desk.pause();
assert.equal(desk.ended, null);
await desk.resume();
});
test('desk: one per game context', async () => {
const dbg = await connect();
conns.push(dbg);
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 });
let made = 0;
const make = () => {
made++;
return { ...deps, gate: new AttachGate() };
};
const a = debugDesk(ctx, make);
assert.equal(debugDesk(ctx, make), a);
assert.equal(made, 1);
});
// ---- rows for the stopped game ---------------------------------------------------------------
test('view: the stack, with return addresses picked out', async () => {
const { ctx, desk } = await setup();
await desk.addBreakpoint(CODE('healthDrain'));
mock.tick();
await until(() => desk.state === 'stopped', 'the breakpoint');
const session = desk.session;
const regs = desk.stop.regs;
const chain = await session.stack(16);
assert.deepEqual(chain.map((e) => [e.kind, e.where]), [
['current', 'game+0x1006'],
['return', 'game+0x5010'],
['return', 'game+0x5104'],
['return', 'libkernel.sprx+0x100'],
]);
// Plant a function pointer, a data pointer, a heap pointer and a pointer into the stack itself among the frames.
const stack = mock.game.stack;
const top = Number(regs.rsp - GAME.stackBase);
stack.writeBigUInt64LE(GAME.imageBase + 0x4000n, top);
stack.writeBigUInt64LE(GAME.dataBase + 0x100n, top + 8);
stack.writeBigUInt64LE(HEALTH, top + 16);
stack.writeBigUInt64LE(regs.rsp + 0x40n, top + 24);
stack.writeBigUInt64LE(42n, top + 32);
const words = await stackWords(ctx, regs.rsp, await ctx.dbg().read(ctx.pid, regs.rsp, 0x100), chain);
assert.equal(words.length, 32);
assert.deepEqual(words.slice(0, 5).map((w) => [w.offset, w.kind, w.label]), [
[0, 'code', 'game+0x4000'],
[8, 'module', 'game+0x10100'],
[16, 'memory', undefined],
[24, 'stack', 'stack +0x40'],
[32, undefined, undefined],
]);
assert.equal(words[4].value, 42n);
assert.equal(words[1].address, regs.rsp + 8n);
const returns = words.filter((w) => w.kind === 'return');
assert.deepEqual(returns.map((w) => w.label), ['game+0x5010', 'game+0x5104', 'libkernel.sprx+0x100']);
assert.deepEqual(returns.map((w) => w.offset), [0x48, 0x88, 0xc8]);
// The saved frame pointers beside them point further up the same stack.
assert.equal(words[0x40 / 8].kind, 'stack');
for (const off of [0, 8, 16, 24, 32]) stack.writeBigUInt64LE(0n, top + off);
await desk.close();
});
test('view: what people type into a register, and how numbers are shown', () => {
assert.equal(parseRegisterValue('1A2b'), 0x1a2bn);
assert.equal(parseRegisterValue(' 0x10 '), 16n);
assert.equal(parseRegisterValue('#100'), 100n);
assert.equal(parseRegisterValue('-1'), 0xffffffffffffffffn);
assert.equal(parseRegisterValue('-#2'), 0xfffffffffffffffen);
assert.equal(parseRegisterValue('FFFF_FFFF 0000 0000'), 0xffffffff00000000n);
assert.throws(() => parseRegisterValue(''), /Enter a value/);
assert.throws(() => parseRegisterValue('health'), /is not a number\. Type hex/);
assert.throws(() => parseRegisterValue('1' + '0'.repeat(16)), /too large/);
const current = Uint8Array.from({ length: 16 }, (_, i) => i + 1);
const f = xmmWithNumber(current, 'float', '2.5');
assert.equal(new DataView(f.buffer).getFloat32(0, true), 2.5);
assert.deepEqual([...f.subarray(4)], [...current.subarray(4)], 'the rest of the register is kept');
const d = xmmWithNumber(current, 'double', '-0.125');
assert.equal(new DataView(d.buffer).getFloat64(0, true), -0.125);
assert.deepEqual([...d.subarray(8)], [...current.subarray(8)]);
assert.throws(() => xmmWithNumber(current, 'float', 'lots'), /is not a number/);
assert.throws(() => xmmWithNumber(current, 'float', ' '), /is not a number/);
assert.equal(fmtNumber(0, true), '0');
assert.equal(fmtNumber(Math.fround(0.1), true), '0.1');
assert.equal(fmtNumber(2500, true), '2500');
assert.equal(fmtNumber(1e-30, true), '1.000e-30');
assert.equal(fmtNumber(NaN, false), 'NaN');
const one = (hex) => sync.disassemble(Buffer.from(hex.replace(/ /g, ''), 'hex'), 0x1000n, 1)[0];
assert.equal(kindForInsn(one('89 81 90 03 00 00')), 'u32'); // mov [rcx+0x390],eax
assert.equal(kindForInsn(one('F3 0F 5C 47 14')), 'float'); // subss xmm0,[rdi+0x14]
assert.equal(kindForInsn(one('F2 0F 10 45 F8')), 'double'); // movsd xmm0,[rbp-8]
assert.equal(kindForInsn(one('48 8B 47 38')), 'u64'); // mov rax,[rdi+0x38]
assert.equal(kindForInsn(one('C6 47 18 01')), 'u8'); // mov byte [rdi+0x18],1
assert.equal(kindForInsn(one('66 89 47 18')), 'u16'); // mov [rdi+0x18],ax
assert.equal(kindForInsn(one('A5')), 'u32', 'the string copy called movsd is not a decimal'); // movsd
const signal = (n) => describeStop({ cause: { kind: 'signal', signal: n } });
assert.match(signal(11), /memory it does not have/);
assert.match(signal(4), /not a valid instruction/);
assert.match(signal(6), /signal 6/);
assert.match(describeStop({ cause: { kind: 'pause' } }), /You paused the game/);
assert.match(describeStop({ cause: { kind: 'step' } }), /One instruction ran/);
assert.match(describeStop({ cause: { kind: 'watchpoint', watchpoint: { kind: 'write', address: 0x880012340n } } }), /wrote to 0x880012340/);
});
// ---- the structure viewer's helpers -----------------------------------------------------------
test('structure: a row\'s type can be overruled, and a row becomes a cheat value', async () => {
const { ctx } = await setup();
const guessed = await readStructure(ctx, CHAR[0], 0x40);
const at = (s, off) => s.rows.find((r) => r.offset === off);
assert.equal(at(guessed, F.stamina).guesses[0].kind, 'int');
assert.equal(at(guessed, F.hp).guesses[0].kind, 'float');
assert.equal(at(guessed, F.speed).guesses[0].kind, 'double');
// The user pins stamina to a decimal and hp to a whole number; an engine field keeps its name.
const engine = [{ offset: F.hp, name: 'Health', type: 'float' }, { offset: F.team, name: 'Team', type: 'int32' }];
const fields = withPinnedKinds(engine, new Map([[F.stamina, 'float'], [F.hp, 'int'], [F.speed, 'pointer']]));
assert.deepEqual(fields, [
{ offset: F.stamina, name: '', type: 'float' },
{ offset: F.hp, name: 'Health', type: 'int32' },
{ offset: F.team, name: 'Team', type: 'int32' },
{ offset: F.speed, name: '', type: 'void*' },
]);
assert.deepEqual(withPinnedKinds(undefined, new Map()), []);
const pinned = await readStructure(ctx, CHAR[0], 0x40, { fields });
assert.equal(at(pinned, F.stamina).guesses[0].kind, 'float');
assert.equal(at(pinned, F.hp).guesses[0].kind, 'int');
assert.equal(at(pinned, F.hp).field.name, 'Health');
assert.equal(at(pinned, F.speed).guesses[0].kind, 'pointer');
assert.equal(at(pinned, F.team).field.name, 'Team');
assert.equal(canBeWide(0x38, 0x40), true);
assert.equal(canBeWide(0x3c, 0x40), false);
assert.equal(canBeWide(0x14, 0x40), false);
assert.deepEqual(guessAsValue(at(guessed, F.hp).guesses[0]), { type: 'float', value: '120' });
assert.deepEqual(guessAsValue(at(guessed, F.stamina).guesses[0]), { type: 'u32', value: String(mock.game.heap.readUInt32LE(GAME.characters[0] + F.stamina)) });
assert.deepEqual(guessAsValue(at(guessed, F.speed).guesses[0]), { type: 'double', value: '4.5' });
assert.deepEqual(guessAsValue(at(guessed, F.name).guesses[0]), { type: 'u64', value: String(at(guessed, F.name).guesses[0].value) });
assert.deepEqual(guessAsValue({ kind: 'int', size: 4, text: '-3', value: -3 }), { type: 'i32', value: '-3' });
assert.deepEqual(guessAsValue({ kind: 'int', size: 8, text: '0x1FFFFFFFF', value: 0x1ffffffffn }), { type: 'u64', value: '8589934591' });
assert.equal(guessAsValue({ kind: 'text', size: 4, text: '"abcd"', value: 'abcd' }), null);
assert.deepEqual(
['pointer', 'float', 'double', 'text'].map((kind) => guessLabel({ kind, size: 8 })),
['Pointer', 'Decimal', 'Decimal, 8 bytes', 'Text'],
);
assert.equal(guessLabel({ kind: 'int', size: 4 }), 'Whole number');
assert.equal(guessLabel({ kind: 'int', size: 8 }), 'Whole number, 8 bytes');
});
// ---- pointer candidates between restarts --------------------------------------------------------
test('pointer paths: candidates are kept per game, and narrowed to the ones that lead to the value again', async () => {
const { ctx } = await setup();
// A browser's storage, as far as storage.ts uses it.
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => void store.set(k, String(v)),
removeItem: (k) => void store.delete(k),
};
try {
assert.deepEqual(loadSavedPaths('PPSA99999'), []);
const hp = CHAR[1] + BigInt(F.hp);
const real = { location: { kind: 'pointer', base: '4080', offsets: ['0x14'], module: 'libgame.prx' }, depth: 1 };
const lucky = { location: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, depth: 1 }; // leads to the player's health
const broken = { location: { kind: 'pointer', base: '10108', offsets: ['0x10'] }, depth: 1 };
const set = { id: 'a', name: 'Float 80, found at 0x880014114', type: 'float', foundAt: 1000, pid: 55, checks: 0, paths: [real, lucky, broken] };
assert.equal(savePaths('PPSA99999', set), true);
assert.deepEqual(loadSavedPaths('PPSA99999'), [set]);
assert.deepEqual(loadSavedPaths('PPSA00001'), [], 'another game has its own list');
// "After the restart": follow each candidate, keep the ones that lead to where the value is now.
const results = await recheckPointerPaths(ctx, loadSavedPaths('PPSA99999')[0].paths);
assert.deepEqual(pathsLeadingTo(results, hp), [real]);
assert.deepEqual(pathsLeadingTo(results, HEALTH), [lucky]);
assert.deepEqual(pathsLeadingTo(results, hp + 4n), []);
savePaths('PPSA99999', { ...set, pid: GAME.pid, checks: 1, paths: pathsLeadingTo(results, hp) });
assert.deepEqual(loadSavedPaths('PPSA99999'), [{ ...set, pid: GAME.pid, checks: 1, paths: [real] }], 'the set is replaced, not added again');
// Newest first, and the lists do not grow without limit.
for (let i = 0; i < MAX_SAVED_SETS + 2; i++) savePaths('PPSA99999', { ...set, id: `n${i}`, paths: Array.from({ length: MAX_SAVED_PATHS + 5 }, () => lucky) });
const all = loadSavedPaths('PPSA99999');
assert.equal(all.length, MAX_SAVED_SETS);
assert.equal(all[0].id, `n${MAX_SAVED_SETS + 1}`);
assert.equal(all[0].paths.length, MAX_SAVED_PATHS);
assert.ok(!all.some((s) => s.id === 'a'), 'the oldest fell off');
forgetPaths('PPSA99999', all[0].id);
assert.equal(loadSavedPaths('PPSA99999').length, MAX_SAVED_SETS - 1);
for (const s of loadSavedPaths('PPSA99999')) forgetPaths('PPSA99999', s.id);
assert.equal(store.size, 0, 'nothing is left behind');
// Garbage in storage is dropped rather than trusted.
store.set('cheatspecter.pointers.PPSA99999', JSON.stringify([{ id: 'x', paths: [{ location: { kind: 'module', offset: '1' } }, lucky] }, { id: 'y', paths: 'no' }, null, { id: 'z', paths: [] }]));
const cleaned = loadSavedPaths('PPSA99999');
assert.equal(cleaned.length, 1);
assert.deepEqual(cleaned[0].paths, [lucky]);
assert.equal(cleaned[0].type, 'u32');
store.set('cheatspecter.pointers.PPSA99999', '{"not":"a list"}');
assert.deepEqual(loadSavedPaths('PPSA99999'), []);
} finally {
delete globalThis.localStorage;
}
// Without any storage at all (a private window) nothing throws.
assert.deepEqual(loadSavedPaths('PPSA99999'), []);
assert.equal(savePaths('PPSA99999', { id: 'a', name: '', type: 'u32', foundAt: 0, pid: 1, checks: 0, paths: [] }), false);
});