// The debugger core against the mock console: the wire commands, a debugging // session (breakpoints, stepping, pausing, registers), collecting the // addresses an instruction touches, structure reading and comparing, // module-qualified locations and re-checking pointer paths. 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, DEBUG_EVENT_SIZE, encodeRegisters, parseDebugEvent, parseFpRegisters, parseRegisters, withXmm } from '../src/lib/ps5debug.ts'; import { createX86, asAsync, effectiveAddress } from '../src/lib/x86.ts'; import { createGameContext, addressOwner, describeAddress, describeLocation, findReferences, loadedModules, moduleLocation, moduleRange, parseAddress, resolveLocation } from '../src/lib/locations.ts'; import { DebugSession } from '../src/lib/debugger.ts'; import { AccessCollector, groupByBase } from '../src/lib/access.ts'; import { compareStructures, plainKind, readStructure, textAt } from '../src/lib/structure.ts'; import { findPointerPaths, recheckPointerPaths } from '../src/lib/pointerscan.ts'; import { exportCommunityTable, emptyTable } from '../src/lib/table.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 = 17520 + PORT_OFFSET; const EVENTS = 17820 + PORT_OFFSET; let mock, bridge, port; const P = (off) => GAME.heapBase + BigInt(off); const CODE = (name) => GAME.imageBase + BigInt(GAME.code[name].offset); const RET = (name) => CODE(name) + BigInt(GAME.code[name].bytes.length); const HEALTH = P(GAME.vars.health); const AMMO = P(GAME.vars.ammo); const CHAR = GAME.characters.map(P); const F = GAME.character; const health = () => mock.game.heap.readUInt32LE(GAME.vars.health); const ammo = () => mock.game.heap.readUInt32LE(GAME.vars.ammo); 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')) }; const context = async () => { const dbg = await connect(); return { dbg, ctx: createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 }) }; }; /** Opens a session and collects its stops; `next()` waits for the one after those already taken. */ const opened = []; const openSession = async (ctx) => { const session = await DebugSession.open(ctx, deps); opened.push(session); const stops = []; let taken = 0; session.onStop((s) => stops.push(s)); const next = async (what = 'a stop') => { await until(() => stops.length > taken, what); return stops[taken++]; }; return { session, stops, next, pending: () => stops.length - taken }; }; /** Every instruction of the mock game is as it was built: no breakpoint byte left behind. */ 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, and at the start of a frame. afterEach(async () => { // A test that failed half way must not hold the debugger for the rest. for (const s of opened.splice(0)) await s.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'); assert.equal(mock.debug.pc, 0, 'the frame was finished'); const h = health(); mock.tick(); assert.notEqual(health(), h, 'the game still runs'); mock.game.heap.writeUInt32LE(100, GAME.vars.health); mock.game.heap.writeUInt32LE(30, GAME.vars.ammo); }); // ---- x86: memory operands ----------------------------------------------------------------- test('x86: memory operands are decoded and turned into addresses', () => { const one = (hex, at = 0x1000n) => sync.disassemble(Buffer.from(hex.replace(/ /g, ''), 'hex'), at, 1)[0]; const regs = { rax: 1n, rbx: 0x1000n, rcx: 0x2000n, rdx: 4n, rsi: 3n, rdi: 0x5000n, rbp: 0x7000n, rsp: 0x6000n, r8: 0n, r9: 0n, r10: 0n, r11: 0n, r12: 0n, r13: 0n, r14: 0n, r15: 0x10n }; const scaled = one('8B 44 B3 20'); // mov eax,[rbx+rsi*4+0x20] assert.deepEqual( { base: scaled.memory.base, index: scaled.memory.index, scale: scaled.memory.scale, displacement: scaled.memory.displacement, size: scaled.memory.size, access: scaled.memory.access }, { base: 'rbx', index: 'rsi', scale: 4, displacement: 0x20n, size: 4, access: 'read' }, ); assert.equal(effectiveAddress(scaled.memory, regs), 0x1000n + 12n + 0x20n); const store = one('89 81 90 03 00 00'); // mov [rcx+0x390],eax assert.equal(store.memory.access, 'write'); assert.equal(effectiveAddress(store.memory, regs), 0x2390n); const negative = one('F2 0F 10 45 F8'); // movsd xmm0,[rbp-8] assert.equal(negative.memory.displacement, -8n); assert.equal(negative.memory.size, 8); assert.equal(effectiveAddress(negative.memory, regs), 0x6ff8n); const incr = one('FF 47 10'); // inc dword [rdi+0x10] assert.equal(incr.memory.access, 'readwrite'); const fixed = one('48 8B 05 F9 CB 00 00', 0x3500n); // mov rax,[rip+0xCBF9] assert.equal(fixed.memory.fixedAddress, 0x3500n + 7n + 0xcbf9n); assert.equal(fixed.memory.base, null); assert.equal(effectiveAddress(fixed.memory, regs), 0x10100n); const tls = one('64 8B 04 25 30 00 00 00'); // mov eax,fs:[0x30] assert.equal(tls.memory.segment, 'fs'); assert.equal(tls.memory.fixedAddress, null); assert.equal(effectiveAddress(tls.memory, regs), null, 'needs the thread base'); assert.equal(effectiveAddress(tls.memory, regs, { fs: 0x9000n }), 0x9030n); const lea = one('48 8D 04 37'); // lea rax,[rdi+rsi] assert.equal(lea.memory.access, 'none'); assert.equal(lea.memory.size, 0); assert.equal(effectiveAddress(lea.memory, regs), 0x5003n); const narrow = one('67 8B 43 04'); // mov eax,[ebx+4] assert.equal(narrow.memory.addr32, true); assert.equal(effectiveAddress(narrow.memory, { ...regs, rbx: 0x1_ffff_fffen }), 2n, 'a 32-bit address wraps'); const copy = one('F3 A4'); // rep movsb assert.deepEqual([copy.memory.base, copy.memory.access, copy.memory.size], ['rdi', 'write', 1]); assert.equal(one('F3 0F 5C C1').memory, null, 'register-only instruction'); assert.equal(one('50').memory, null, 'a push is not a memory operand'); }); // ---- the wire commands ------------------------------------------------------------------------ test('protocol: threads, registers, vector registers, stop and go, stack, references', async () => { const dbg = await connect(); const events = await openWebSocketStream(url('events')); await dbg.debugAttach(GAME.pid); // Registers of a running game come back as zeros, not as an error. assert.equal((await dbg.getRegisters(GAME.threads[0].lwpid)).rip, 0n); await dbg.debugStop(); await dbg.debugStop(); // harmless when already stopped assert.deepEqual(await dbg.threadList(), GAME.threads.map((t) => t.lwpid)); assert.deepEqual(await dbg.threadInfo(GAME.threads[1].lwpid), { lwpid: GAME.threads[1].lwpid, priority: 700, name: 'RenderThread' }); const main = GAME.threads[0].lwpid; const raw = await dbg.getRegistersRaw(main); const regs = parseRegisters(raw); assert.equal(regs.rip, CODE('healthDrain')); assert.equal(regs.rcx, HEALTH - 0x390n); assert.equal(regs.rsp, GAME.stackBase + 0x7000n); // The inverse keeps what Registers does not carry (segment selectors). const changed = encodeRegisters({ rax: 0x1122334455667788n, r15: 5n }, raw); assert.equal(Buffer.from(changed).readBigUInt64LE(144), 0x43n, 'cs is preserved'); await dbg.setRegistersRaw(main, changed); const back = parseRegisters(await dbg.getRegistersRaw(main)); assert.equal(back.rax, 0x1122334455667788n); assert.equal(back.r15, 5n); assert.equal(back.rip, regs.rip); await dbg.setRegistersRaw(main, raw); assert.throws(() => encodeRegisters({ rax: 1n }, new Uint8Array(10)), /176 bytes/); // Holding one thread back and letting it go again. await dbg.suspendThread(GAME.threads[2].lwpid); await dbg.resumeThread(GAME.threads[2].lwpid); await assert.rejects(dbg.suspendThread(1), /Suspending the thread failed/); assert.equal(dbg.closed, false); const fpRaw = await dbg.getFpRegistersRaw(main); assert.equal(fpRaw.length, 832); assert.equal(parseFpRegisters(fpRaw).mxcsr, 0x1f80); const sixteen = new Uint8Array(16); new DataView(sixteen.buffer).setFloat32(0, 12.5, true); new DataView(sixteen.buffer).setFloat64(8, -2, true); await dbg.setFpRegistersRaw(main, withXmm(fpRaw, 3, sixteen)); const fp = await dbg.getFpRegisters(main); assert.equal(fp.xmm.length, 16); assert.equal(fp.xmm[3].floats[0], 12.5); assert.equal(fp.xmm[3].doubles[1], -2); await dbg.setFpRegistersRaw(main, fpRaw); assert.deepEqual(await dbg.getFsGsBase(main), { fs: 0x7ef006000n, gs: 0n }); // The console walks the saved frame pointers. const frames = await dbg.readStack(GAME.pid, regs.rbp, regs.rsp, 16); assert.deepEqual(frames.map((f) => f.returnAddress), [GAME.imageBase + 0x5010n, GAME.imageBase + 0x5104n, 0x800000100n]); assert.equal(frames[0].locals.length, 0x48, 'rsp up to and including the saved pair'); assert.equal(frames[0].code.length, 200); assert.equal(frames[2].savedRbp, 0n); // A thread step is reported on the event channel, with the debug status cleared. await dbg.debugStepThread(main); const ev = parseDebugEvent(await events.read(DEBUG_EVENT_SIZE)); assert.deepEqual([ev.lwpid, ev.signal, ev.threadName, ev.regs.rip, ev.dr6], [main, 5, 'MainThread', RET('healthDrain'), 0n]); assert.equal(ev.rawRegs.length, 176); assert.equal(ev.rawFpRegs.length, 832); await dbg.debugResume(); await dbg.debugResume(); // harmless when already running assert.equal(mock.debug.stopped, false); // Code references are found on the console, without a debug session. const other = await connect(); const target = GAME.dataBase + BigInt(GAME.statics.player); assert.deepEqual(await other.findXrefsTo({ pid: GAME.pid, address: GAME.imageBase, length: 0x10000, target }), [CODE('loadPlayer')]); assert.deepEqual(await other.findXrefsTo({ pid: GAME.pid, address: GAME.imageBase, length: 0x10000, target: target + 8n }), []); assert.deepEqual(await other.extractCodeXrefs({ pid: GAME.pid, address: GAME.imageBase, length: 0x10000 }), [target]); other.close(); await dbg.debugDetach(); dbg.close(); events.close(); }); test('protocol: breakpoint slots follow the console rules', async () => { const dbg = await connect(); const events = await openWebSocketStream(url('events')); await dbg.debugAttach(GAME.pid); await assert.rejects(dbg.setBreakpoint(30, true, CODE('ammoSpend')), /0 to 29/); await dbg.setBreakpoint(4, true, CODE('ammoSpend')); assert.equal(mock.game.image[GAME.code.ammoSpend.offset], 0xcc); await dbg.setBreakpoint(4, true, CODE('ammoSpend')); // the same again is fine await assert.rejects(dbg.setBreakpoint(4, true, CODE('scoreAdd')), /Setting the breakpoint failed/, 'a slot cannot be moved while armed'); await assert.rejects(dbg.setBreakpoint(5, true, CODE('ammoSpend')), /Setting the breakpoint failed/, 'one address, one slot'); assert.equal(dbg.closed, false, 'refusals keep the connection'); // The report has the registers from before the instruction, yet it has run. const before = ammo(); mock.tick(); const ev = parseDebugEvent(await events.read(DEBUG_EVENT_SIZE)); assert.equal(ev.regs.rip, CODE('ammoSpend')); assert.equal(ev.regs.rbx, AMMO - 0x28n); assert.equal(ammo(), before - 1); assert.equal((await dbg.getRegisters(ev.lwpid)).rip, RET('ammoSpend')); assert.equal(mock.game.image[GAME.code.ammoSpend.offset], 0xcc, 'armed again'); // Detaching puts the byte back and lets the game go, armed slot or not. await dbg.debugDetach(); assert.equal(mock.game.image[GAME.code.ammoSpend.offset], GAME.code.ammoSpend.bytes[0]); dbg.close(); events.close(); }); test('protocol: disarming a breakpoint at a stop lets the game run, which is unsafe with another armed', async () => { // This is the sequence that crashed a real game: stopped at one breakpoint, // a second one armed, the first disarmed through the console. The console // resumes the game by itself there, and the client's own resume that // follows can throw away a trap at the second breakpoint. The mock records // it so that nothing in the library can do it unnoticed. const dbg = await connect(); const events = await openWebSocketStream(url('events')); await dbg.debugAttach(GAME.pid); await dbg.setBreakpoint(0, true, CODE('healthDrain')); mock.tick(); await events.read(DEBUG_EVENT_SIZE); assert.equal(mock.debug.stopped, true); // Alone, it is merely surprising: the game runs again before any resume. await dbg.setBreakpoint(0, false, CODE('healthDrain')); assert.equal(mock.debug.stopped, false); assert.equal(mock.debug.phase, 'stopped', 'the console still believes the game is stopped'); await dbg.debugResume(); assert.deepEqual(mock.state.hazards, []); await dbg.setBreakpoint(0, true, CODE('healthDrain')); await dbg.setBreakpoint(1, true, CODE('scoreAdd')); mock.tick(); await events.read(DEBUG_EVENT_SIZE); await dbg.setBreakpoint(0, false, CODE('healthDrain')); assert.deepEqual(mock.state.hazards, ['breakpoint disarmed at a stop while another is armed']); mock.state.hazards.length = 0; // So are the commands that make the console freeze a running game by itself. await events.read(DEBUG_EVENT_SIZE); // the second breakpoint, reached meanwhile await dbg.debugResume(); await dbg.threadList(); await dbg.setWatchpoint(0, true, 'write', 4, HEALTH); await dbg.setWatchpoint(0, false, 'write', 4, HEALTH); assert.deepEqual(mock.state.hazards, [ 'threads listed while the game runs with a breakpoint armed', 'watchpoint changed while the game runs with a breakpoint armed', 'watchpoint changed while the game runs with a breakpoint armed', ]); mock.state.hazards.length = 0; await dbg.debugDetach(); dbg.close(); events.close(); }); // ---- a debugging session ----------------------------------------------------------------------- test('session: breakpoint hit, look around, step, change a register, resume', async () => { const { dbg, ctx } = await context(); const { session, next, pending } = await openSession(ctx); const states = []; session.onState((s) => states.push(s)); assert.equal(session.state, 'running'); const bp = await session.addBreakpoint(CODE('healthDrain')); assert.deepEqual([bp.id, bp.where, bp.insn.text, bp.enabled, bp.hits], [0, 'game+0x1000', 'mov [rcx+0x390], eax', true, 0]); assert.equal(await session.addBreakpoint(CODE('healthDrain')), bp, 'one breakpoint per address'); assert.deepEqual(session.breakpoints(), [bp]); // Memory read while it is armed shows the INT3; unpatch gives the real code. const patched = await dbg.read(GAME.pid, CODE('healthDrain'), 6); assert.equal(patched[0], 0xcc); assert.deepEqual(Buffer.from(session.unpatch(CODE('healthDrain'), patched)), GAME.code.healthDrain.bytes); assert.deepEqual(session.breakpointsIn(CODE('healthDrain') - 2n, 4), [bp]); assert.deepEqual(session.breakpointsIn(CODE('healthDrain') + 1n, 4), []); const h = health(); mock.tick(); const stop = await next('the breakpoint'); assert.equal(session.state, 'stopped'); assert.equal(session.stop, stop); assert.equal(stop.cause.kind, 'breakpoint'); assert.equal(stop.cause.breakpoint, bp); assert.equal(bp.hits, 1); assert.deepEqual(stop.thread, { lwpid: GAME.threads[0].lwpid, name: 'MainThread' }); // What triggered it: the instruction and the registers from before it ran... assert.equal(stop.before.address, CODE('healthDrain')); assert.equal(stop.before.insn.text, 'mov [rcx+0x390], eax'); assert.equal(stop.before.regs.rip, CODE('healthDrain')); assert.equal(stop.before.regs.rcx, HEALTH - 0x390n); assert.equal(effectiveAddress(stop.before.insn.memory, stop.before.regs), HEALTH); // ...and where the thread is now: past it, with the write done. assert.equal(health(), h - 1); assert.equal(stop.rip, RET('healthDrain')); assert.equal(stop.where, 'game+0x1006'); assert.equal(stop.insn.text, 'ret'); assert.equal(stop.regs.rip, stop.rip); assert.deepEqual(await session.registers(), stop.regs); // Step: one instruction at a time, stopping before each. let s = await session.step(); assert.equal(s.cause.kind, 'step'); assert.equal(s.rip, CODE('ammoSpend')); assert.equal(s.insn.text, 'dec dword ptr [rbx+0x28]'); assert.equal(s.before, null); assert.equal(s.regs.rbx, AMMO - 0x28n); assert.equal(await next('the step to be reported'), s); // A changed register changes what the next instruction does. const a = ammo(); const slot0 = mock.game.heap.readUInt32LE(GAME.inventory); s = await session.setRegisters({ rbx: P(GAME.inventory) - 0x28n }); assert.equal(s.regs.rbx, P(GAME.inventory) - 0x28n); assert.equal(s.regs.rip, CODE('ammoSpend'), 'the others are untouched'); s = await session.step(); assert.equal(s.rip, RET('ammoSpend')); assert.equal(ammo(), a, 'ammo was spared'); assert.equal(mock.game.heap.readUInt32LE(GAME.inventory), slot0 - 1, 'the redirected write landed'); mock.game.heap.writeUInt32LE(slot0, GAME.inventory); await next(); // Moving RIP moves the thread: skip the gold instruction entirely. await session.step(); // ret -> goldSpend const gold = mock.game.heap.readFloatLE(GAME.vars.gold); s = await session.setRegisters({ rip: RET('goldSpend') }); assert.equal(s.rip, RET('goldSpend')); assert.equal(s.insn.text, 'ret'); assert.equal(s.where, 'game+0x30A4'); await next(); // Vector registers of the stopped thread. const v = await session.vectorRegisters(); assert.equal(v.xmm[1].floats[0], 2.5); const sixteen = new Uint8Array(16); new DataView(sixteen.buffer).setFloat32(0, 99, true); await session.setXmm(7, sixteen); assert.equal((await session.vectorRegisters()).xmm[7].floats[0], 99); await session.resume(); assert.equal(session.state, 'running'); assert.equal(session.stop, null); assert.equal(mock.game.heap.readFloatLE(GAME.vars.gold), gold, 'the skipped instruction never ran'); assert.equal(mock.debug.pc, 0, 'the rest of the frame ran'); await assert.rejects(session.step(), /Pause it first/); await assert.rejects(session.registers(), /Pause it first/); // Next frame: the breakpoint is still armed. mock.tick(); assert.equal((await next()).cause.kind, 'breakpoint'); assert.equal(bp.hits, 2); // Switched off, it is out of the game at once; switched on, back in. await session.setBreakpointEnabled(bp, false); assert.equal(bp.enabled, false); assert.ok(codeIntact()); await session.resume(); mock.tick(); await wait(60); assert.equal(pending(), 0, 'a disabled breakpoint does not stop the game'); await session.setBreakpointEnabled(bp, true); mock.tick(); await next('the re-enabled breakpoint'); assert.equal(bp.hits, 3); await session.removeBreakpoint(bp); assert.deepEqual(session.breakpoints(), []); await assert.rejects(session.setBreakpointEnabled(bp, true), /not part of this session/); await session.close(); assert.equal(session.state, 'closed'); await assert.rejects(session.addBreakpoint(CODE('scoreAdd')), /has ended/); // Stepping keeps the game "stopped" as far as listeners are concerned. assert.deepEqual(states, ['stopped', 'running', 'stopped', 'running', 'stopped', 'closed']); dbg.close(); }); test('session: breakpoints are only accepted on real code', async () => { const { dbg, ctx } = await context(); const { session } = await openSession(ctx); await assert.rejects(session.addBreakpoint(HEALTH), /not inside the game's code/); await assert.rejects(session.addBreakpoint(GAME.imageBase + 0x800n), /already holds a breakpoint instruction/, 'padding between functions'); assert.deepEqual(session.breakpoints(), []); // A removed breakpoint's place is reused when one is set there again. const a = await session.addBreakpoint(CODE('scoreAdd')); await session.removeBreakpoint(a); assert.ok(codeIntact()); const b = await session.addBreakpoint(CODE('scoreAdd')); assert.equal(b.id, a.id); assert.equal(b.hits, 0); assert.notEqual(b, a); assert.equal(mock.game.image[GAME.code.scoreAdd.offset], 0xcc); await session.close(); dbg.close(); }); test('session: resuming steps past a breakpoint the thread was stepped onto', async () => { const { dbg, ctx } = await context(); const { session, next, pending } = await openSession(ctx); const first = await session.addBreakpoint(CODE('healthDrain')); const second = await session.addBreakpoint(CODE('ammoSpend')); const nop = await session.addBreakpoint(CODE('idle')); const load = await session.addBreakpoint(CODE('loadPlayer')); mock.tick(); assert.equal((await next()).cause.breakpoint, first); const landed = await session.step(); // the ret, onto the second breakpoint await next(); assert.equal(landed.cause.kind, 'step'); assert.equal(landed.rip, CODE('ammoSpend')); assert.equal(landed.insn.text, 'dec dword ptr [rbx+0x28]', 'decoded through the breakpoint byte'); assert.equal(second.hits, 0, 'reaching it by stepping is not a hit'); // Resume: the instruction under the breakpoint runs once, without a stop on the spot. const a = ammo(); await session.resume(); assert.equal(ammo(), a - 1); assert.equal(second.hits, 0); assert.equal(mock.game.image[GAME.code.ammoSpend.offset], 0xcc, 'and it is armed again'); // The frame carries on to the next breakpoint. assert.equal((await next()).cause.breakpoint, load); // A one-byte instruction under a breakpoint is stepped by the console itself // (a plain step that ends one byte past a breakpoint is dropped by it). let s = await session.step(); // loadPlayer's ret, onto the nop await next(); assert.equal(s.rip, CODE('idle')); s = await session.step(); await next(); assert.equal(s.cause.kind, 'step'); assert.equal(s.rip, RET('idle')); assert.equal(nop.hits, 0); await session.resume(); assert.equal(mock.debug.pc, 0); // Next frame hits them in order, each resumed with a single command. mock.tick(); for (const bp of [first, second, load, nop]) { assert.equal((await next()).cause.breakpoint, bp); await session.resume(); } assert.equal(pending(), 0); assert.deepEqual([first.hits, second.hits, load.hits, nop.hits], [2, 1, 2, 1]); await session.close(); dbg.close(); }); test('session: pause, threads, stack, then run on', async () => { const { dbg, ctx } = await context(); const { session, next } = await openSession(ctx); session.settleMs = 60; const stop = await session.pause(); assert.equal(stop.cause.kind, 'pause'); assert.deepEqual(stop.thread, { lwpid: GAME.threads[0].lwpid, name: 'MainThread' }); assert.equal(stop.rip, CODE('healthDrain')); assert.equal(stop.before, null); assert.equal(await next(), stop); assert.equal(await session.pause(), stop, 'pausing twice changes nothing'); assert.equal(mock.debug.stopped, true); const threads = await session.threads({ registers: true }); assert.deepEqual(threads.map((t) => [t.lwpid, t.name, t.where]), [ [100123, 'MainThread', 'game+0x1000'], [100124, 'RenderThread', 'libkernel.sprx+0x38C'], [100125, 'AudioThread', 'libkernel.sprx+0x38C'], ]); assert.equal((await session.registers(100124)).rip, 0x80000038cn); const stack = await session.stack(); assert.deepEqual(stack.map((e) => [e.kind, e.where]), [ ['current', 'game+0x1000'], ['return', 'game+0x5010'], ['return', 'game+0x5104'], ['return', 'libkernel.sprx+0x100'], ]); assert.deepEqual([stack[1].module, stack[1].offset, stack[3].module, stack[3].offset], [undefined, 0x5010n, 'libkernel.sprx', 0x100n]); assert.deepEqual((await session.stack(16, 100124)).map((e) => e.where), ['libkernel.sprx+0x38C'], 'a thread with no frame chain'); // The console only writes registers of a thread that reported a stop. await assert.rejects(session.setRegisters({ rax: 1n }), /not after a pause/); // Another thread can be stepped; it is then the current one, and writable. const other = await session.step(100125); assert.deepEqual([other.cause.kind, other.thread.lwpid, other.rip], ['step', 100125, 0x80000038dn]); await next(); assert.equal((await session.setRegisters({ rax: 7n })).regs.rax, 7n); await session.resume(); assert.equal(mock.debug.stopped, false); // With nothing armed the thread list may be asked for while the game runs. assert.equal((await session.threads()).length, 3); await session.close(); dbg.close(); }); test('session: a pause that loses the race with a breakpoint does not leave the game frozen', async () => { const { dbg, ctx } = await context(); const { session, next } = await openSession(ctx); session.settleMs = 60; const bp = await session.addBreakpoint(CODE('scoreAdd')); // The breakpoint fires just as the pause is requested: that stop is what // the caller gets, and the pause stays queued inside the game. mock.debug.raceNextPause = true; const stop = await session.pause(); assert.equal(stop.cause.kind, 'breakpoint'); assert.equal(await next(), stop); assert.equal(mock.debug.latentStop, true); // Resuming clears the queued pause instead of leaving a silent freeze. await session.resume(); assert.equal(session.state, 'running'); assert.equal(mock.debug.latentStop, false); assert.equal(mock.debug.stopped, false); assert.equal(mock.debug.pc, 0); // The same race against a breakpoint that continues by itself: the pause is // retried until it holds. await session.removeBreakpoint(bp); let seen = 0; await session.addBreakpoint(CODE('healthDrain'), { onHit: () => { seen++; return 'continue'; }, }); mock.debug.raceNextPause = true; const held = await session.pause(); assert.equal(held.cause.kind, 'pause'); assert.equal(seen, 1); assert.equal(mock.debug.latentStop, false); assert.equal(mock.debug.stopped, true); // Things that make the console freeze a running game by itself are refused // while a breakpoint is switched on, and fine once the game is stopped. await session.resume(); await assert.rejects(session.threads(), /Pause the game first/); await assert.rejects(session.addWatchpoint(HEALTH, 4, 'write'), /Pause the game first/); await session.pause(); assert.equal((await session.threads()).length, 3); // Closing with a pause still queued must not leave it behind either. await session.resume(); mock.debug.raceNextPause = true; await session.removeBreakpoint(session.breakpoints()[0]); const again = await session.addBreakpoint(CODE('ammoSpend')); assert.equal((await session.pause()).cause.breakpoint, again); assert.equal(mock.debug.latentStop, true); await session.close(); dbg.close(); }); test('session: hardware watchpoints stop after the instruction that touched the value', async () => { const { dbg, ctx } = await context(); const { session, next, pending } = await openSession(ctx); const wp = await session.addWatchpoint(HEALTH + 1n, 4, 'write'); assert.deepEqual([wp.id, wp.address, wp.size, wp.kind], [0, HEALTH, 4, 'write'], 'aligned down to the watched block'); const bp = await session.addBreakpoint(RET('healthDrain')); mock.tick(); const stop = await next('the watchpoint'); assert.equal(stop.cause.kind, 'watchpoint'); assert.equal(stop.cause.watchpoint, wp); assert.equal(wp.hits, 1); assert.equal(stop.before.insn.text, 'mov [rcx+0x390], eax'); assert.equal(stop.before.address, CODE('healthDrain')); // RIP is on the next instruction, which carries a breakpoint that was not hit. assert.equal(stop.rip, RET('healthDrain')); assert.equal(stop.insn.text, 'ret'); assert.equal(bp.hits, 0); // Resuming from there does not stop on the spot; the breakpoint stays armed. await session.resume(); assert.equal(session.state, 'running'); assert.equal(pending(), 0); assert.equal(bp.hits, 0); assert.equal(mock.debug.pc, 0); assert.equal(mock.game.image[GAME.code.healthDrain.offset + 6], 0xcc); // Watchpoints cannot be changed while the game runs with a breakpoint on; pause for it. await assert.rejects(session.removeWatchpoint(wp), /Pause the game first/); session.settleMs = 60; await session.pause(); await next(); await session.removeWatchpoint(wp); await session.resume(); mock.tick(); assert.equal((await next()).cause.breakpoint, bp, 'without the watchpoint in front, the breakpoint is hit'); await session.removeBreakpoint(bp); const again = await session.addWatchpoint(HEALTH, 4, 'write'); await session.resume(); // A second one that only counts. let writes = 0; const counter = await session.addWatchpoint(AMMO, 4, 'write', { onHit: (hit) => { writes++; assert.equal(hit.regs.rip, RET('ammoSpend')); return 'continue'; }, }); mock.tick(); await next('the health watchpoint'); await session.resume(); await until(() => writes === 1 && !mock.debug.stopped, 'the counting watchpoint'); assert.equal(pending(), 0); assert.deepEqual(session.watchpoints(), [again, counter]); await session.removeWatchpoint(again); assert.deepEqual(session.watchpoints(), [counter]); // Four slots. for (let i = 0; i < 3; i++) await session.addWatchpoint(P(0x100 + i * 8), 4, 'write'); await assert.rejects(session.addWatchpoint(P(0x200), 4, 'write'), /All four watchpoints/); await session.close(); assert.equal(mock.state.detachedWithWatchArmed, false); dbg.close(); }); test('session: closing lets go of the game from every state', async () => { const { dbg, ctx } = await context(); const letGo = () => { assert.equal(mock.debug.owner, null); assert.ok(codeIntact()); assert.equal(mock.debug.stopped, false); assert.equal(mock.state.detachedWithWatchArmed, false); assert.deepEqual(mock.state.hazards, []); assert.equal(mock.state.crashed, null); }; // Stopped at a breakpoint, with more breakpoints and a watchpoint armed. let { session, next } = await openSession(ctx); await session.addWatchpoint(P(GAME.vars.score), 4, 'write'); await session.addBreakpoint(CODE('healthDrain')); await session.addBreakpoint(CODE('staminaTick')); mock.tick(); await next(); await session.close(); await session.close(); // again is fine letGo(); assert.equal(mock.debug.pc, 0, 'the game finished its frame'); // Running, breakpoints armed. ({ session, next } = await openSession(ctx)); await session.addBreakpoint(CODE('healthDrain')); await session.addBreakpoint(CODE('scoreAdd')); await session.close(); letGo(); // Running with only a watchpoint. ({ session, next } = await openSession(ctx)); await session.addWatchpoint(HEALTH, 4, 'write'); await session.close(); letGo(); // Paused. ({ session, next } = await openSession(ctx)); session.settleMs = 60; await session.addBreakpoint(CODE('healthDrain')); await session.pause(); await session.close(); letGo(); // Stopped on a crash: the fault is not swallowed for ever, the game is let go. ({ session, next } = await openSession(ctx)); mock.raise(11); const crash = await next('the signal'); assert.deepEqual(crash.cause, { kind: 'signal', signal: 11 }); await session.close(); letGo(); // The console drops the connection (the game quit): the session notices and says why. ({ session, next } = await openSession(ctx)); await session.addBreakpoint(CODE('healthDrain')); let reason; session.onState((state, why) => { if (state === 'closed') reason = why; }); mock.debug.owner.destroy(); await until(() => session.state === 'closed', 'the session to notice'); assert.match(reason, /console ended the debugging session/); letGo(); // Only one attach exists console-wide. ({ session, next } = await openSession(ctx)); await assert.rejects(DebugSession.open(ctx, deps), /already attached/); await session.close(); dbg.close(); }); test('session: a hit already on its way when a breakpoint is switched off is absorbed', async () => { const { dbg, ctx } = await context(); const { session, pending } = await openSession(ctx); const bp = await session.addBreakpoint(CODE('ammoSpend')); await session.setBreakpointEnabled(bp, false); assert.ok(codeIntact()); // The thread trapped a moment before the byte went back: the console // handles the hit, re-arms its INT3 and reports. Nobody asked for that stop. mock.debug.lateHit = true; const a = ammo(); mock.tick(); await until(() => !mock.debug.stopped && mock.debug.pc === 0, 'the game to be resumed'); assert.equal(pending(), 0, 'no stop is shown'); assert.equal(bp.hits, 0); assert.equal(ammo(), a - 1); assert.ok(codeIntact(), 'the INT3 the console put back is taken out again'); // The same during close: the bytes go back first, a straggler arrives after. await session.setBreakpointEnabled(bp, true); mock.debug.lateHit = true; setTimeout(() => mock.tick(), 15); await session.close(); assert.ok(codeIntact()); assert.equal(mock.debug.stopped, false); dbg.close(); }); test('session: code patched under a breakpoint is not damaged', async () => { const { dbg, ctx } = await context(); const { session, pending } = await openSession(ctx); const site = GAME.code.healthDrain; const bp = await session.addBreakpoint(CODE('healthDrain')); const other = await session.addBreakpoint(CODE('scoreAdd')); await session.setBreakpointEnabled(other, false); // A cheat switches the instruction off while the breakpoint is armed, and patches the other one too. const nops = Buffer.alloc(site.bytes.length, 0x90); await dbg.write(GAME.pid, CODE('healthDrain'), nops); const patch = Buffer.from('8387808803000B', 'hex'); // add ..., 0xB instead of 0xA: same first byte await dbg.write(GAME.pid, CODE('scoreAdd'), patch); const changed = Buffer.from(patch); changed[0] = 0x90; await dbg.write(GAME.pid, CODE('scoreAdd'), changed.subarray(0, 1)); // The breakpoint no longer fires (its INT3 is gone), and neither can be switched back on over foreign code. const h = health(); mock.tick(); await wait(40); assert.equal(pending(), 0); assert.equal(health(), h, 'the patch is in effect'); await session.setBreakpointEnabled(bp, false); assert.deepEqual(mock.game.image.subarray(site.offset, site.offset + nops.length), nops, 'switching it off leaves the patch alone'); await assert.rejects(session.setBreakpointEnabled(bp, true), /has been changed since it was set/); await assert.rejects(session.setBreakpointEnabled(other, true), /has been changed since it was set/); // Closing: the console writes its remembered bytes back, and the patches are restored on top. await session.close(); assert.deepEqual(mock.game.image.subarray(site.offset, site.offset + nops.length), nops); assert.deepEqual(mock.game.image.subarray(GAME.code.scoreAdd.offset, GAME.code.scoreAdd.offset + 7), changed); site.bytes.copy(mock.game.image, site.offset); GAME.code.scoreAdd.bytes.copy(mock.game.image, GAME.code.scoreAdd.offset); dbg.close(); }); test('session: stepping over a one-byte instruction that once had a breakpoint', async () => { const { dbg, ctx } = await context(); const { session, next } = await openSession(ctx); session.stepTimeoutMs = 300; const nop = await session.addBreakpoint(CODE('idle')); const load = await session.addBreakpoint(CODE('loadPlayer')); // Switched off, but the console still has the address in a slot, and it // drops any step that ends one byte past a slot. await session.setBreakpointEnabled(nop, false); mock.tick(); assert.equal((await next()).cause.breakpoint, load); assert.equal((await session.step()).rip, CODE('idle')); const s = await session.step(); assert.deepEqual([s.cause.kind, s.rip], ['step', RET('idle')]); assert.equal(mock.game.image[GAME.code.idle.offset], 0x90, 'and it is left switched off'); assert.equal(nop.hits, 0); await session.close(); dbg.close(); }); test('session: random use against a running game never breaks it', async () => { const { dbg, ctx } = await context(); // A small fixed-seed generator, so a failure can be replayed. let seed = Number(process.env.CS_STRESS_SEED ?? 20261010); const rnd = (n) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed % n; }; const sites = ['healthDrain', 'ammoSpend', 'goldSpend', 'scoreAdd', 'staminaTick', 'slotRead', 'loadPlayer', 'idle'].flatMap((n) => [CODE(n), RET(n)]); // Refusals that are part of the contract; anything else is a bug. const expected = /Pause it first|Pause the game first|keeps hitting a breakpoint|not part of this session|All four watchpoints|has ended|already a breakpoint|not after a pause|Already collecting|step has not finished|cannot be paused yet/; const ticker = setInterval(() => mock.tick(), 2); const log = []; try { for (let round = 0; round < 4; round++) { const { session } = await openSession(ctx); session.settleMs = 25; session.stepTimeoutMs = 400; session.onStop(() => { // Sometimes look at the stop, sometimes carry straight on. if (rnd(3) === 0) void session.resume().catch(() => {}); }); const collector = new AccessCollector(ctx, deps); for (let i = 0; i < 60; i++) { const op = rnd(12); const bps = session.breakpoints(); const pick = bps.length ? bps[rnd(bps.length)] : null; log.push(op); try { if (op === 0) await session.addBreakpoint(sites[rnd(sites.length)]); else if (op === 1) await session.addBreakpoint(sites[rnd(sites.length)], { onHit: () => 'continue' }); else if (op === 2 && pick) await session.removeBreakpoint(pick); else if (op === 3 && pick) await session.setBreakpointEnabled(pick, rnd(2) === 0); else if (op === 4) await session.pause(); else if (op === 5) await session.resume(); else if (op === 6) await session.step(); else if (op === 7) await session.addWatchpoint([HEALTH, AMMO, CHAR[1] + 0x10n][rnd(3)], 4, 'write', rnd(2) ? { onHit: () => 'continue' } : {}); else if (op === 8 && session.watchpoints().length) await session.removeWatchpoint(session.watchpoints()[0]); else if (op === 9) await session.stack(4); else if (op === 10 && !collector.running) await collector.start(CODE(['staminaTick', 'slotRead', 'healthDrain'][rnd(3)]), { session, maxHits: 1 + rnd(6), maxSeconds: 0.2 }, () => {}); else if (op === 11) await wait(rnd(8)); } catch (e) { if (!expected.test(e.message)) throw new Error(`round ${round} step ${i} op ${op}: ${e.message} (ops so far: ${log.slice(-12).join(',')})`); } assert.equal(mock.state.crashed, null); assert.deepEqual(mock.state.hazards, []); } await collector.cancel(); await session.close(); assert.equal(mock.debug.owner, null); assert.equal(mock.debug.stopped, false); assert.ok(codeIntact(), `code intact after round ${round}`); } } finally { clearInterval(ticker); } await until(() => mock.debug.pc === 0 || !mock.debug.stopped, 'the game to settle'); if (mock.debug.pc !== 0) mock.tick(); dbg.close(); }); test('session: a step that never reports does not hang the session', async () => { const { dbg, ctx } = await context(); const { session, next } = await openSession(ctx); session.settleMs = 60; session.stepTimeoutMs = 150; await session.pause(); await next(); mock.debug.dropNextReport = true; await assert.rejects(session.step(), /The step has not finished/); assert.equal(session.state, 'running'); // Pausing ends the wait and shows where things are. const stop = await session.pause(); assert.equal(stop.cause.kind, 'pause'); await session.close(); dbg.close(); }); // ---- what addresses does this instruction touch ----------------------------------------------------- test('access: one instruction, several objects', async () => { const { dbg, ctx } = await context(); const collector = new AccessCollector(ctx, deps); const staminaBefore = mock.game.heap.readUInt32LE(GAME.characters[2] + F.stamina); let latest = null; await collector.start(CODE('staminaTick'), { maxHits: 7, registers: ['rsp'] }, (r) => { latest = r; }); assert.equal(collector.running, true); assert.equal(mock.game.image[GAME.code.staminaTick.offset], 0xcc); // Each frame runs the instruction once per character; every hit resumes by itself. mock.tick(); await until(() => latest && latest.hits === 3 && !mock.debug.stopped, 'the first frame'); mock.tick(); await until(() => latest.hits === 6 && !mock.debug.stopped, 'the second frame'); mock.tick(); await until(() => latest.done, 'the hit limit'); assert.equal(latest.hits, 7); assert.match(latest.ended, /Stopped after 7 hits/); assert.equal(latest.insn.text, 'inc dword ptr [rdi+0x10]'); assert.deepEqual(latest.addresses.map((a) => [a.address, a.count, a.base]), [ [CHAR[0] + 0x10n, 3, CHAR[0]], [CHAR[1] + 0x10n, 2, CHAR[1]], [CHAR[2] + 0x10n, 2, CHAR[2]], ]); assert.deepEqual(latest.addresses[1].regs, { rsp: GAME.stackBase + 0x7000n, rdi: CHAR[1] }); assert.deepEqual(latest.addresses[0].threads, [GAME.threads[0].lwpid]); assert.deepEqual(groupByBase(latest.addresses).map((g) => [g.base, g.count, g.fields.map((f) => f.offset)]), [ [CHAR[0], 3, [0x10n]], [CHAR[1], 2, [0x10n]], [CHAR[2], 2, [0x10n]], ]); assert.equal(collector.running, false); await until(() => mock.debug.owner === null, 'its session to close'); assert.equal(mock.game.heap.readUInt32LE(GAME.characters[2] + F.stamina), staminaBefore + 3, 'every pass still did its work'); dbg.close(); }); test('access: an index register, the limits, and what cannot be collected', async () => { const { dbg, ctx } = await context(); const collector = new AccessCollector(ctx, deps); let latest = null; const update = (r) => { latest = r; }; // [rbx+rsi*4+0x20] with rsi = 0, 1, 2: three addresses in one object. await collector.start(CODE('slotRead'), {}, update); mock.tick(); await until(() => latest && latest.hits === 3 && !mock.debug.stopped, 'three reads'); const t0 = Date.now(); await collector.cancel(); assert.ok(Date.now() - t0 < 1000, 'canceling does not wait for the game'); assert.equal(latest.done, true); assert.equal(latest.ended, undefined); assert.deepEqual(latest.addresses.map((a) => a.address).sort(), [0, 1, 2].map((i) => P(GAME.inventory + i * 4))); const [group] = groupByBase(latest.addresses); assert.deepEqual([group.base, group.count, group.fields.map((f) => f.offset)], [P(GAME.inventory) - 0x20n, 3, [0x20n, 0x24n, 0x28n]]); assert.deepEqual(latest.addresses[0].regs, { rbx: P(GAME.inventory) - 0x20n, rsi: latest.addresses[0].regs.rsi }); await until(() => mock.debug.owner === null, 'its session to close'); assert.ok(codeIntact()); // Canceling before the instruction ever runs is just as quick. latest = null; await collector.start(CODE('healthDrain'), {}, update); await collector.cancel(); assert.deepEqual([latest.hits, latest.addresses.length, latest.done], [0, 0, true]); await until(() => mock.debug.owner === null, 'its session to close'); // The time limit ends it without any help. latest = null; await collector.start(CODE('healthDrain'), { maxSeconds: 0.15 }, update); mock.tick(); await until(() => latest && latest.done, 'the time limit'); assert.match(latest.ended, /Stopped after 0.15 seconds/); assert.equal(latest.hits, 1); assert.equal(latest.addresses[0].address, HEALTH); await until(() => mock.debug.owner === null, 'its session to close'); // Nothing to collect on these, and no session is left open by trying. await assert.rejects(collector.start(CODE('goldSpend'), {}, update), /does not read or write memory/); await assert.rejects(collector.start(CODE('loadPlayer'), {}, update), /always uses the same address/); await assert.rejects(collector.start(GAME.imageBase + 0x800n, {}, update), /does not read or write memory|no instruction/); await assert.rejects(collector.start(HEALTH, {}, update), /not inside the game's code|does not read or write|no instruction/); assert.equal(mock.debug.owner, null); dbg.close(); }); test('access: inside an open session, other breakpoints are left alone', async () => { const { dbg, ctx } = await context(); const { session, next } = await openSession(ctx); const mine = await session.addBreakpoint(CODE('scoreAdd')); const collector = new AccessCollector(ctx, deps); let latest = null; await assert.rejects(collector.start(CODE('scoreAdd'), { session }, () => {}), /already a breakpoint/); await collector.start(CODE('staminaTick'), { session, maxHits: 2 }, (r) => { latest = r; }); mock.tick(); assert.equal((await next()).cause.breakpoint, mine, 'the session still stops at its own breakpoint'); await session.resume(); await until(() => latest && latest.done, 'the collector to finish'); assert.deepEqual(latest.addresses.map((a) => a.address), [CHAR[0] + 0x10n, CHAR[1] + 0x10n]); await until(() => !mock.debug.stopped, 'the game to run on'); assert.equal(session.state, 'running'); assert.deepEqual(session.breakpoints(), [mine]); assert.equal(mock.game.image[GAME.code.staminaTick.offset], GAME.code.staminaTick.bytes[0], 'its own breakpoint is gone'); assert.equal(mock.game.image[GAME.code.scoreAdd.offset], 0xcc); await session.close(); dbg.close(); }); // ---- structures ------------------------------------------------------------------------------------ const resetCharacters = () => GAME.characters.forEach((at, i) => mock.game.heap.writeUInt32LE(1000 * (i + 1), at + F.stamina)); test('structure: fields are told apart by what they hold', async () => { const { dbg, ctx } = await context(); resetCharacters(); const reads = []; const real = dbg.read.bind(dbg); dbg.read = (pid, address, length) => { reads.push(length); return real(pid, address, length); }; const s = await readStructure(ctx, CHAR[1], 0x40, { nameOf: async (a) => (a === CHAR[0] ? 'Character' : null), fields: [ { offset: F.stamina, name: 'Stamina', type: 'int32' }, { offset: F.hp, name: 'Health', type: 'float' }, ], }); assert.equal(reads[0], 0x40, 'the object itself is one read'); assert.equal(s.bytes.length, 0x40); const row = (off) => s.rows.find((r) => r.offset === off); const best = (off) => [row(off).guesses[0].kind, row(off).guesses[0].text]; assert.deepEqual(best(F.vtable), ['pointer', 'game+0x10300']); assert.deepEqual([row(F.vtable).guesses[0].target.region, row(F.vtable).guesses[0].target.offset, row(F.vtable).bytes.length], ['module', 0x10300n, 8]); assert.deepEqual(best(F.name), ['pointer', `0x${(P(0x15020)).toString(16).toUpperCase()} "Sentry"`]); assert.equal(row(F.name).guesses[0].target.text, 'Sentry'); assert.deepEqual(best(F.stamina), ['int', '2000']); assert.deepEqual(row(F.stamina).field, { offset: F.stamina, name: 'Stamina', type: 'int32' }); assert.deepEqual(best(F.hp), ['float', '80']); assert.equal(row(F.hp).field.name, 'Health'); assert.deepEqual(best(F.team), ['int', '2']); assert.deepEqual(best(F.pos), ['float', '42']); assert.deepEqual(best(F.pos + 4), ['int', '0']); assert.deepEqual(best(F.pos + 8), ['float', '17.5']); assert.deepEqual(best(F.speed), ['double', '3.25']); assert.equal(row(F.speed).bytes.length, 8); assert.deepEqual(best(F.target), ['pointer', `0x${CHAR[0].toString(16).toUpperCase()} (Character)`]); assert.deepEqual([row(F.target).guesses[0].target.region, row(F.target).guesses[0].target.name], ['heap', 'Character']); // Every row keeps its other readings. assert.deepEqual(row(F.hp).guesses.map((g) => g.kind), ['float', 'int']); assert.deepEqual(row(F.target).guesses.map((g) => g.kind), ['pointer', 'int', 'double']); assert.equal(s.rows.reduce((n, r) => n + r.bytes.length, 0), 0x40, 'the rows cover the object exactly'); // A pointer into another module, a negative float, a stack pointer. const lib = await readStructure(ctx, GAME.module.dataBase + BigInt(GAME.module.statics.boss), 8); assert.equal(lib.rows[0].guesses[0].kind, 'pointer'); const player = await readStructure(ctx, CHAR[0], 0x40); assert.deepEqual([player.rows.find((r) => r.offset === F.pos + 8).guesses[0].text, player.rows.find((r) => r.offset === F.target).guesses[0].text], ['-3.25', '0']); await assert.rejects(readStructure(ctx, CHAR[0], 2), /between 4 bytes and 64 KB/); assert.equal(textAt(Buffer.from('Hello\0')), 'Hello'); assert.equal(textAt(Buffer.from('H\0e\0l\0l\0o\0\0\0', 'latin1')), 'Hello', 'two-byte characters'); assert.equal(textAt(Buffer.from([1, 2, 3, 4, 5])), null); assert.deepEqual(['float', 'int32', 'bool', 'APlayerState*', 'TObjectPtr', 'FVector', 'double'].map(plainKind), ['float', 'int', 'int', 'pointer', 'pointer', null, 'double']); dbg.close(); }); test('structure: comparing objects finds the fields that separate mine from theirs', async () => { const { dbg, ctx } = await context(); resetCharacters(); const cmp = await compareStructures( ctx, [{ address: CHAR[0], group: 'mine' }, { address: CHAR[1], group: 'theirs' }, { address: CHAR[2], group: 'theirs' }], 0x40, { fields: [{ offset: F.team, name: 'Team', type: 'int32' }] }, ); const row = (off) => cmp.rows.find((r) => r.offset === off); const at = (off) => [row(off).verdict, row(off).separates, row(off).values.map((v) => v.text)]; assert.deepEqual(at(F.vtable), ['same', false, ['game+0x10300', 'game+0x10300', 'game+0x10300']]); assert.deepEqual(at(F.stamina), ['differs', true, ['1000', '2000', '3000']]); assert.deepEqual(at(F.hp), ['differs', true, ['120', '80', '35']]); // The team number: the same within each side, different between them. assert.deepEqual(at(F.team), ['groups', true, ['1', '2', '2']]); assert.equal(row(F.team).field.name, 'Team'); // Only the others point at a target (the player): also a clean split, and a null stays in the pointer column. assert.equal(row(F.target).verdict, 'groups'); assert.deepEqual(row(F.target).values.map((v) => v.kind), ['pointer', 'pointer', 'pointer']); assert.equal(row(F.target).values[0].text, 'null'); assert.deepEqual(row(F.speed).values.map((v) => v.text), ['4.5', '3.25', '6']); assert.deepEqual(row(F.pos + 4).verdict, 'same'); assert.deepEqual(row(F.name).values.map((v) => v.target.text), ['Player', 'Sentry', 'Dog']); // The rows that matter for "which one is mine". assert.deepEqual(cmp.rows.filter((r) => r.verdict === 'groups').map((r) => r.offset), [F.team, F.target]); // Without labels there is only same or different. const plain = await compareStructures(ctx, [CHAR[1], CHAR[2]], 0x40); assert.deepEqual([plain.rows.find((r) => r.offset === F.team).verdict, plain.rows.find((r) => r.offset === F.hp).verdict], ['same', 'differs']); assert.ok(plain.rows.every((r) => !r.separates && r.verdict !== 'groups')); await assert.rejects(compareStructures(ctx, [CHAR[0]], 0x40), /at least two/); dbg.close(); }); // ---- locations in any module, pointer paths ------------------------------------------------------------ test('locations: offsets into a named module', async () => { const { dbg, ctx } = await context(); const lib = GAME.module; assert.deepEqual(await moduleRange(ctx, 'libgame.prx'), { start: lib.codeBase, end: lib.dataBase + 0x4000n }); assert.deepEqual(await moduleRange(ctx, 'LIBGAME.PRX'), { start: lib.codeBase, end: lib.dataBase + 0x4000n }, 'names ignore case'); assert.deepEqual(await moduleRange(ctx), { start: GAME.imageBase, end: GAME.dataBase + 0x4000n }); assert.equal(await moduleRange(ctx, 'nothing.prx'), null); assert.deepEqual(new Set((await loadedModules(ctx)).map((m) => m.module)), new Set([undefined, 'libkernel.sprx', 'libgame.prx'])); // What someone types. assert.equal(await parseAddress(ctx, 'libgame.prx+0x4080'), lib.dataBase + 0x80n); assert.equal(await parseAddress(ctx, ' LibGame.prx + 10 '), lib.codeBase + 0x10n); assert.equal(await parseAddress(ctx, 'game+1000'), GAME.imageBase + 0x1000n); assert.equal(await parseAddress(ctx, 'eboot.bin+0x1000'), GAME.imageBase + 0x1000n); assert.equal(await parseAddress(ctx, '0x4B3A8C1000'), GAME.imageBase + 0x1000n); await assert.rejects(parseAddress(ctx, 'nothing.prx+10'), /No module called "nothing.prx" is loaded/); await assert.rejects(parseAddress(ctx, 'libgame.prx+zz'), /not a hex number/); // Addresses to lasting descriptions. The game's own keep the old shape exactly. assert.deepEqual(await moduleLocation(ctx, GAME.imageBase + 0x1000n), { kind: 'module', offset: '1000' }); assert.deepEqual(await moduleLocation(ctx, lib.dataBase + 0x80n), { kind: 'module', offset: '4080', module: 'libgame.prx' }); assert.equal(await moduleLocation(ctx, HEALTH), null); assert.deepEqual(await addressOwner(ctx, lib.codeBase + 0x10n), { module: 'libgame.prx', offset: 0x10n }); assert.equal(await describeAddress(ctx, lib.codeBase + 0x10n), 'libgame.prx+0x10'); assert.equal(await describeAddress(ctx, HEALTH), '0x880012340'); // And back. const inLib = { kind: 'module', offset: '4080', module: 'libgame.prx' }; assert.equal(await resolveLocation(ctx, inLib), lib.dataBase + 0x80n); assert.equal(describeLocation(inLib), 'libgame.prx+0x4080'); const path = { kind: 'pointer', base: '4080', offsets: ['0x14'], module: 'libgame.prx' }; assert.equal(await resolveLocation(ctx, path), CHAR[1] + 0x14n); assert.equal(describeLocation(path), '[libgame.prx+0x4080]+0x14'); await assert.rejects(resolveLocation(ctx, { kind: 'module', offset: '10', module: 'gone.prx' }), /"gone.prx" is not loaded/); // Saved cheats without a module mean the game, as before. assert.equal(await resolveLocation(ctx, { kind: 'pointer', base: '10100', offsets: ['0x340'] }), HEALTH); assert.equal(describeLocation({ kind: 'pointer', base: '10100', offsets: ['0x340'] }), '[game+0x10100]+0x340'); // What refers to a static: the instruction that loads the player pointer. assert.deepEqual(await findReferences(ctx, GAME.dataBase + BigInt(GAME.statics.player)), [CODE('loadPlayer')]); assert.deepEqual(await findReferences(ctx, GAME.dataBase + BigInt(GAME.statics.player), 'libgame.prx'), []); await assert.rejects(findReferences(ctx, 1n, 'gone.prx'), /"gone.prx" is not loaded/); // The community table format only knows the game's executable. const table = emptyTable(GAME.titleId, GAME.version); table.entries.push( { id: 'a', type: 'patch', name: 'Game', at: { kind: 'module', offset: '1000' }, on: '9090', off: '8981' }, { id: 'b', type: 'patch', name: 'Lib', at: inLib, on: '90', off: 'CC' }, ); assert.deepEqual([exportCommunityTable(table).included, exportCommunityTable(table).skipped], [1, 1]); dbg.close(); }); test('pointer paths: found from any module, and re-checked later', async () => { const { dbg, ctx } = await context(); const hp = CHAR[1] + BigInt(F.hp); const progress = []; const paths = await findPointerPaths(ctx, hp, { maxDepth: 2 }, (t) => progress.push(t)); const texts = paths.map((p) => describeLocation(p.location)); assert.ok(texts.includes('[libgame.prx+0x4080]+0x14'), texts.join(' | ')); assert.deepEqual(paths[0].location, { kind: 'pointer', base: '4080', offsets: ['0x14'], module: 'libgame.prx' }); for (const p of paths) assert.equal(await resolveLocation(ctx, p.location), hp); assert.deepEqual(progress, ['Level 1 of 2: searching memory', 'Level 2 of 2: searching memory']); // Paths into the game itself are unchanged, and several nearby objects share one pass over memory. const scans = []; const scan = dbg.scanStart.bind(dbg); dbg.scanStart = (opts, onBlock) => { scans.push(opts.address); return scan(opts, onBlock); }; const toHealth = await findPointerPaths(ctx, HEALTH, { maxDepth: 2 }, () => {}); const healthTexts = toHealth.map((p) => describeLocation(p.location)); assert.ok(healthTexts.includes('[game+0x10100]+0x340') && healthTexts.includes('[[game+0x10200]+0x8]+0x340'), healthTexts.join(' | ')); assert.ok(toHealth.every((p) => p.location.module === undefined)); const regions = new Set(scans).size; assert.equal(scans.length, regions * 2, 'two levels, one pass over memory each'); // Saved candidates, checked again "after a restart". const saved = [ ...paths.slice(0, 1), { location: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, depth: 1 }, { location: { kind: 'pointer', base: '10200', offsets: ['0x8', '0x340'] }, depth: 2 }, { location: { kind: 'pointer', base: '10108', offsets: ['0x10'] }, depth: 1 }, // an empty pointer { location: { kind: 'pointer', base: '10', offsets: ['0x0'], module: 'gone.prx' }, depth: 1 }, { location: { kind: 'pointer', base: '10100', offsets: ['0x7fffff00'] }, depth: 1 }, // leads outside the game's memory ]; let res = await recheckPointerPaths(ctx, saved); assert.deepEqual(res.map((r) => [r.readable, r.matches, r.address]), [ [true, null, hp], [true, null, HEALTH], [true, null, HEALTH], [false, null, null], [false, null, null], [false, null, P(GAME.player) + 0x7fffff00n], ]); assert.equal(res[0].text, '[libgame.prx+0x4080]+0x14'); assert.match(res[3].problem, /pointer on the path is empty/); assert.match(res[4].problem, /"gone.prx" is not loaded/); assert.match(res[5].problem, /memory the game is not using/); res = await recheckPointerPaths(ctx, saved, { type: 'float', value: 80 }); assert.deepEqual(res.map((r) => [r.matches, r.value]), [[true, '80'], [false, res[1].value], [false, res[2].value], [false, null], [false, null], [false, null]]); res = await recheckPointerPaths(ctx, saved.slice(1, 3), { type: 'u32', min: 90, max: 100 }); assert.deepEqual(res.map((r) => [r.matches, r.value]), [[true, '100'], [true, '100']]); assert.deepEqual((await recheckPointerPaths(ctx, saved.slice(1, 2), { type: 'u32', value: '99' })).map((r) => r.matches), [false]); // The object moved (the library now points at another character): the path // still resolves, to a value that no longer matches. const boss = GAME.module.statics.boss; mock.game.lib.data.writeBigUInt64LE(CHAR[2], boss); res = await recheckPointerPaths(ctx, saved.slice(0, 1), { type: 'float', value: 80 }); assert.deepEqual([res[0].readable, res[0].matches, res[0].value, res[0].address], [true, false, '35', CHAR[2] + 0x14n]); mock.game.lib.data.writeBigUInt64LE(CHAR[1], boss); dbg.close(); });