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

711 lines
29 KiB
JavaScript

// The guided "make my first cheat" flow (src/lib/wizard.ts) against the mock
// console: the whole path on ammo, the route for a value with no number on
// health, what happens when a search comes up empty or stops shrinking,
// several pieces of code writing one value, and that leaving at any point
// puts the game back the way it was.
import test, { after, before, beforeEach } 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 { TableRunner } from '../src/lib/table.ts';
import { FirstCheatFlow, PROVE_AT, PROVE_MAX, TARGETS, afterSearch, rankWriters, sameNumber, testValue, writerReason } from '../src/lib/wizard.ts';
const require = createRequire(import.meta.url);
const x86 = asAsync(createX86(require('iced-x86')));
// Lets several copies of the suite run at once on one machine.
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
const TCP = 17480 + PORT_OFFSET;
const EVENTS = 17785 + PORT_OFFSET;
const HOLD_MS = 25;
let mock, bridge, port, pristine;
const AMMO = GAME.heapBase + BigInt(GAME.vars.ammo);
const HEALTH = GAME.heapBase + BigInt(GAME.vars.health);
const AMMO_CODE = GAME.code.ammoSpend;
const DECOYS = [0x400, 0x9000, 0x1a2b0, 0x30000, 0x3fff0];
const heap = () => mock.game.heap;
const ammo = () => heap().readUInt32LE(GAME.vars.ammo);
const health = () => heap().readUInt32LE(GAME.vars.health);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (cond, what) => {
for (let i = 0; i < 200; i++) {
if (cond()) return;
await wait(10);
}
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 });
/** A flow wired the way the app wires it, plus what it reported along the way. */
async function makeFlow() {
const dbg = await connect();
const scanDbg = await connect();
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, scanDbg: () => scanDbg, x86 });
const runner = new TableRunner(ctx);
const kept = [];
const labels = [];
const flow = new FirstCheatFlow({
ctx,
openDebugger: connect,
openEvents: () => openWebSocketStream(url('events')),
runner,
keep: (entry) => void kept.push(entry),
onChange: (s) => s.busy && labels.push(s.busy.label),
holdMs: HOLD_MS,
});
const close = async () => {
await flow.abort();
runner.dispose();
dbg.close();
scanDbg.close();
};
return { flow, runner, kept, labels, dbg, close };
}
/** One frame of the game, waiting for a watching debugger to deal with it and let the game go again. */
async function tick(flow) {
const seen = flow?.state.watch?.changes;
mock.tick();
if (seen !== undefined) await until(() => flow.state.watch?.changes === seen + 1 && !mock.debug.stopped, 'the change to be noticed and the game resumed');
}
/** Makes the debugger report a write from somewhere else in the code, as a second writer would. */
async function writeFrom(flow, codeEnd) {
const seen = flow.state.watch.changes;
const pkt = Buffer.alloc(1184);
pkt.writeUInt32LE(100123, 0);
pkt.writeUInt32LE((5 << 8) | 0x7f, 4);
pkt.writeBigUInt64LE(GAME.imageBase + BigInt(codeEnd), 0x30 + 136);
pkt.writeBigUInt64LE(1n, 0x420 + 48);
// The console's own idea of the game's state has to say "stopped" as well, or
// it answers the resume with "already running" and leaves the game held.
mock.debug.stopped = true;
mock.debug.phase = 'stopped';
mock.debug.events.write(pkt);
await until(() => flow.state.watch?.changes === seen + 1 && !mock.debug.stopped, 'the extra change to be noticed');
}
const codeIntact = () => mock.game.image.equals(pristine.image);
const assertLetGo = () => {
assert.equal(mock.debug.owner, null, 'no debugger attached');
assert.deepEqual(mock.debug.watch, [null, null, null, null], 'no watchpoint armed');
assert.equal(mock.debug.stopped, false, 'the game is running');
};
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;
pristine = { image: Buffer.from(mock.game.image), heap: Buffer.from(mock.game.heap) };
});
// Every test starts from the same game: 30 ammo, 100 health, untouched code.
beforeEach(() => {
pristine.image.copy(mock.game.image);
pristine.heap.copy(mock.game.heap);
});
after(async () => {
await bridge.close();
await mock.close();
});
test('decisions: when to keep narrowing, try them, or admit the search is stuck', () => {
assert.equal(afterSearch([0]), 'nothing');
assert.equal(afterSearch([1539815]), 'again');
assert.equal(afterSearch([2]), 'again', 'a first search never goes straight to changing things');
assert.equal(afterSearch([1539815, 419]), 'again');
assert.equal(afterSearch([1539815, 419, 3]), 'prove');
assert.equal(afterSearch([40, PROVE_AT]), 'prove');
assert.equal(afterSearch([40, PROVE_AT + 1]), 'again');
assert.equal(afterSearch([419, 3, 0]), 'nothing');
// Several copies of the same number: the count stops moving.
assert.equal(afterSearch([900, 12, 12]), 'stuck');
assert.equal(afterSearch([900, PROVE_MAX, PROVE_MAX]), 'stuck');
assert.equal(afterSearch([900, 300, 300]), 'again', 'one flat round with many left is not yet stuck');
assert.equal(afterSearch([900, 300, 300, 298]), 'stuck', 'two flat rounds are');
assert.equal(afterSearch([900, 300, 300, 150]), 'again');
assert.equal(testValue('27'), '99');
assert.equal(testValue('0'), '99');
assert.equal(testValue('95'), '206', 'never lower than what is there, and not 99 when that is too close to tell');
assert.equal(testValue('2500'), '2611');
assert.equal(testValue('87.5'), '99');
assert.equal(testValue('nan'), '99');
assert.equal(sameNumber('30', ' 30.0 '), true);
assert.equal(sameNumber('30', '27'), false);
assert.equal(sameNumber('', ''), false);
});
test('decisions: which of several writers to switch off first', () => {
const insn = (address, text, extra = {}) => ({ address, text, length: 3, bytes: new Uint8Array(3), flow: 'next', writesMemory: true, invalid: false, ...extra });
const hit = (rip, count, i) => ({ rip, count, insn: i, regs: {} });
const dec = insn(0x1000n, 'dec dword ptr [rbx+0x28]');
const redraw = insn(0x2000n, 'mov [rax+0x10], ecx');
const reload = insn(0x3000n, 'mov [rbx+0x28], eax');
const copy = insn(0x4000n, 'rep movsb');
const one = rankWriters([hit(0x1003n, 3, dec)], new Map());
assert.equal(one.length, 1);
assert.equal(writerReason(one), 'only');
// The redraw ran 50 times before the user did anything; the decrement only ran for the three shots.
const two = rankWriters([hit(0x2003n, 80, redraw), hit(0x1003n, 3, dec)], new Map([[0x2003n, 50]]));
assert.deepEqual(two.map((w) => w.insn.text), ['dec dword ptr [rbx+0x28]', 'mov [rax+0x10], ecx']);
assert.deepEqual(two.map((w) => [w.onAction, w.quiet]), [[true, 0], [false, 50]]);
assert.equal(writerReason(two), 'action');
const both = rankWriters([hit(0x3003n, 1, reload), hit(0x1003n, 3, dec)], new Map());
assert.equal(both[0].insn, dec, 'the busier of two that ran on the action');
assert.equal(writerReason(both), 'busiest-action');
const quiet = rankWriters([hit(0x2003n, 80, redraw), hit(0x1003n, 60, dec)], new Map([[0x2003n, 50], [0x1003n, 20]]));
assert.equal(quiet[0].insn, dec, 'most changes since the user was asked to act');
assert.equal(writerReason(quiet), 'busiest');
// Bulk copies and things that could not be read are never offered.
const unusable = rankWriters([hit(0x4002n, 9, copy), hit(0x5000n, 4, null), hit(0x6000n, 2, insn(0x6000n, 'call 0x1234', { flow: 'call' }))], new Map());
assert.deepEqual(unusable, []);
});
test('the whole way on ammo: find 30, narrow, prove, catch the code, switch it off, save', async () => {
const { flow, runner, kept, labels, close } = await makeFlow();
assert.equal(flow.state.step, 'what');
flow.chooseTarget('ammo');
assert.equal(flow.state.step, 'shown');
flow.chooseRoute('number');
assert.equal(flow.state.step, 'number');
await flow.searchNumber('thirty');
assert.match(flow.state.error, /digits only/);
assert.equal(flow.state.step, 'number');
await flow.searchNumber('30');
assert.equal(flow.state.error, null);
assert.equal(flow.state.step, 'change');
assert.deepEqual(flow.state.counts, [1]);
assert.ok(labels.some((l) => /Looking through the game for 30/.test(l) && /Do not fire or reload/.test(l)), labels.join(' | '));
// The game is paused in a menu: the number has not moved, so nothing is searched and the user is told.
await flow.narrowNumber('30');
assert.equal(flow.state.sameAsBefore, true);
assert.deepEqual(flow.state.counts, [1]);
for (let i = 0; i < 3; i++) await tick();
assert.equal(ammo(), 27);
await flow.narrowNumber('27');
assert.equal(flow.state.sameAsBefore, false);
assert.deepEqual(flow.state.counts, [1, 1]);
assert.equal(flow.state.step, 'ready');
assert.deepEqual(flow.state.candidates.map((c) => [c.address, c.kind.id, c.value]), [[AMMO, 'u32', '27']]);
// Prove it: the one possibility is changed, and put back whatever the answer.
await flow.startProving();
assert.equal(flow.state.step, 'prove');
assert.deepEqual({ ...flow.state.prove, candidate: undefined }, { index: 0, total: 1, mode: 'set', shown: '99', candidate: undefined });
assert.equal(ammo(), 99);
await flow.answerProof(true);
assert.equal(ammo(), 27, 'the test value does not stay');
assert.equal(flow.state.step, 'kind');
assert.equal(flow.state.found.address, AMMO);
// Stop the game from lowering it: watch, act, look at what was caught.
await flow.chooseStop();
assert.equal(flow.state.step, 'watch');
assert.equal(flow.state.watch.phase, 'quiet');
assert.notEqual(mock.debug.owner, null);
assert.equal(mock.debug.watch[0].address, AMMO);
flow.quietOver();
assert.equal(flow.state.watch.phase, 'act');
for (let i = 0; i < 3; i++) await tick(flow);
assert.deepEqual(flow.state.watch, { phase: 'act', places: 1, changes: 3 });
await flow.finishWatch();
assertLetGo();
assert.equal(mock.state.detachedWithWatchArmed, false);
assert.equal(flow.state.step, 'caught');
assert.equal(flow.state.writers.length, 1);
assert.equal(flow.state.writers[0].insn.text, 'dec dword ptr [rbx+0x28]');
assert.equal(flow.state.writers[0].onAction, true);
assert.equal(flow.state.unusable, 0);
await flow.applyWriter();
assert.equal(flow.state.step, 'test');
const site = mock.game.image.subarray(AMMO_CODE.offset, AMMO_CODE.offset + 3);
assert.equal(site.toString('hex'), '909090');
const left = ammo();
for (let i = 0; i < 3; i++) await tick();
assert.equal(ammo(), left, 'firing no longer costs ammo');
await flow.answerTest(true);
assert.equal(flow.state.step, 'name');
assert.equal(kept.length, 0, 'nothing is saved until it is named');
await flow.save(' Bottomless magazine ');
assert.equal(flow.state.step, 'done');
assert.equal(kept.length, 1);
assert.equal(kept[0].name, 'Bottomless magazine');
assert.equal(kept[0].type, 'patch');
// A place in the game's own code, never a raw address, so it survives a
// restart. The mock's code sits in padding, so no pattern matches in exactly
// one place and the fixed position is kept; either way the code around it
// is stored so an update that moves it can be noticed.
assert.ok(['signature', 'module'].includes(kept[0].at.kind), kept[0].at.kind);
assert.deepEqual(kept[0].at.kind === 'signature' ? kept[0].fallback : kept[0].at, { kind: 'module', offset: AMMO_CODE.offset.toString(16).toUpperCase() });
assert.match(kept[0].around.bytes, /FF4B28/, 'the surrounding code is kept as proof');
assert.equal(kept[0].off.toLowerCase(), 'ff4b28');
assert.equal(runner.isOn(kept[0].id), true, 'it is a switch in "Your cheats", already on');
// Leaving after saving keeps the cheat.
await close();
assert.equal(site.toString('hex'), '909090');
assert.equal(flow.clean, true);
});
test('a first search that finds nothing, and a later one: other ways to look are offered', async () => {
const { flow, close } = await makeFlow();
flow.chooseTarget('money');
flow.chooseRoute('number');
await flow.searchNumber('777777');
assert.equal(flow.state.step, 'nothing');
assert.deepEqual(flow.state.counts, [0]);
assert.equal(flow.state.canSearchAnotherWay, true);
await flow.restart('same');
assert.equal(flow.state.step, 'number');
assert.deepEqual(flow.state.counts, []);
await flow.searchNumber('30');
assert.equal(flow.state.step, 'change');
// The user reads the wrong number off the screen.
await flow.narrowNumber('12345');
assert.equal(flow.state.step, 'nothing');
assert.deepEqual(flow.state.counts, [1, 0]);
// "Stored differently": the same search as a smaller kind of number finds it.
await flow.restart('another');
assert.equal(flow.state.step, 'number');
await flow.searchNumber('30');
assert.equal(flow.state.step, 'change');
await tick();
await flow.narrowNumber('29');
assert.equal(flow.state.step, 'ready');
assert.deepEqual(flow.state.candidates.map((c) => [c.address, c.kind.id]), [[AMMO, 'u16']]);
await flow.narrowNumber('70000'); // cannot be a number this small kind holds
assert.equal(flow.state.step, 'nothing');
await flow.restart('another');
await flow.restart('another');
await flow.searchNumber('29');
await flow.narrowNumber('5');
assert.equal(flow.state.step, 'nothing');
assert.equal(flow.state.canSearchAnotherWay, false, 'every way of storing a number has been tried');
// The other route is always open.
await flow.restart('bar');
assert.equal(flow.state.step, 'bar');
assert.equal(flow.state.route, 'bar');
await close();
});
test('several copies of the number: the count stops shrinking, and they are tried one at a time', async () => {
// Copies that follow the real value around (a display copy, a save buffer and so on).
const copies = [0x500, 0x504, 0x508, 0x50c, 0x510, 0x514];
const follow = () => copies.forEach((off) => heap().writeUInt32LE(ammo(), off));
follow();
const { flow, close } = await makeFlow();
flow.chooseTarget('ammo');
flow.chooseRoute('number');
await flow.searchNumber('30');
assert.deepEqual(flow.state.counts, [7]);
await tick();
follow();
await flow.narrowNumber('29');
assert.deepEqual(flow.state.counts, [7, 7]);
assert.equal(flow.state.step, 'stuck');
assert.equal(flow.state.candidates.length, 7);
flow.keepNarrowing();
assert.equal(flow.state.step, 'change');
await tick();
follow();
await flow.narrowNumber('28');
assert.equal(flow.state.step, 'stuck');
// Move on with what is left. The copies come first in memory; none of them is "the" ammo.
await flow.startProving();
for (let i = 0; i < copies.length; i++) {
assert.equal(flow.state.prove.index, i);
assert.equal(heap().readUInt32LE(copies[i]), 99);
assert.equal(ammo(), 28);
await flow.answerProof(false);
assert.equal(heap().readUInt32LE(copies[i]), 28, 'a wrong guess is put back');
}
assert.equal(flow.state.prove.index, 6);
assert.equal(ammo(), 99);
assert.equal(flow.untried, 0);
await flow.answerProof(true);
assert.equal(flow.state.step, 'kind');
assert.equal(flow.state.found.address, AMMO);
assert.equal(ammo(), 28);
// Saying no to every one of them ends somewhere useful too.
await flow.back();
assert.equal(flow.state.step, 'ready');
await flow.startProving();
for (let i = 0; i < 7; i++) await flow.answerProof(false);
assert.equal(flow.state.step, 'unproven');
assert.equal(ammo(), 28);
copies.forEach((off) => assert.equal(heap().readUInt32LE(off), 28));
assert.equal(flow.clean, true);
await close();
});
test('no number to see: the went-down route on health, hold it, then stop the game lowering it', async () => {
const { flow, runner, kept, close } = await makeFlow();
flow.chooseTarget('health');
flow.chooseRoute('bar');
assert.equal(flow.state.step, 'bar');
await flow.takeSnapshot();
assert.equal(flow.state.step, 'change');
const everything = flow.state.counts[0];
assert.ok(everything > 1000, 'everything is a possibility at first');
// "It stayed the same" as often as the user likes is never read as the search having stalled.
for (let i = 0; i < 3; i++) {
await flow.narrowBar('same');
assert.equal(flow.state.counts[i + 1], everything, 'nothing moved, so nothing is ruled out');
assert.equal(flow.state.step, 'change');
}
await tick(); // took a hit: health 99, and ammo, gold and score moved too
await flow.narrowBar('down');
assert.equal(flow.state.step, 'ready');
assert.equal(flow.state.candidates[0].address, HEALTH);
assert.ok(flow.state.candidates.length <= PROVE_AT);
// Prove it by keeping it still: no number is invented for a value nobody can read.
await flow.startProving();
assert.equal(flow.state.prove.mode, 'hold');
assert.equal(flow.state.prove.shown, '99');
await tick();
await until(() => health() === 99, 'the health to be held at 99');
await flow.answerProof(true);
assert.equal(flow.state.step, 'kind');
await tick();
await wait(HOLD_MS * 3);
assert.equal(health(), 98, 'it is let go again once the question is answered');
// "Hold it": held where it is now.
await flow.chooseHold();
assert.equal(flow.state.step, 'test');
assert.equal(flow.state.cheat, 'hold');
assert.equal(flow.state.entry.type, 'value');
assert.equal(flow.state.entry.value, '98');
assert.equal(flow.state.entry.freeze, true);
assert.equal(flow.state.entry.name, 'Infinite health');
await tick();
await until(() => health() === 98, 'the cheat to hold health at 98');
// The user says it does not work: it is switched off, and the other kind is offered.
await flow.answerTest(false);
assert.equal(flow.state.step, 'failed');
assert.deepEqual(flow.state.tried, { hold: true, stop: false });
assert.equal(runner.isOn(flow.state.entry?.id ?? ''), false);
await tick();
await wait(300);
assert.equal(health(), 97, 'nothing holds it any more');
assert.equal(flow.clean, true);
flow.chooseAgain();
assert.equal(flow.state.step, 'kind');
await flow.chooseStop();
flow.quietOver();
await tick(flow);
await flow.finishWatch();
assert.equal(flow.state.step, 'caught');
assert.equal(flow.state.writers[0].insn.text, 'mov [rcx+0x390], eax');
await flow.applyWriter();
const before = health();
await tick();
assert.equal(health(), before);
await flow.answerTest(true);
await flow.save('');
assert.equal(kept[0].name, 'Infinite health', 'an empty name falls back to a sensible one');
await close();
assertLetGo();
});
test('several places write the value: the one that ran on the action goes first, the next is offered if it fails', async () => {
const { flow, runner, close } = await makeFlow();
flow.chooseTarget('ammo');
flow.chooseRoute('number');
await flow.searchNumber('30');
await tick();
await flow.narrowNumber('29');
await flow.startProving();
await flow.answerProof(true);
await flow.chooseStop();
// While the user does nothing, the game already writes it from one place (say, a redraw)...
const score = GAME.code.scoreAdd;
await writeFrom(flow, score.offset + score.bytes.length);
await writeFrom(flow, score.offset + score.bytes.length);
// ...and from something that is not safe to remove at all.
await writeFrom(flow, 0x5000);
flow.quietOver();
// Then the user fires, and both the redraw and the real decrement run.
await writeFrom(flow, score.offset + score.bytes.length);
await tick(flow);
assert.deepEqual(flow.state.watch, { phase: 'act', places: 3, changes: 5 });
await flow.finishWatch();
assertLetGo();
assert.equal(flow.state.step, 'caught');
assert.deepEqual(flow.state.writers.map((w) => [w.insn.text, w.count, w.quiet, w.onAction]), [
['dec dword ptr [rbx+0x28]', 1, 0, true],
['add dword ptr [rdi+0x38880], 0xA', 3, 2, false],
]);
assert.equal(writerReason(flow.state.writers), 'action');
assert.equal(flow.state.unusable, 1);
// First try: the one that ran on the action. The user says it did not work.
await flow.applyWriter();
const first = flow.state.entry;
assert.equal(mock.game.image.subarray(AMMO_CODE.offset, AMMO_CODE.offset + 3).toString('hex'), '909090');
await flow.answerTest(false);
assert.ok(codeIntact(), 'switched off and restored');
assert.equal(runner.isOn(first.id), false);
assert.equal(flow.state.step, 'caught');
assert.equal(flow.state.writerIndex, 1);
// Second try: the other one. Still no.
await flow.applyWriter();
assert.equal(mock.game.image.subarray(score.offset, score.offset + 7).toString('hex'), '90'.repeat(7));
await flow.answerTest(false);
assert.ok(codeIntact());
assert.equal(flow.state.step, 'failed');
assert.deepEqual(flow.state.tried, { hold: false, stop: true });
// What is left: holding it. On the number route the number is asked for first.
flow.chooseAgain();
await flow.chooseHold();
assert.equal(flow.state.step, 'holdvalue');
await flow.holdAt('lots');
assert.match(flow.state.error, /whole number/);
await flow.holdAt('50');
assert.equal(flow.state.step, 'test');
assert.equal(ammo(), 50);
await tick();
await until(() => ammo() === 50, 'ammo to be held at 50');
// Back from the test switches it off and puts the old number back.
await flow.back();
assert.equal(flow.state.step, 'holdvalue');
await wait(300);
assert.equal(ammo(), 28, 'the number from before the hold');
assert.equal(flow.clean, true);
await close();
});
test('watching: nothing changes the value (the game is paused), and the game going away', async () => {
const { flow, close } = await makeFlow();
flow.chooseTarget('ammo');
flow.chooseRoute('number');
await flow.searchNumber('30');
await tick();
await flow.narrowNumber('29');
await flow.startProving();
await flow.answerProof(true);
await flow.chooseStop();
flow.quietOver();
await flow.finishWatch();
assert.equal(flow.state.step, 'nowriter');
assert.equal(flow.state.error, null);
assertLetGo();
// A second debugger is refused while the Code tab (or anything else) is attached, in plain words.
const other = await connect();
await other.debugAttach(GAME.pid);
flow.chooseAgain();
await flow.chooseStop();
assert.equal(flow.state.step, 'kind');
assert.match(flow.state.error, /already watching this game/);
other.close();
await until(() => mock.debug.owner === null, 'the other debugger to go');
// The console ends the session by itself, as when the game quits.
await flow.chooseStop();
assert.equal(flow.state.step, 'watch');
mock.debug.events.destroy();
await until(() => flow.state.step === 'nowriter', 'the flow to notice');
assert.match(flow.state.error, /ended the debugging session/);
mock.debug.owner?.destroy();
await until(() => mock.debug.owner === null, 'teardown');
await close();
assert.equal(flow.clean, true);
});
// The happy path cut into steps, so leaving can be tried after each one.
const PATH = [
['what', async (f) => f.chooseTarget('ammo')],
['shown', async (f) => f.chooseRoute('number')],
['number', async (f) => f.searchNumber('30')],
['change', async (f) => { await tick(); await f.narrowNumber('29'); }],
['ready', async (f) => f.startProving()],
['prove', async (f) => f.answerProof(true)],
['kind', async (f) => f.chooseStop()],
['watch (quiet)', async (f) => f.quietOver()],
['watch (act)', async (f) => { await tick(f); await f.finishWatch(); }],
['caught', async (f) => f.applyWriter()],
['test', async (f) => f.answerTest(true)],
['name', async () => {}],
];
for (let stop = 0; stop < PATH.length; stop++) {
test(`leaving at "${PATH[stop][0]}" puts the game back`, async () => {
const { flow, runner, kept, dbg, close } = await makeFlow();
for (let i = 0; i < stop; i++) await PATH[i][1](flow);
// What the game itself last wrote; the flow's own changes do not count.
const real = stop >= 9 ? 28 : stop >= 4 ? 29 : 30;
await flow.abort();
assert.equal(flow.clean, true);
assertLetGo();
assert.ok(codeIntact(), 'the game code is as it was');
assert.equal(ammo(), real, 'the value is as the game left it');
assert.equal(kept.length, 0);
if (flow.state.entry) assert.equal(runner.isOn(flow.state.entry.id), false);
// And the game runs freely: nothing is still holding or patching.
mock.tick();
await wait(HOLD_MS * 3);
assert.equal(ammo(), real - 1);
// Nothing the flow does after being told to stop has any effect.
await flow.searchNumber('28');
await flow.startProving();
await flow.chooseStop();
assert.equal(ammo(), real - 1);
assertLetGo();
await close();
dbg.close();
});
}
test('leaving while something is still running, and leaving the hold route', async () => {
// In the middle of the first search.
let w = await makeFlow();
w.flow.chooseTarget('ammo');
w.flow.chooseRoute('number');
let running = w.flow.searchNumber('30');
await w.flow.abort();
await running;
assert.equal(w.flow.clean, true);
await w.close();
// In the middle of attaching to watch: the debugger must not stay attached.
w = await makeFlow();
for (let i = 0; i < 6; i++) await PATH[i][1](w.flow);
running = w.flow.chooseStop();
await w.flow.abort();
await running;
assertLetGo();
assert.equal(w.flow.clean, true);
await w.close();
// In the middle of switching the new cheat on.
w = await makeFlow();
pristine.heap.copy(mock.game.heap);
for (let i = 0; i < 9; i++) await PATH[i][1](w.flow);
running = w.flow.applyWriter();
await w.flow.abort();
await running;
assert.ok(codeIntact());
assert.equal(w.flow.clean, true);
await w.close();
// Leaving at the very moment the cheat is being written into the game.
w = await makeFlow();
pristine.heap.copy(mock.game.heap);
for (let i = 0; i < 9; i++) await PATH[i][1](w.flow);
const enable = w.runner.enable.bind(w.runner);
let leaving;
w.runner.enable = (entry, opts) => {
leaving = w.flow.abort();
return enable(entry, opts);
};
await w.flow.applyWriter();
await leaving;
assert.ok(codeIntact(), 'the cheat that landed after leaving is taken out again');
assert.equal(w.flow.clean, true);
await w.close();
// While a possibility is being held still (the route with no number).
w = await makeFlow();
pristine.heap.copy(mock.game.heap);
w.flow.chooseTarget('health');
w.flow.chooseRoute('bar');
await w.flow.takeSnapshot();
await tick();
await w.flow.narrowBar('down');
await w.flow.startProving();
await w.flow.abort();
mock.tick();
await wait(HOLD_MS * 3);
assert.equal(health(), 98, 'nothing holds it after leaving');
await w.close();
// With a "hold" cheat switched on for testing but not confirmed.
w = await makeFlow();
pristine.heap.copy(mock.game.heap);
for (let i = 0; i < 6; i++) await PATH[i][1](w.flow);
await w.flow.chooseHold();
await w.flow.holdAt('999');
assert.equal(ammo(), 999);
await w.flow.abort();
assert.equal(ammo(), 29, 'the number from before the test is back');
mock.tick();
await wait(300);
assert.equal(ammo(), 28, 'and it is not held');
assert.equal(w.runner.isOn(w.flow.state.entry.id), false);
await w.close();
// A "hold" cheat the user confirmed and saved stays.
w = await makeFlow();
pristine.heap.copy(mock.game.heap);
for (let i = 0; i < 6; i++) await PATH[i][1](w.flow);
await w.flow.chooseHold();
await w.flow.holdAt('999');
await w.flow.answerTest(true);
await w.flow.save('Plenty');
await w.flow.abort();
assert.equal(w.kept[0].at.kind, 'address', 'a held value is only good until the game restarts');
mock.tick();
await until(() => ammo() === 999, 'the saved cheat to keep holding');
await w.runner.disable(w.kept[0]);
await w.close();
});
test('leaving after the console has gone away does not hang or throw', async () => {
const { flow, dbg, close } = await makeFlow();
for (let i = 0; i < 5; i++) await PATH[i][1](flow);
assert.equal(ammo(), 99);
dbg.close();
await flow.abort(); // the value cannot be put back, and that must not be an error
await close();
assertLetGo();
});
test('every target has the words the screens need', () => {
for (const t of TARGETS) {
for (const field of ['label', 'noun', 'act', 'actOnce', 'still', 'cheatName']) assert.ok(t[field], `${t.id}.${field}`);
assert.doesNotMatch(Object.values(t).join(' '), /address|pointer|instruction|hex|byte/i);
}
});