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

1242 lines
59 KiB
JavaScript

// 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<AActor>', '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();
});