mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-23 11:05:54 +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
711 lines
29 KiB
JavaScript
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);
|
|
}
|
|
});
|