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