// 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); });