Files
2026-10-12 01:39:58 -07:00

1282 lines
72 KiB
JavaScript

// Code cheats: labels, if/else, variables, names shared between cheats,
// shared code, several places under one switch, and the rule builder,
// against the mock console.
//
// The mock assembles with Keystone, the assembler the PS5 uses, when its
// Python module is installed (pip install keystone-engine); the tests that
// need real instructions are skipped without it. The mock also runs the
// injected machine code (mock/x86emu.mjs), so "does the cheat do what it
// says" is checked by letting the pretend game run, not by reading bytes.
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { startMock, GAME } from '../mock/mock-ps5.mjs';
import { assemble as mockAssemble, hasKeystone, jsAssemble } from '../mock/assembler.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, canKeep, describeEntry, emptyTable, exportOwnTable, keepProblem, parseOwnTable, saveFileText } from '../src/lib/table.ts';
import { AsmError, buildUnit, nameProblem, outlineUnit, parseCode } from '../src/lib/asm.ts';
import { checkSource, codeDependents, codeListing, codeNeeds, codeOutline, coverInsns, describeRule, enableOrder, ruleCode, ruleTarget } from '../src/lib/codecheat.ts';
import { codeCheatEntry, codeSite } from '../src/lib/make.ts';
import { WORKER_SOURCE, commandValues, handleScriptCall, hostScript, readParams } from '../src/lib/script.ts';
import vm from 'node:vm';
import { bytesToHex, hexToBytes } from '../src/lib/bytes.ts';
const require = createRequire(import.meta.url);
const sync = createX86(require('iced-x86'));
const x86 = asAsync(sync);
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
const TCP = 17570 + PORT_OFFSET;
const TCP_FAR = 17571 + PORT_OFFSET;
const TCP_JS = 17572 + PORT_OFFSET;
let mock, mockFar, mockJs, bridge, bridgeFar, bridgeJs, port, portFar, portJs;
const KS = hasKeystone();
const needsKeystone = { skip: KS ? false : 'Keystone is not installed here (pip install keystone-engine)' };
const IMAGE = GAME.imageBase;
const HEALTH = GAME.vars.health;
const at = (name) => ({ kind: 'module', offset: GAME.code[name].offset.toString(16) });
const siteOf = (name) => IMAGE + BigInt(GAME.code[name].offset);
const F = GAME.character;
const url = (p, tcp) => `ws://127.0.0.1:${p}/ws?host=127.0.0.1&port=${tcp}`;
const connect = async (far = false) => new Ps5Debug(await openWebSocketStream(url(far ? portFar : port, far ? TCP_FAR : TCP)), { timeoutMs: 8000 });
/** A game context and a runner over `entries`, as the app sets them up. */
async function setup(entries = [], far = false) {
// Each test starts from the game as shipped, whatever an earlier one left behind.
const m = far ? mockFar : mock;
(far ? pristineFar : pristine).copy(m.game.image);
m.state.crashed = null;
m.game.heap.writeUInt32LE(100, HEALTH);
const dbg = await connect(far);
const assembled = [];
const assemble = async (base, text) => {
assembled.push(text);
const tmp = await connect(far);
try {
return await tmp.assemble(base, text);
} finally {
if (!tmp.closed) tmp.close();
}
};
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86, assemble });
const runner = new TableRunner(ctx, { entries: () => entries });
return { dbg, ctx, runner, assembled, m, done: () => (runner.dispose(), dbg.close()) };
}
/** The game's code as it is before any cheat, to compare against afterward. */
let pristine, pristineFar;
const untouched = (m = mock) => assert.deepEqual(Buffer.from(m.game.image), m === mock ? pristine : pristineFar, "the game's code is exactly as it was");
const frames = (m, n) => {
for (let i = 0; i < n; i++) m.tick();
assert.equal(m.state.crashed, null, 'the game did not crash');
};
/** Instructions at an address in the pretend game. */
const listing = (m, address, bytes = 96, count = 40) => sync.disassemble(Buffer.from(m.game.read(address, bytes)), address, count);
/** Where the jump written over a hooked instruction leads. */
const hookTarget = (m, site) => {
const [jump] = listing(m, site, 16, 1);
assert.equal(jump.flow, 'jump');
return jump.target ?? Buffer.from(m.game.read(site + 6n, 8)).readBigUInt64LE(0);
};
const stamina = (m, i) => m.game.heap.readUInt32LE(GAME.characters[i] + F.stamina);
const allocations = (m) => m.game.regions.filter((r) => r.allocated).length;
before(async () => {
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
mockFar = startMock({ tcpPort: TCP_FAR, udpPort: 0, tick: false, noNearAlloc: true });
await Promise.all([mock.ready, mockFar.ready]);
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 't' } });
bridgeFar = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP_FAR, udpPort: 0, label: 't' } });
port = await bridge.ready;
portFar = await bridgeFar.ready;
// A console as the demo is on a computer without Keystone: the mock's own small assembler.
mockJs = startMock({ tcpPort: TCP_JS, udpPort: 0, tick: false, noKeystone: true });
await mockJs.ready;
bridgeJs = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP_JS, udpPort: 0, label: 't' } });
portJs = await bridgeJs.ready;
pristine = Buffer.from(mock.game.image);
pristineFar = Buffer.from(mockFar.game.image);
});
after(async () => {
await bridge.close();
await bridgeFar.close();
await bridgeJs.close();
await mockJs.close();
await mock.close();
await mockFar.close();
});
// The screenshot's cheat, moved to the pretend game. There: "mov [rbx+0x730], r14d"
// with r10d telling the player from an enemy. Here: every character's stamina
// is counted up by one line, "inc dword ptr [rdi+0x10]", with the character
// in rdi and its team at +0x18 (1 = the player).
const DRAIN_ENEMIES = `staminaTarget:
cmp dword ptr [rdi+0x18], 1 ; is this the player?
je isPlayer
mov dword ptr [rdi+0x10], 0 ; an enemy: no stamina at all
jmp staminaReturn
isPlayer:
original
jmp staminaReturn`;
test('the screenshot cheat end to end: a decision per character, made by code with labels', needsKeystone, async () => {
const entry = { id: 'ohk', type: 'inject', name: 'Enemies have no stamina', symbol: 'stamina', at: at('staminaTick'), code: DRAIN_ENEMIES, placement: 'code' };
const { runner, done } = await setup([entry]);
const site = siteOf('staminaTick');
frames(mock, 2);
const start = [0, 1, 2].map((i) => stamina(mock, i));
await runner.enable(entry);
assert.equal(runner.isOn('ohk'), true);
// The game's line is now a jump into new memory near the game.
const cave = hookTarget(mock, site);
assert.ok(cave > IMAGE && cave - IMAGE < 0x7fffffffn);
const code = listing(mock, cave, 64);
const text = code.map((i) => i.text);
assert.equal(text[0], 'cmp dword ptr [rdi+0x18], 1');
assert.equal(code[1].flow, 'cond');
assert.equal(text[2], 'mov dword ptr [rdi+0x10], 0');
// The forward jump lands on the label's line, which is the game's own instruction again.
const inc = code.find((i) => i.text === 'inc dword ptr [rdi+0x10]');
assert.equal(code[1].target, inc.address);
assert.equal(runner.symbolAddress('isPlayer'), inc.address);
assert.equal(runner.symbolAddress('staminaTarget'), cave);
assert.equal(runner.symbolAddress('staminaSource'), site);
// The 5-byte jump is longer than the 3-byte line, so it also replaced the `ret` after it and a
// filler byte. Those were moved to where staminaReturn leads, and run there for both cases.
const ret = runner.symbolAddress('staminaReturn');
assert.ok(ret > inc.address && ret < cave + 64n, 'a label of the new code');
assert.deepEqual(code.filter((i) => i.flow === 'jump' && i.address < ret).map((i) => i.target), [ret, ret]);
assert.deepEqual(listing(mock, ret, 16, 3).map((i) => i.text.split(' ')[0]), ['ret', 'int3', 'jmp']);
assert.equal(listing(mock, ret, 16, 3)[2].target, site + 5n, 'and after them, back to the game');
frames(mock, 5);
assert.equal(stamina(mock, 0), start[0] + 5, 'the player still gains stamina');
assert.equal(stamina(mock, 1), 0, 'the Sentry has none');
assert.equal(stamina(mock, 2), 0, 'the Dog has none');
await runner.disable(entry);
untouched();
frames(mock, 2);
assert.equal(stamina(mock, 1), 2, 'with the cheat off the game counts again');
done();
});
// ---- reading the text (no console needed) ---------------------------------------------
test('the text: comments, labels, names and what is refused before anything is sent', () => {
const p = parseCode('start: mov eax, 1 ; a comment\n // another\nloop_1: dec eax # third kind\n jnz loop_1\n db 0x90, 90h\n original\n lock inc dword ptr [counter]\n');
assert.deepEqual([...p.labels.keys()], ['start', 'loop_1']);
assert.deepEqual(p.lines.filter((l) => l.kind !== 'empty').map((l) => [l.no, l.kind, l.mnemonic]), [
[1, 'insn', 'mov'], [3, 'insn', 'dec'], [4, 'insn', 'jnz'], [5, 'data', '.byte'], [6, 'original', ''], [7, 'insn', 'inc'],
]);
assert.equal(p.lines[4].text, '.byte 0x90, 90h', 'db is spelled the way the console assembler knows it');
assert.deepEqual(p.lines[6].refs.map((r) => r.name), ['counter'], 'registers, sizes and numbers are not names');
assert.equal(p.usesOriginal, true);
assert.throws(() => parseCode('a:\nnop\na:\nnop'), /Line 3: the label "a" is already used on line 1/);
assert.throws(() => parseCode('rax: nop'), /processor register/);
assert.throws(() => parseCode('return: nop'), /fixed meaning/);
assert.throws(() => parseCode('mov eax, [rbx+[rcx]]'), /inside another/);
assert.equal(nameProblem('speedMultiplier'), null);
assert.match(nameProblem('2fast'), /letters, digits/);
assert.match(nameProblem('dword'), /already means something/);
// What a piece of code defines, and what it has to get from somewhere else.
const outline = outlineUnit({
blocks: [{ code: 'again:\n mov eax, dword ptr [mine]\n cmp eax, [theirs]\n jne again\n call helper\n jmp return', locals: new Map([['return', 0n]]) }],
variables: [{ name: 'mine', type: 'i32', value: '5' }],
});
assert.deepEqual(outline.defines.sort(), ['again', 'mine']);
assert.deepEqual(outline.needs.sort(), ['helper', 'theirs']);
assert.throws(() => outlineUnit({ blocks: [{ code: 'mine: nop' }], variables: [{ name: 'mine', type: 'i32', value: '5' }] }), /already the name of a variable/);
assert.throws(() => outlineUnit({ blocks: [{ code: 'nop' }], variables: [{ name: 'v', type: 'float', value: 'fast' }] }), /variable "v".*not a number/);
assert.throws(() => outlineUnit({ blocks: [{ code: 'nop' }], variables: [{ name: 'v', type: 'bytes', value: 'zz' }] }), /hex bytes/);
});
test('the text sent to the console: what is rewritten, and what is left to its assembler', async () => {
// A stand-in assembler that records the text and answers with the right number of table entries.
const sent = [];
const env = {
assemble: async (base, text) => {
sent.push(text);
const names = text.split('\n').filter((l) => l.startsWith('.quad __') || /^\.quad [A-Za-z]/.test(l));
const out = Buffer.alloc(16 + names.length * 8);
names.forEach((_, i) => out.writeBigUInt64LE(base + 8n, 16 + i * 8));
return new Uint8Array(out);
},
relocate: async (bytes) => bytes,
};
const base = 0x7fff00010000n;
const near = base + 0x5000n;
const far = 0x10000000n;
await buildUnit(env, base, {
blocks: [{
code: ['top:', ' mov eax, dword ptr [mine]', ' movss xmm0, [nearThing+4]', ' call nearThing', ' call farThing', ' mov rax, farThing', ' mov rcx, top', ' lea rdx, [mine]', ' dq top, nearThing', ' jmp elsewhere', ' jmp return'].join('\n'),
locals: new Map([['elsewhere', far + 0x10n]]),
returnTo: near + 6n,
}],
variables: [{ name: 'mine', type: 'float', value: '1.5' }],
resolve: (name) => ({ nearThing: near, farThing: far })[name],
});
const text = sent[0];
const has = (line) => assert.ok(text.split('\n').includes(line), `expected the line "${line}" in:\n${text}`);
has('mov eax, dword ptr [rip + mine]'); // own variable: relative to the code, which reaches it wherever both are put
has(`movss xmm0, [0x${near.toString(16)}+4]`); // defined elsewhere, in reach: its address
has(`call 0x${near.toString(16)}`);
has('call __cs_far0'); // out of reach of a call: through a jump that holds the whole address
has(`mov rax, 0x${far.toString(16)}`);
has('movabs rcx, top'); // the address of a label needs the 64-bit form
has('lea rdx, [rip + mine]');
has(`.quad top, 0x${near.toString(16)}`);
has('jmp __cs_far1'); // a name with a far address that only this block knows
// "return" is where the game carries on; the same jump is added after the last line.
assert.equal(text.split('\n').filter((l) => l === `jmp 0x${(near + 6n).toString(16)}`).length, 2);
has('.byte 0x0, 0x0, 0xc0, 0x3f'); // 1.5 as a float
assert.match(text, /__cs_far0:\n\.byte 0xff, 0x25, 0, 0, 0, 0\n\.quad 0x10000000\n/);
const refuse = (code, pattern, resolve = () => undefined) =>
assert.rejects(buildUnit(env, base, { blocks: [{ code }], variables: [{ name: 'mine', type: 'i32', value: '1' }], resolve }), pattern);
await refuse('mov eax, [farThing]', /Line 1: "farThing" is too far from this code.*mov rax, farThing/, () => far);
await refuse('nop\nmov eax, [rbx+mine]', /Line 2: "mine" cannot be combined with a register/);
await refuse('cmp rax, mine', /Line 1: "mine" is an address.*lea rax, \[mine\]/);
await refuse('mov eax, isPlayr', /Line 1: "isPlayr" is not defined\. This cheat defines: mine/);
await refuse('mov eax, FFh', /write it as 0xFF/);
await refuse('dd mine', /needs 8 bytes/);
await refuse('original', /not a hook/);
assert.ok((await refuse('nop', /x/).catch((e) => e)) instanceof Error);
});
// ---- on the pretend console, with the real assembler ---------------------------------------
const resetHealth = (m = mock) => m.game.heap.writeUInt32LE(100, HEALTH);
const health = (m = mock) => m.game.heap.readUInt32LE(HEALTH);
test('labels in both directions, and a variable the code writes and the user reads', needsKeystone, async () => {
const entry = {
id: 'loop', type: 'inject', name: 'Count to three', at: at('healthDrain'), placement: 'code',
variables: [{ name: 'turns', type: 'i32', value: '0' }, { name: 'runs', type: 'u64', value: '0' }],
code: ` mov dword ptr [turns], 0
mov edx, 3
again:
inc dword ptr [turns]
dec edx
jnz again ; back up
cmp dword ptr [turns], 3
je done ; forward, over the trap
int3
done:
inc qword ptr [runs]
original
jmp return`,
};
const { runner, done } = await setup([entry]);
resetHealth();
await runner.enable(entry);
const cave = hookTarget(mock, siteOf('healthDrain'));
const code = listing(mock, cave, 80);
const back = code.find((i) => i.text.startsWith('jne'));
const forward = code.find((i) => i.text.startsWith('je '));
assert.ok(back.target < back.address, 'jnz again goes backward');
assert.equal(back.target, runner.symbolAddress('again'));
assert.ok(forward.target > forward.address, 'je done goes forward');
assert.equal(forward.target, runner.symbolAddress('done'));
// The variables sit after the code, out of its way, and the code reaches them relative to itself.
const vars = runner.codeVariables('loop');
assert.deepEqual(vars.map((v) => [v.name, v.type, v.size]), [['turns', 'i32', 4], ['runs', 'u64', 8]]);
assert.ok(vars[0].address >= cave + 64n && vars[0].address % 4n === 0n && vars[1].address % 8n === 0n);
assert.equal(code[0].ripTarget, vars[0].address);
frames(mock, 4);
assert.equal(await runner.readVariable('loop', 'turns'), '3');
assert.equal(await runner.readVariable('loop', 'runs'), '4', 'once per frame');
assert.equal(health(), 96, 'the game\'s own line still ran each time');
await runner.disable(entry);
untouched();
await assert.rejects(runner.readVariable('loop', 'turns'), /only be read or changed while its cheat is switched on/);
done();
});
test('variables: typed, with a starting value, changed while the cheat is on', needsKeystone, async () => {
const entry = {
id: 'pin', type: 'inject', name: 'Pinned health', at: at('healthDrain'), placement: 'code',
variables: [
{ name: 'pinned', type: 'i32', value: '250' },
{ name: 'ratio', type: 'float', value: '1.5' },
{ name: 'big', type: 'double', value: '-2.25' },
{ name: 'small', type: 'u8', value: '200' },
{ name: 'blob', type: 'bytes', value: 'DE AD BE EF 01' },
],
code: ' mov eax, dword ptr [pinned] ; what the game stores is replaced by the variable\n original',
};
const { runner, done } = await setup([entry]);
resetHealth();
await runner.enable(entry);
frames(mock, 1);
assert.equal(health(), 250);
for (const [name, text] of [['pinned', '250'], ['ratio', '1.5'], ['big', '-2.25'], ['small', '200'], ['blob', 'DE AD BE EF 01']]) {
assert.equal(await runner.readVariable('pin', name), text, name);
}
await runner.writeVariable('pin', 'pinned', '999');
frames(mock, 1);
assert.equal(health(), 999, 'the code sees the new value the next time it runs');
await runner.writeVariable('pin', 'blob', '01 02 03 04 05');
assert.equal(await runner.readVariable('pin', 'blob'), '01 02 03 04 05');
await assert.rejects(runner.writeVariable('pin', 'blob', '01 02'), /holds 5 bytes/);
await assert.rejects(runner.writeVariable('pin', 'pinned', 'lots'), /not a whole number/);
await assert.rejects(runner.writeVariable('pin', 'nope', '1'), /only be read or changed/);
assert.deepEqual(runner.findVariable('ratio')?.variable.type, 'float');
assert.equal(runner.findVariable('nope'), null);
await runner.disable(entry);
untouched();
done();
});
test("an instruction that finds its data relative to itself is re-made for its new place", needsKeystone, async () => {
const entry = {
id: 'rip', type: 'inject', name: 'Remember the player', at: at('loadPlayer'), placement: 'code',
variables: [{ name: 'seen', type: 'u64', value: '0' }],
code: ' original\n mov qword ptr [seen], rax',
};
const { runner, done } = await setup([entry]);
await runner.enable(entry);
const cave = hookTarget(mock, siteOf('loadPlayer'));
const [first] = listing(mock, cave, 16, 1);
assert.equal(first.ripTarget, GAME.dataBase + BigInt(GAME.statics.player), 'still reads the same global');
assert.notDeepEqual(Buffer.from(first.bytes), GAME.code.loadPlayer.bytes, 'through a different displacement');
frames(mock, 1);
assert.equal(BigInt(await runner.readVariable('rip', 'seen')), GAME.heapBase + BigInt(GAME.player));
await runner.disable(entry);
untouched();
done();
});
test('old injected-code entries still load and run as they did', needsKeystone, async () => {
// A file as saved before code cheats had labels, variables or "original" lines.
const file = JSON.stringify({
format: 'cheatspecter', formatVersion: 1, game: { name: 'Specter Run', titleId: 'PPSA99999', version: '01.000.000' },
cheats: [
{ name: 'Before', type: 'inject', reviewed: true, at: { kind: 'module', offset: '1000' }, code: 'mov eax, 999', placement: 'before' },
{ name: 'After', type: 'inject', reviewed: true, at: { kind: 'module', offset: '1000' }, code: 'mov dword ptr [rcx+0x390], 555', placement: 'after' },
{ name: 'Replace', type: 'inject', reviewed: true, at: { kind: 'module', offset: '1000' }, code: 'nop', placement: 'replace' },
{ name: 'Theirs', type: 'inject', at: { kind: 'signature', pattern: '89 81 90 03 00 00', offset: 0 }, code: 'nop', placement: 'before' },
],
});
const table = parseOwnTable(file, { own: true });
assert.deepEqual(table.entries.map((e) => [e.type, e.placement, !!e.unreviewed]), [['inject', 'before', false], ['inject', 'after', false], ['inject', 'replace', false], ['inject', 'before', true]]);
// Saving it again writes the same cheats, field for field.
assert.deepEqual(JSON.parse(saveFileText(table)).cheats, JSON.parse(file).cheats);
// The oldest export format, with everything at the top level.
const legacy = parseOwnTable(JSON.stringify({ format: 'cheatspecter/1', titleId: 'PPSA99999', version: '1', name: 'x', entries: [{ name: 'L', type: 'inject', at: { kind: 'module', offset: '1000' }, code: 'nop', placement: 'after' }] }));
assert.equal(legacy.entries[0].placement, 'after');
assert.equal(describeEntry(table.entries[0]), 'Code cheat at game+0x1000');
const { runner, done } = await setup(table.entries);
const [beforeIt, afterIt, replaceIt] = table.entries;
resetHealth();
await runner.enable(beforeIt);
frames(mock, 1);
assert.equal(health(), 999, '"before": the new code ran, then the game\'s line stored what it left in eax');
await runner.disable(beforeIt);
untouched();
await runner.enable(afterIt);
frames(mock, 1);
assert.equal(health(), 555, '"after": the game\'s line ran, then the new code overwrote the value');
await runner.disable(afterIt);
resetHealth();
await runner.enable(replaceIt);
frames(mock, 3);
assert.equal(health(), 100, '"replace": the game\'s line no longer runs');
await runner.disable(replaceIt);
untouched();
frames(mock, 1);
assert.equal(health(), 99);
done();
});
// ---- names shared between cheats ------------------------------------------------------------
const GOD_FLOOR = {
id: 'floor', type: 'inject', name: 'Health floor', symbol: 'drain', at: at('healthDrain'), placement: 'code',
variables: [{ name: 'floor', type: 'i32', value: '60' }],
code: ' cmp eax, dword ptr [floor]\n jge fine\n mov eax, dword ptr [floor]\nfine:\n original',
};
// Uses a variable of another code cheat.
const SCORE_BONUS = {
id: 'bonus', type: 'inject', name: 'Score bonus', at: at('scoreAdd'), placement: 'code',
code: ' original\n mov edx, dword ptr [floor]\n add dword ptr [rdi+0x38880], edx',
};
// A held value with a pointer path, given a name in code; and one of the game's functions, as a call entry.
const GOLD = {
id: 'gold', type: 'value', name: 'Gold', symbol: 'goldValue', valueType: 'float', value: '5', freeze: true,
at: { kind: 'pointer', base: (0x10000 + GAME.statics.player).toString(16), offsets: ['0x' + (GAME.vars.gold - GAME.player).toString(16)] },
};
const ADD = { id: 'add', type: 'call', name: 'Add two numbers', symbol: 'addNumbers', fn: { kind: 'module', offset: GAME.funcs.add.toString(16) }, args: [] };
const score = (m = mock) => m.game.heap.readUInt32LE(GAME.vars.score);
const gold = (m = mock) => m.game.heap.readFloatLE(GAME.vars.gold);
test('references: a cheat that uses another cheat\'s names switches that one on first', needsKeystone, async () => {
const entries = [SCORE_BONUS, GOD_FLOOR, GOLD, ADD];
assert.deepEqual(codeOutline(GOD_FLOOR), { defines: ['floor', 'fine', 'drainSource', 'drainReturn'], needs: [] });
assert.deepEqual(codeNeeds(entries, SCORE_BONUS).map((n) => [n.name, n.provider.name]), [['floor', 'Health floor']]);
assert.deepEqual(enableOrder(entries, SCORE_BONUS).map((e) => e.name), ['Health floor', 'Score bonus']);
assert.deepEqual(codeDependents(entries, GOD_FLOOR).map((e) => e.name), ['Score bonus']);
const { runner, done } = await setup(entries);
const before = allocations(mock);
await runner.enable(SCORE_BONUS);
assert.deepEqual([runner.isOn('floor'), runner.isOn('bonus')], [true, true], 'what it needs came on with it');
assert.equal(runner.isAuto('floor'), true);
assert.equal(allocations(mock), before + 2, 'each has its own block');
assert.deepEqual(runner.dependentsOn('floor'), ['Score bonus']);
const s0 = score();
frames(mock, 1);
assert.equal(score(), s0 + 10 + 60, 'the bonus code read the other cheat\'s variable');
await runner.writeVariable('floor', 'floor', '5');
frames(mock, 1);
assert.equal(score(), s0 + 80 + 5);
// Switching the dependent off also switches off what was only on for its sake.
await runner.disable(SCORE_BONUS);
assert.deepEqual([runner.isOn('floor'), runner.isOn('bonus')], [false, false]);
untouched();
// Switched on by hand, the provider stays on by itself afterward...
await runner.enable(GOD_FLOOR);
await runner.enable(SCORE_BONUS);
assert.equal(runner.isAuto('floor'), false);
await runner.disable(SCORE_BONUS);
assert.equal(runner.isOn('floor'), true);
// ...and switching the provider off takes the dependent off first, and says so.
await runner.enable(SCORE_BONUS);
await runner.disable(GOD_FLOOR);
assert.deepEqual([runner.isOn('floor'), runner.isOn('bonus')], [false, false]);
assert.match(runner.result('floor'), /Also switched off, because it uses this one: "Score bonus"/);
untouched();
// Switching on a cheat that was only on for another's sake keeps it on.
await runner.enable(SCORE_BONUS);
await runner.enable(GOD_FLOOR);
await runner.disable(SCORE_BONUS);
assert.equal(runner.isOn('floor'), true);
await runner.disable(GOD_FLOOR);
untouched();
done();
});
test('references: a named value entry and a named game function, near or far', needsKeystone, async () => {
const uses = {
id: 'uses', type: 'inject', name: 'Uses the others', at: at('scoreAdd'), placement: 'code',
variables: [{ name: 'sum', type: 'u64', value: '0' }, { name: 'drainAt', type: 'u64', value: '0' }],
code: ` original
mov rax, goldValue ; the Gold entry's address: too far for [goldValue], fine as a number
mov dword ptr [rax], 0x447A0000 ; 1000.0
mov rdi, 40
mov rsi, 2
call addNumbers ; one of the game's own functions
mov qword ptr [sum], rax
mov rax, drainSource ; where another code cheat hooks
mov qword ptr [drainAt], rax`,
};
const entries = [GOD_FLOOR, GOLD, ADD, uses];
assert.deepEqual(codeNeeds(entries, uses).map((n) => [n.name, n.provider.name]), [['goldValue', 'Gold'], ['addNumbers', 'Add two numbers'], ['drainSource', 'Health floor']]);
const { runner, done } = await setup(entries);
await runner.enable(uses);
assert.equal(runner.isOn('floor'), true, 'the code cheat it names came on');
assert.equal(runner.isOn('gold'), false, 'a value entry is only looked up, not switched');
frames(mock, 1);
assert.equal(gold(), 1000);
assert.equal(await runner.readVariable('uses', 'sum'), '42');
assert.equal(BigInt(await runner.readVariable('uses', 'drainAt')), siteOf('healthDrain'));
await runner.disable(uses);
untouched();
// Reading the far value directly cannot be encoded; the message says what to write instead.
const direct = { ...uses, id: 'direct', code: ' mov eax, dword ptr [goldValue]\n original' };
const blocks = allocations(mock);
await assert.rejects(runner.enable(direct), /Line 1: "goldValue" is too far from this code to be used inside \[ \] directly\. Load its address first: mov rax, goldValue/);
untouched();
assert.equal(allocations(mock), blocks, 'the memory set aside for it was given back');
assert.equal(runner.isOn('floor'), false, 'and nothing it needed was left on');
done();
});
test('references: what cannot be linked is refused in words, with nothing changed', needsKeystone, async () => {
const x = { id: 'x', type: 'inject', name: 'X', at: at('healthDrain'), placement: 'code', variables: [{ name: 'xVar', type: 'i32', value: '1' }], code: 'mov edx, dword ptr [yVar]\noriginal' };
const y = { id: 'y', type: 'inject', name: 'Y', at: at('scoreAdd'), placement: 'code', variables: [{ name: 'yVar', type: 'i32', value: '1' }], code: 'mov edx, dword ptr [xVar]\noriginal' };
const typo = { id: 't', type: 'inject', name: 'Typo', at: at('healthDrain'), placement: 'code', code: 'jmp isPlayr\nisPlayer:\noriginal' };
const twin = { ...GOD_FLOOR, id: 'twin', name: 'Second floor', symbol: undefined, at: at('scoreAdd') };
const wantsFloor = { ...SCORE_BONUS, id: 'wf', name: 'Wants floor', at: at('loadPlayer') };
const imported = { ...GOD_FLOOR, id: 'imp', name: 'From a file', unreviewed: true };
const needsImported = { ...SCORE_BONUS, id: 'ni', name: 'Needs the file one' };
assert.throws(() => enableOrder([x, y], x), /need each other in a circle.*"X" needs "Y" needs "X".*Shared code/);
const { runner, done } = await setup([x, y, typo]);
const blocks = allocations(mock);
await assert.rejects(runner.enable(x), /circle/);
await assert.rejects(runner.enable(typo), /"isPlayr" is used in "Typo" but is not defined there or in any other of your cheats/);
untouched();
assert.equal(allocations(mock), blocks);
done();
const two = await setup([GOD_FLOOR, twin, wantsFloor]);
await assert.rejects(two.runner.enable(wantsFloor), /"floor" is defined in more than one place: "Health floor" and "Second floor"\. Rename one/);
// Each of the two can still be used by itself: a name only has to be unique for whoever uses it from outside.
await two.runner.enable(twin);
await two.runner.disable(twin);
untouched();
two.done();
const three = await setup([imported, needsImported]);
await assert.rejects(three.runner.enable(needsImported), /uses "From a file", which came from a file and has not been reviewed yet/);
untouched();
three.done();
});
// ---- shared code -----------------------------------------------------------------------------
const CLAMP = {
id: 'clamp', type: 'shared', name: 'Clamp', symbol: 'clampValue',
variables: [{ name: 'lowest', type: 'i32', value: '40' }],
code: ' cmp eax, dword ptr [lowest]\n jge clampDone\n mov eax, dword ptr [lowest]\nclampDone:\n ret',
};
const USE_1 = { id: 'u1', type: 'inject', name: 'Health never under the lowest', at: at('healthDrain'), placement: 'code', code: ' call clampValue\n original' };
const USE_2 = { id: 'u2', type: 'inject', name: 'Score gains the lowest too', at: at('scoreAdd'), placement: 'code', code: ' mov eax, 5\n call clampValue\n add dword ptr [rdi+0x38880], eax\n original' };
test('shared code: defined once, called from several cheats, in the game while any of them is on', needsKeystone, async () => {
const entries = [USE_1, USE_2, CLAMP];
assert.equal(describeEntry(CLAMP), 'Shared code "clampValue", for other cheats to use');
assert.deepEqual(codeOutline(CLAMP).defines.sort(), ['clampDone', 'clampValue', 'lowest']);
const { runner, done } = await setup(entries);
const blocks = allocations(mock);
mock.game.heap.writeUInt32LE(42, HEALTH);
await runner.enable(USE_1);
assert.equal(runner.isOn('clamp'), true);
assert.equal(allocations(mock), blocks + 2);
const fn = runner.symbolAddress('clampValue');
// The call in the cheat's code goes to the shared block.
const call = listing(mock, hookTarget(mock, siteOf('healthDrain')), 32).find((i) => i.flow === 'call');
assert.equal(call.target, fn);
assert.deepEqual(listing(mock, fn, 32, 4).map((i) => i.text.split(' ')[0]), ['cmp', 'jge', 'mov', 'ret']);
await runner.enable(USE_2);
assert.equal(allocations(mock), blocks + 3, 'the second user got its own block; the shared one was not made again');
assert.equal(runner.symbolAddress('clampValue'), fn);
const s0 = score();
frames(mock, 3);
assert.equal(health(), 40, 'held at the shared variable');
assert.equal(score(), s0 + 3 * (10 + 40));
// One variable, in one place, for every user.
await runner.writeVariable('clamp', 'lowest', '70');
frames(mock, 1);
assert.equal(health(), 70);
assert.equal(score(), s0 + 3 * 50 + 80);
await runner.disable(USE_1);
assert.equal(runner.isOn('clamp'), true, 'still used by the other cheat');
await runner.disable(USE_2);
assert.equal(runner.isOn('clamp'), false, 'nobody uses it any more');
untouched();
// It can also be switched on by itself (to check it assembles), and then stays until switched off.
await runner.enable(CLAMP);
assert.equal(runner.isOn('clamp'), true);
await runner.enable(USE_1);
await runner.disable(USE_1);
assert.equal(runner.isOn('clamp'), true);
await runner.enable(USE_1);
await runner.disable(CLAMP);
assert.deepEqual([runner.isOn('clamp'), runner.isOn('u1')], [false, false], 'switching shared code off switches its users off first');
untouched();
done();
});
// ---- several places under one switch --------------------------------------------------------
const TWO_PLACES = {
id: 'two', type: 'inject', name: 'Mirror health into the score', symbol: 'mirror', at: at('healthDrain'), placement: 'code',
variables: [{ name: 'last', type: 'i32', value: '0' }],
code: ' mov dword ptr [last], eax\n original',
more: [{ at: at('scoreAdd'), symbol: 'mirrorScore', placement: 'code', code: ' original\n mov edx, dword ptr [last]\n mov dword ptr [rdi+0x38884], edx\n jmp mirrorScoreReturn' }],
};
test('several places under one switch share variables and labels, all or nothing', needsKeystone, async () => {
assert.equal(describeEntry(TWO_PLACES), 'Code cheat at game+0x1000 and 1 more place');
assert.deepEqual(codeOutline(TWO_PLACES).defines, ['last', 'mirrorSource', 'mirrorReturn', 'mirrorScoreSource', 'mirrorScoreReturn']);
const { runner, done } = await setup([TWO_PLACES]);
const blocks = allocations(mock);
await runner.enable(TWO_PLACES);
assert.equal(allocations(mock), blocks + 1, 'one block holds the code for both places');
const a = hookTarget(mock, siteOf('healthDrain'));
const b = hookTarget(mock, siteOf('scoreAdd'));
assert.ok(b > a && b - a < 64n);
assert.equal(runner.symbolAddress('mirrorScoreReturn'), siteOf('scoreAdd') + 7n);
frames(mock, 2);
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.score + 4), 98, 'the second place used what the first one stored');
await runner.disable(TWO_PLACES);
untouched();
// The second place does not fit this game: neither place is touched.
const misfit = { ...TWO_PLACES, more: [{ ...TWO_PLACES.more[0], off: '83 87 80 88 03 00 0B' }] };
await assert.rejects(runner.enable(misfit), /not what this cheat expects/);
untouched();
assert.equal(runner.isOn('two'), false);
await runner.enable(misfit, { force: true });
assert.notDeepEqual(Buffer.from(mock.game.image), pristine);
await runner.disable(misfit);
untouched();
// Two places on the same spot, and labels clashing between places.
await assert.rejects(runner.enable({ ...TWO_PLACES, more: [{ ...TWO_PLACES.more[0], at: at('healthDrain') }] }), /Place 2: This is the same spot as another place/);
await assert.rejects(runner.enable({ ...TWO_PLACES, code: 'again:\noriginal', more: [{ ...TWO_PLACES.more[0], code: 'nop\nagain:\noriginal' }] }), /Place 2, line 2: the name "again" is used twice/);
await assert.rejects(runner.enable({ ...TWO_PLACES, more: [{ ...TWO_PLACES.more[0], code: 'original\nbogus eax' }] }), /Place 2, line 2 \(bogus eax\): the assembler does not know this instruction/);
untouched();
done();
});
// ---- every way switching on can fail leaves the game as it was --------------------------------
test('undo: whatever fails, the game\'s code is put back and nothing is left half on', needsKeystone, async () => {
const { runner, ctx, dbg, done } = await setup([TWO_PLACES, CLAMP, USE_1]);
const blocks = allocations(mock);
const fails = async (entry, pattern, opts) => {
await assert.rejects(runner.enable(entry, opts), pattern);
untouched();
assert.equal(runner.isOn(entry.id), false);
assert.deepEqual(runner.onIds(), []);
};
// The text does not assemble: said with the line, and the memory set aside is given back.
await fails({ ...TWO_PLACES, code: 'mov dword ptr [last], eax\nnop\nfrobnicate rax\noriginal' }, /Place 1, line 3 \(frobnicate rax\): the assembler does not know this instruction \[Assembler: Invalid mnemonic/);
await fails({ ...USE_1, code: 'mov eax, ebx, ecx\noriginal' }, /Line 1 \(mov eax, ebx, ecx\): these operands do not fit this instruction/);
assert.equal(allocations(mock), blocks);
// The place is not what the cheat was made for.
await fails({ ...USE_1, off: '89 81 90 03 00 01' }, /not what this cheat expects/);
// The pattern is nowhere, or in several places.
await fails({ ...USE_1, at: { kind: 'signature', pattern: 'DE AD BE EF 99', offset: 0 } }, /byte pattern was not found/);
await fails({ ...USE_1, at: { kind: 'signature', pattern: 'C3 CC CC CC', offset: 0 } }, /more than one place/);
// Not enough room for a jump before other code begins.
Buffer.from('90C3554889E55DC3', 'hex').copy(mock.game.image, 0x6000);
Buffer.from('90C3554889E55DC3', 'hex').copy(pristine, 0x6000);
await fails({ ...USE_1, at: { kind: 'module', offset: '6000' } }, /needs 5 bytes, but only 2 are left here before this piece of the game's code ends \("ret"\)/);
assert.equal(allocations(mock), blocks);
// A cheat that needs shared code fails after the shared code went on: that is switched off again.
await fails({ ...USE_1, code: 'call clampValue\nbogus\noriginal' }, /Line 2 \(bogus\)/);
assert.equal(runner.isOn('clamp'), false);
// The console refuses a write half way: the first jump was in, the second failed.
let writes = 0;
const flaky = new Proxy(dbg, {
get(target, prop) {
if (prop === 'write') {
return (...args) => (++writes === 3 ? Promise.reject(new Error('The console did not accept the write')) : target.write(...args));
}
const v = target[prop];
return typeof v === 'function' ? v.bind(target) : v;
},
});
const flakyCtx = createGameContext({ pid: GAME.pid, dbg: () => flaky, x86, assemble: ctx.assemble });
const flakyRunner = new TableRunner(flakyCtx, { entries: () => [TWO_PLACES] });
await assert.rejects(flakyRunner.enable(TWO_PLACES), /did not accept the write/);
assert.equal(writes, 4, 'new code, first jump, the refused second jump, and the first jump taken out again');
untouched();
assert.equal(flakyRunner.isOn('two'), false);
// A spot that is already hooked is refused rather than stacked on.
await runner.enable(USE_1);
await assert.rejects(runner.enable({ ...USE_1, id: 'again', code: 'original' }), /already a jump at this spot/);
assert.equal(runner.isOn('u1'), true);
await runner.disable(USE_1);
untouched();
mock.state.crashed = null;
done();
});
test('where a jump can go: whole instructions, never past the end of a function into other code', () => {
const dis = (hex) => sync.disassemble(hexToBytes(hex), 0x1000n, 12);
assert.deepEqual(coverInsns(dis('898190030000 C3'), 5).map((i) => i.text), ['mov [rcx+0x390], eax']);
assert.deepEqual(coverInsns(dis('FF4B28 8B4310 C3'), 5).map((i) => i.length), [3, 3], 'whole instructions until five bytes are covered');
// A short line, the end of the function, then filler: the filler may be covered.
assert.deepEqual(coverInsns(dis('FF4710 C3 CC CC CC CC'), 5).map((i) => i.text), ['inc dword ptr [rdi+0x10]', 'ret', 'int3']);
assert.equal(coverInsns(dis('898190030000 C3 CCCCCCCCCCCCCC'), 14).length, 9);
// ...but the start of the next function may not.
assert.throws(() => coverInsns(dis('FF4710 C3 55 4889E5'), 5), /only 4 are left here.*"ret"/);
assert.throws(() => coverInsns(dis('EB02 4889E5 C3'), 5), /only 2 are left/);
assert.throws(() => coverInsns(dis('90 90'), 5), /not enough code/);
assert.throws(() => coverInsns(dis('7401 90 9090 C3'), 5), /jumps into the middle/);
});
test('far from the game: the same cheats work through full-address jumps, or are refused in words', needsKeystone, async () => {
// This console cannot give memory near the game, so nothing is within reach of a 5-byte jump.
const entry = {
id: 'far', type: 'inject', name: 'Far floor', symbol: 'far', at: at('healthDrain'), placement: 'code',
variables: [{ name: 'floor', type: 'i32', value: '70' }, { name: 'sum', type: 'u64', value: '0' }],
code: ` cmp eax, dword ptr [floor]
jge keep
mov eax, dword ptr [floor]
keep:
original
push rdi
push rsi
mov rdi, 40
mov rsi, 2
call addNumbers
mov qword ptr [sum], rax
pop rsi
pop rdi
jmp farReturn`,
};
const { runner, done } = await setup([entry, ADD], true);
mockFar.game.heap.writeUInt32LE(72, HEALTH);
await runner.enable(entry);
const site = siteOf('healthDrain');
const [hook] = listing(mockFar, site, 16, 1);
assert.equal(hook.length, 6, 'jmp [rip+0], then the address');
const cave = hookTarget(mockFar, site);
assert.ok(cave < IMAGE - 0x80000000n || cave > IMAGE + 0x80000000n);
// The 14-byte jump covered the game's line, its ret and filler. "farReturn" runs those again.
const ret = runner.symbolAddress('farReturn');
assert.deepEqual(listing(mockFar, ret, 32, 2).map((i) => i.text), ['ret', 'int3']);
const code = listing(mockFar, cave, 160, 60);
const call = code.find((i) => i.flow === 'call');
const viaAdd = listing(mockFar, call.target, 16, 1)[0];
assert.equal(viaAdd.text.startsWith('jmp'), true, 'the call goes through a jump that holds the whole address');
assert.equal(Buffer.from(mockFar.game.read(call.target + 6n, 8)).readBigUInt64LE(0), IMAGE + BigInt(GAME.funcs.add));
const back = code.find((i) => i.flow === 'jump' && i.address > call.address);
assert.equal(back.target, ret);
// After the moved instructions, the way back to the game is a full-address jump as well.
const tail = listing(mockFar, ret, 32, 9).find((i) => i.flow === 'jump');
assert.equal(Buffer.from(mockFar.game.read(tail.address + 6n, 8)).readBigUInt64LE(0), site + 14n);
frames(mockFar, 5);
assert.equal(health(mockFar), 70);
assert.equal(await runner.readVariable('far', 'sum'), '42');
await runner.disable(entry);
untouched(mockFar);
// An instruction that finds its data relative to itself cannot be moved that far. Nothing is changed.
const blocks = allocations(mockFar);
await assert.rejects(
runner.enable({ id: 'farRip', type: 'inject', name: 'Far player', at: at('loadPlayer'), placement: 'code', code: 'original' }),
/the game's instruction "mov rax, \[0x[0-9A-F]+\]" finds its data or jump target relative to its own position, and the new code is too far away/,
);
untouched(mockFar);
assert.equal(allocations(mockFar), blocks);
done();
});
// ---- "check": looking at the place before anything is switched on ----------------------------
test('check: found once, found several times, not found, changed since the cheat was made, already on', needsKeystone, async () => {
const { ctx, runner, done } = await setup();
const drain = await checkSource(ctx, { at: { kind: 'signature', pattern: '89 81 90 03 00 00', offset: 0 } });
assert.equal(drain.status, 'ok');
assert.equal(drain.message, 'Found in exactly one place: game+0x1000.');
assert.equal(drain.address, siteOf('healthDrain'));
assert.deepEqual(drain.replaced.map((i) => i.text), ['mov [rcx+0x390], eax']);
assert.deepEqual([drain.length, drain.bytes], [6, '89 81 90 03 00 00']);
assert.deepEqual(drain.notes, []);
const fixed = await checkSource(ctx, { at: at('slotRead') });
assert.equal(fixed.message, "The game's code at game+0x3480.");
assert.deepEqual(fixed.replaced.map((i) => i.text), ['mov eax, [rbx+rsi*4+0x20]', 'ret']);
assert.match(fixed.notes[0], /longer than this line, so it also replaces the line after it \(ret\)\. It is moved along and still runs, right after your code/);
const short = await checkSource(ctx, { at: at('staminaTick') });
assert.equal(short.status, 'ok');
assert.equal(short.length, 5);
assert.match(short.notes[0], /covers the end of it and unused filler/);
const rip = await checkSource(ctx, { at: at('loadPlayer') });
assert.match(rip.notes[0], /relative to its own position\. It is re-made for its new place/);
const several = await checkSource(ctx, { at: { kind: 'signature', pattern: 'C3 CC CC CC', offset: 0 } });
assert.equal(several.status, 'several');
assert.match(several.message, /found in at least 6 places.*exactly one place; make it longer/);
assert.equal((await checkSource(ctx, { at: { kind: 'signature', pattern: '48 8D 04 37 C3 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? CC', offset: 0 } })).status, 'ok');
const missing = await checkSource(ctx, { at: { kind: 'signature', pattern: 'DE AD BE EF 99', offset: 0 } });
assert.equal(missing.status, 'missing');
assert.match(missing.message, /not found anywhere in the game's code/);
const changed = await checkSource(ctx, { at: at('healthDrain'), off: '89 81 90 03 00 01' });
assert.equal(changed.status, 'changed');
assert.match(changed.message, /not what it was when the cheat was made/);
assert.equal(changed.bytes, '89 81 90 03 00 00', 'what is there now is still shown');
assert.equal((await checkSource(ctx, { at: at('healthDrain'), off: '89 81 90 03 00 00' })).status, 'ok');
assert.equal((await checkSource(ctx, { at: { kind: 'module', offset: '6000' } })).status, 'unsuitable');
assert.equal((await checkSource(ctx, { at: { kind: 'signature', pattern: 'zz', offset: 0 } })).status, 'invalid');
assert.equal((await checkSource(ctx, { at: { kind: 'module', offset: 'F0000000' } })).status, 'invalid');
assert.equal((await checkSource(ctx, { at: { kind: 'module', offset: '10', module: 'nothere.prx' } })).status, 'invalid');
// While the cheat is on, its own jump is what is there.
const entry = { id: 'chk', type: 'inject', name: 'On', ...(await codeSite(ctx, siteOf('healthDrain'))), code: 'original', placement: 'code' };
assert.deepEqual(entry.at, { kind: 'signature', pattern: '89 81 90 03 00 00 C3 CC', offset: 0 });
assert.deepEqual(entry.fallback, { kind: 'module', offset: '1000' });
assert.equal(entry.off, '89 81 90 03 00 00');
await runner.enable(entry);
const on = await checkSource(ctx, entry);
assert.equal(on.status, 'ok', 'what it replaced is kept with the cheat, so the editor can still show it');
assert.deepEqual(on.replaced.map((i) => i.text), ['mov [rcx+0x390], eax']);
assert.match(on.notes[0], /switched on right now, so its own jump sits here/);
const unknown = await checkSource(ctx, { at: entry.fallback });
assert.equal(unknown.status, 'hooked');
assert.match(unknown.message, /a code cheat that is switched on looks like/);
await runner.disable(entry);
assert.equal((await checkSource(ctx, entry)).status, 'ok');
untouched();
done();
});
test('a code cheat left on before the page was reloaded is found again and can be switched off', needsKeystone, async () => {
const first = await setup();
const entry = await codeCheatEntry(first.ctx, siteOf('scoreAdd'), 'Double score');
entry.code = 'original\noriginal';
await first.runner.enable(entry);
const s0 = score();
frames(mock, 1);
assert.equal(score(), s0 + 20);
first.runner.dispose();
first.dbg.close();
// A new page: nothing remembered, and the pattern cannot match because the jump sits on it.
const dbg = await connect();
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 });
const runner = new TableRunner(ctx, { entries: () => [entry] });
assert.equal(runner.isOn(entry.id), false);
assert.equal(await runner.adopt([entry]), true);
assert.equal(runner.isOn(entry.id), true);
assert.match(runner.result(entry.id), /Found already on in the game/);
assert.deepEqual(runner.codeVariables(entry.id), []);
await runner.disable(entry);
untouched();
// Without the original bytes there would be nothing to put back, so such an entry is left alone.
const noOff = { ...entry, id: 'nooff', off: undefined };
mock.game.image[GAME.code.scoreAdd.offset] = 0xe9;
assert.equal(await runner.adopt([noOff]), false);
pristine.copy(mock.game.image);
runner.dispose();
dbg.close();
});
// ---- the file ------------------------------------------------------------------------------------
test('the .cspector file: code cheats with everything, there and back', () => {
const rule = { line: 'mov [rcx+0x390], eax', mem: 'rcx+0x390', memType: 'i32', test: { operand: { kind: 'reg', reg: 'eax' }, type: 'i32', cmp: 'lt', value: '50' }, then: { do: 'set', reg: 'eax', type: 'i32', value: '100', live: 'refill' }, otherwise: { do: 'original' } };
const table = emptyTable('PPSA99999', '01.000.000', 'Specter Run');
table.entries.push(
{ ...TWO_PLACES, rule: undefined, hotkey: 'F6', group: 'Fun', fallback: { kind: 'module', offset: '1000' }, off: '89 81 90 03 00 00',
variables: [{ name: 'last', type: 'i32', value: '0', choices: [{ label: 'None', value: '0' }, { label: 'Lots', value: '500' }] }] },
CLAMP,
{ id: 'r', type: 'inject', name: 'Refill', at: at('healthDrain'), placement: 'code', rule, ...ruleCode(rule) },
GOLD,
{ id: 'raw', type: 'inject', name: 'Raw', at: { kind: 'address', address: '4B3A8C1000' }, code: 'original', placement: 'code' },
{ ...TWO_PLACES, id: 'raw2', name: 'Raw second place', more: [{ at: { kind: 'address', address: '4B3A8C3200' }, code: 'original', placement: 'code' }] },
);
const saved = saveFileText(table);
const back = parseOwnTable(saved, { own: true });
const plain = (e) => ({ ...JSON.parse(JSON.stringify(e)), id: '' });
assert.deepEqual(back.entries.map(plain), table.entries.map(plain));
assert.deepEqual(back.entries.map((e) => !!e.unreviewed), [false, false, false, false, false, false]);
assert.equal(JSON.parse(saved).formatVersion, 1, 'the format version did not have to change');
// Shared with someone else: raw addresses stay behind, and everything that runs code has to be read first.
const theirs = parseOwnTable(exportOwnTable(table));
assert.deepEqual(theirs.entries.map((e) => [e.name, e.type, !!e.unreviewed]), [
['Mirror health into the score', 'inject', true], ['Clamp', 'shared', true], ['Refill', 'inject', true], ['Gold', 'value', false],
]);
assert.match(codeListing(theirs.entries[0]), /variable last \(i32\) = 0\n\n; ---- place 1 ----\n {4}mov dword ptr \[last\], eax\n {4}original\njmp return\n\n; ---- place 2 ----/);
assert.match(codeListing(theirs.entries[1]), /variable lowest \(i32\) = 40\n\nclampValue:\n {4}cmp eax/);
// Things a file may get wrong are dropped rather than trusted.
const messy = parseOwnTable(JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: { titleId: 'PPSA99999' }, cheats: [
{ name: 'M', type: 'inject', at: at('healthDrain'), symbol: 'rax', off: 'not hex', fallback: 5, rule: { nonsense: true }, placement: 'sideways',
variables: [{ name: 'ok', type: 'i32', value: '1' }, { name: 'bad name', type: 'i32', value: '1' }, { name: 'worse', type: 'i32', value: 'x' }, { name: 'odd', type: 'quux', value: '1' }, 7],
more: [{ code: 'no location' }, { at: at('scoreAdd'), code: 5, symbol: 'fine' }] },
{ name: 'S', type: 'shared' },
{ name: 'V', type: 'value', at: at('idle'), valueType: 'u32', value: '1', symbol: 'a b' },
] }));
const [m, sh, v] = messy.entries;
assert.deepEqual([m.symbol, m.off, m.fallback, m.rule, m.placement, m.code], [undefined, undefined, undefined, undefined, 'code', '']);
assert.deepEqual(m.variables, [{ name: 'ok', type: 'i32', value: '1' }]);
assert.deepEqual(m.more, [{ at: at('scoreAdd'), code: '', symbol: 'fine', placement: 'code' }]);
assert.deepEqual([sh.type, sh.code, sh.unreviewed], ['shared', '', true]);
assert.equal(v.symbol, undefined);
// The console cannot keep a code cheat on by itself, and says why.
for (const e of [TWO_PLACES, CLAMP]) {
assert.equal(canKeep(e), false);
assert.match(keepProblem(e), /put together by this page each time.*which the PS5 cannot do by itself/);
}
assert.equal(parseOwnTable(saveFileText({ ...table, entries: [{ ...CLAMP, keep: true }] }), { own: true }).entries[0].keep, undefined);
});
// ---- the rule builder: if/else without writing assembly ---------------------------------------
const insnAt = (name) => sync.disassemble(GAME.code[name].bytes, siteOf(name), 1)[0];
test('rules: what a rule can be about is read off the game\'s line, in words', () => {
const drain = ruleTarget(insnAt('healthDrain'));
assert.deepEqual([drain.line, drain.mem, drain.memType, drain.writes, drain.object], ['mov [rcx+0x390], eax', 'rcx+0x390', 'i32', true, 'rcx']);
assert.deepEqual(drain.operands.map((o) => [o.label, o.operand, o.type]), [
['The value being written (eax)', { kind: 'reg', reg: 'eax' }, 'i32'],
['The value there now, before this line changes it', { kind: 'mem', expr: 'rcx+0x390' }, 'i32'],
]);
assert.deepEqual(drain.settable.map((o) => o.label), ['the value being written (eax)']);
const slot = ruleTarget(insnAt('slotRead'));
assert.equal(slot.mem, 'rbx+rsi*4+0x20');
assert.deepEqual(slot.operands.map((o) => o.label), ['What eax holds before this line runs', 'The value this line reads']);
assert.deepEqual(slot.settable, [], 'the line overwrites eax, so setting it first would do nothing');
const goldLine = ruleTarget(insnAt('goldSpend'));
assert.equal(goldLine.mem, undefined);
assert.deepEqual(goldLine.operands.map((o) => [o.operand.reg, o.type]), [['xmm0', 'float'], ['xmm1', 'float']]);
assert.deepEqual(goldLine.settable.map((o) => o.operand.reg), ['xmm0', 'xmm1']);
assert.deepEqual([ruleTarget(insnAt('staminaTick')).mem, ruleTarget(insnAt('staminaTick')).object], ['rdi+0x10', 'rdi']);
assert.equal(ruleTarget(insnAt('loadPlayer')).mem, undefined, 'a global found relative to the line itself is not something a rule moves');
const stack = ruleTarget(sync.disassemble(hexToBytes('89442410'), 0x1000n, 1)[0]); // mov [rsp+0x10], eax
assert.equal(stack.mem, undefined, 'the rule\'s own code moves the stack, so stack values are left to hand-written code');
const double = ruleTarget(sync.disassemble(hexToBytes('F20F114308'), 0x1000n, 1)[0]); // movsd [rbx+8], xmm0
assert.deepEqual([double.memType, double.operands[0].type], ['double', 'double']);
});
test('rules: the code they write, and that it does what the words say', needsKeystone, async () => {
const { runner, done } = await setup();
const make = (id, site, rule, suffix) => ({ id, type: 'inject', name: id, at: at(site), placement: 'code', rule, ...ruleCode(rule, suffix) });
const regs = () => [mock.game.threads[0].get('rflags'), mock.game.threads[0].get('rsp')];
const calm = regs();
// "If the value being written is below 50, change it to 100 first" with a number that can be changed while on.
const refill = make('refill', 'healthDrain', {
line: 'mov [rcx+0x390], eax', mem: 'rcx+0x390', memType: 'i32',
test: { operand: { kind: 'reg', reg: 'eax' }, type: 'i32', cmp: 'lt', value: '50' },
then: { do: 'set', reg: 'eax', type: 'i32', value: '100', live: 'refill' },
otherwise: { do: 'original' },
});
assert.equal(describeRule(refill.rule), 'When the game runs "mov [rcx+0x390], eax": if eax is below 50, set eax to refill first; otherwise let the game carry on as normal.');
assert.deepEqual(refill.variables, [{ name: 'refill', type: 'i32', value: '100' }]);
assert.equal(refill.code, [
'; When the game runs: mov [rcx+0x390], eax',
" lea rsp, [rsp-128] ; step clear of the game's scratch space",
" pushfq ; remember the game's flags",
' cmp eax, 50 ; eax is below 50?',
' jge otherwise ; if not, go to the other case',
" popfq ; the game's flags, as they were",
' lea rsp, [rsp+128]',
' mov eax, dword ptr [refill] ; eax = refill',
" original ; then the game's own line",
' jmp return ; back to the game',
'otherwise:',
' popfq',
' lea rsp, [rsp+128]',
" original ; the game's own line, as it was",
'',
].join('\n'));
mock.game.heap.writeUInt32LE(53, HEALTH);
await runner.enable(refill);
const seen = [];
for (let i = 0; i < 8; i++) {
frames(mock, 1);
seen.push(health());
}
assert.deepEqual(seen, [52, 51, 50, 100, 99, 98, 97, 96]);
assert.deepEqual(regs(), calm, 'the flags and the stack are as the game left them');
mock.game.heap.writeUInt32LE(50, HEALTH);
await runner.writeVariable('refill', 'refill', '77');
frames(mock, 1);
assert.equal(health(), 77);
await runner.disable(refill);
untouched();
// The screenshot's decision, made with a rule: another value of the same object tells the player from the rest.
const enemies = make('enemies', 'staminaTick', {
line: 'inc dword ptr [rdi+0x10]', mem: 'rdi+0x10', memType: 'i32',
test: { operand: { kind: 'mem', expr: 'rdi+0x18' }, type: 'i32', cmp: 'eq', value: '1' },
then: { do: 'original' },
otherwise: { do: 'store', value: '0' },
});
assert.match(enemies.code, /cmp dword ptr \[rdi\+0x18\], 1 +; \[rdi\+0x18\] is 1\?\n {4}jne otherwise/);
assert.match(enemies.code, /otherwise:\n {4}popfq\n {4}lea rsp, \[rsp\+128\]\n {4}mov dword ptr \[rdi\+0x10\], 0 +; write 0 there\n$/);
await runner.enable(enemies);
const s0 = stamina(mock, 0);
frames(mock, 3);
assert.deepEqual([stamina(mock, 0), stamina(mock, 1), stamina(mock, 2)], [s0 + 3, 0, 0]);
await runner.disable(enemies);
// A decimal value in memory, compared with a number kept in a variable: hp below 100 gains no stamina.
const weak = make('weak', 'staminaTick', {
line: 'inc dword ptr [rdi+0x10]', mem: 'rdi+0x10', memType: 'i32',
test: { operand: { kind: 'mem', expr: 'rdi+0x14' }, type: 'float', cmp: 'lt', value: '100' },
then: { do: 'skip' },
otherwise: { do: 'original' },
});
assert.deepEqual(weak.variables, [{ name: 'compareTo', type: 'float', value: '100' }]);
assert.match(weak.code, /movdqu xmmword ptr \[rsp\], xmm0 +; borrow xmm0\n {4}movss xmm0, dword ptr \[rdi\+0x14\]\n {4}comiss xmm0, dword ptr \[compareTo\]/);
const xmm0 = Buffer.from(mock.game.threads[0].fp.subarray(160, 176));
await runner.enable(weak);
const before = [0, 1, 2].map((i) => stamina(mock, i));
frames(mock, 2);
assert.deepEqual([0, 1, 2].map((i) => stamina(mock, i) - before[i]), [2, 0, 0], 'only the player (hp 120) gains stamina');
await runner.writeVariable('weak', 'compareTo', '50');
frames(mock, 2);
assert.deepEqual([0, 1, 2].map((i) => stamina(mock, i) - before[i]), [4, 2, 0], 'now only the Dog (hp 35) is below the line');
assert.deepEqual(regs(), calm);
await runner.disable(weak);
// A register that holds a decimal number: gold is never spent while what is taken is above 1.
const free = make('free', 'goldSpend', {
line: 'subss xmm0, xmm1',
test: { operand: { kind: 'reg', reg: 'xmm1' }, type: 'float', cmp: 'gt', value: '1' },
then: { do: 'set', reg: 'xmm1', type: 'float', value: '0' },
});
mock.game.heap.writeFloatLE(2500, GAME.vars.gold);
await runner.enable(free);
frames(mock, 3);
assert.equal(gold(), 2500);
await runner.disable(free);
frames(mock, 1);
assert.equal(gold(), 2497.5);
// No condition at all, a number that stays editable, written where the line writes.
const pinned = make('pinned', 'healthDrain', { line: 'mov [rcx+0x390], eax', mem: 'rcx+0x390', memType: 'i32', then: { do: 'store', value: '321', live: 'myHealth' } });
assert.match(pinned.code, /push rax +; borrow rax\n {4}mov eax, dword ptr \[myHealth\]\n {4}mov dword ptr \[rcx\+0x390\], eax +; write myHealth there\n {4}pop rax/);
await runner.enable(pinned);
frames(mock, 1);
assert.equal(health(), 321);
assert.deepEqual(regs(), calm);
await runner.disable(pinned);
// A value in memory against a number too big for the instruction, 8 bytes wide, with two rules in one cheat.
const wide = ruleCode({ line: 'x', mem: 'rax+8', memType: 'i64', test: { operand: { kind: 'mem', expr: 'rax+8' }, type: 'u64', cmp: 'ge', value: '0x1122334455' }, then: { do: 'store', value: '-2' }, otherwise: { do: 'skip' } }, '2');
assert.deepEqual(wide.variables, [{ name: 'compareTo2', type: 'u64', value: '0x1122334455' }]);
assert.match(wide.code, /push rcx +; borrow rcx\n {4}mov rcx, qword ptr \[rax\+8\]\n {4}cmp rcx, qword ptr \[compareTo2\].*\n {4}pop rcx\n {4}jb otherwise2/);
assert.match(wide.code, /mov dword ptr \[rax\+8\], -2 .*\n {4}mov dword ptr \[rax\+8\+4\], -1\n/);
// What a rule cannot do is said in words.
assert.throws(() => ruleCode({ line: 'x', then: { do: 'store', value: '1' } }), /does not write to memory in a way a rule can replace/);
assert.throws(() => ruleCode({ line: 'x', test: { operand: { kind: 'mem', expr: 'rsp+0x10' }, type: 'i32', cmp: 'eq', value: '1' }, then: { do: 'skip' } }), /value on the stack/);
assert.throws(() => ruleCode({ line: 'x', test: { operand: { kind: 'reg', reg: 'xmm0' }, type: 'i32', cmp: 'eq', value: '1' }, then: { do: 'skip' } }), /holds a decimal number/);
assert.throws(() => ruleCode({ line: 'x', test: { operand: { kind: 'reg', reg: 'eax' }, type: 'i32', cmp: 'eq', value: 'many' }, then: { do: 'skip' } }), /not a whole number/);
assert.throws(() => ruleCode({ line: 'x', then: { do: 'set', reg: 'eax', type: 'i32', value: '1', live: 'rax' } }), /processor register/);
untouched();
done();
});
// ---- scripts: commands, keys, variables, assembling ---------------------------------------------
/** The script worker's own source, run in a sandbox that stands in for a Web Worker. */
function fakeWorker() {
const worker = { onmessage: null, onerror: null, terminated: false, terminate: () => (worker.terminated = true) };
const sandbox = { setTimeout, postMessage: (m) => queueMicrotask(() => !worker.terminated && worker.onmessage?.({ data: structuredClone(m) })) };
vm.createContext(sandbox);
vm.runInContext(WORKER_SOURCE, sandbox);
worker.postMessage = (m) => queueMicrotask(() => !worker.terminated && sandbox.onmessage({ data: structuredClone(m) }));
return worker;
}
const settle = () => new Promise((r) => setTimeout(r, 30));
test('scripts: named commands with inputs, keys, code-cheat variables and assembling', needsKeystone, async () => {
const { runner, ctx, done } = await setup([GOD_FLOOR]);
await runner.enable(GOD_FLOOR);
const api = { setEntry: async () => undefined, variables: runner };
const call = (method, ...args) => handleScriptCall(ctx, api, method, args);
assert.equal(await call('variableGet', 'floor'), 60);
await call('variableSet', 'floor', '85');
assert.equal(await runner.readVariable('floor', 'floor'), '85');
await assert.rejects(call('variableGet', 'nope'), /no variable called "nope" in a code cheat that is switched on/);
await assert.rejects(handleScriptCall(ctx, { setEntry: api.setEntry }, 'variableGet', ['floor']), /cannot be reached from here/);
assert.equal(await call('assemble', 'mov eax, 1 ; one\nret'), 'B8 01 00 00 00 C3');
assert.equal(await call('assemble', 'top:\n dec ecx\n jnz top\n jmp 0x4B3A8C1000', '4B3A8C2000'), 'FF C9 75 FC E9 F7 EF FF FF');
await assert.rejects(call('assemble', 'nop\nbogus'), /Line 2 \(bogus\)/);
assert.deepEqual(readParams({ amount: 'number', who: ['Me', 'You'], loud: 'yesno', note: { type: 'text', value: 'hi', label: 'A note' } }), [
{ name: 'amount', label: 'amount', type: 'number', choices: undefined, initial: '0' },
{ name: 'who', label: 'who', type: 'choice', choices: ['Me', 'You'], initial: 'Me' },
{ name: 'loud', label: 'loud', type: 'yesno', choices: undefined, initial: 'false' },
{ name: 'note', label: 'A note', type: 'text', choices: undefined, initial: 'hi' },
]);
assert.throws(() => readParams({ x: 'color' }), /unknown type/);
assert.throws(() => commandValues({ name: 'c', params: readParams({ amount: 'number' }) }, { amount: 'lots' }), /"amount" needs a number/);
// A whole script, through the worker's real source.
const logs = [];
let ended = null;
const script = `
const floor = cs.variable('floor');
cs.command('Set the floor', { amount: { type: 'number', value: 90, label: 'Lowest health' }, twice: 'yesno' }, async ({ amount, twice }) => {
await floor.set(twice ? amount * 2 : amount);
cs.log('floor is now', await floor.get());
});
cs.command('Fail', async () => { throw new Error('on purpose'); });
cs.onHotkey('F7', async () => { await floor.set(1); cs.log('F7'); });
cs.command('Broken', { x: 'color' }, () => {});
cs.log('started with', await floor.get());
`;
let changes = 0;
const handle = hostScript(fakeWorker(), ctx, api, script, (l) => logs.push(l), (e) => (ended = e ?? 'finished'));
handle.subscribe(() => changes++);
await settle();
assert.equal(ended, null, 'a script that offers commands stays on after its last line');
assert.deepEqual(handle.commands().map((c) => [c.name, c.params.map((p) => `${p.label}:${p.type}=${p.initial}`)]), [
['Set the floor', ['Lowest health:number=90', 'twice:yesno=false']], ['Fail', []],
]);
assert.deepEqual(handle.hotkeys(), ['F7']);
assert.ok(changes >= 3);
assert.deepEqual(logs, ['Command "Broken" was not added: The input "x" has an unknown type. Use "number", "text", "yesno" or a list of choices.', 'started with 85', 'Ready. Its commands are above; listening for F7.']);
await handle.run('Set the floor', { amount: '45', twice: 'true' });
assert.equal(await runner.readVariable('floor', 'floor'), '90');
await handle.run('Set the floor', {});
assert.equal(await runner.readVariable('floor', 'floor'), '90', 'inputs left alone use their starting values');
await assert.rejects(handle.run('Set the floor', { amount: '' }), /needs a number/);
await assert.rejects(handle.run('Fail', {}), /on purpose/);
await assert.rejects(handle.run('Nope', {}), /no command called "Nope"/);
assert.equal(handle.pressed('F8'), false);
assert.equal(handle.pressed('F7'), true);
await settle();
assert.equal(await runner.readVariable('floor', 'floor'), '1');
assert.equal(logs.at(-1), 'F7');
handle.stop();
assert.deepEqual([handle.commands(), handle.hotkeys()], [[], []]);
await assert.rejects(handle.run('Set the floor', {}), /no command/);
// A script with nothing to wait for still ends by itself, as before.
let over = null;
hostScript(fakeWorker(), ctx, api, 'cs.log("bye")', () => undefined, (e) => (over = e ?? 'finished'));
await settle();
assert.equal(over, 'finished');
hostScript(fakeWorker(), ctx, api, 'cs.command("x")', () => undefined, (e) => (over = e));
await settle();
assert.match(over, /needs a function to run/);
await runner.disable(GOD_FLOOR);
untouched();
done();
});
// ---- the demo console without Keystone ----------------------------------------------------------
const EVERYDAY = `lea rsp, [rsp-128] | pushfq | popfq | cmp eax, 50 | cmp eax, dword ptr [rip + 0x40] | cmp dword ptr [rdi+0x18], 1
cmp dword ptr [rdi+0x18], 100000 | cmp byte ptr [rdi+0x18], -1 | cmp rcx, qword ptr [rip + 0x40] | mov eax, dword ptr [rip + 0x40]
mov r14d, 0 | mov dword ptr [rbx+0x730], r14d | mov dword ptr [rcx+0x390], -2 | mov dword ptr [rax+8+4], -1 | mov word ptr [rax], 7
mov byte ptr [rax], 7 | mov rax, 0x1122334455667788 | mov rax, 5 | mov rax, -1 | mov eax, 5 | mov al, 7 | mov r8b, 1 | mov qword ptr [rip + 0x40], rax
mov eax, [rbx+rsi*4+0x20] | mov rax, rcx | movabs rcx, 0x4B3A8D4000 | push rax | pop rax | push r11 | call 0x4B3A8C4000 | call rax
jmp 0x4B3A8C1006 | jne 0x4B3A8C1006 | jbe 0x4B3A8C1006 | jmp qword ptr [rip + 0x40] | movss xmm0, dword ptr [rdi+0x14] | movss xmm1, dword ptr [rip + 0x40]
movsd xmm2, qword ptr [rip + 0x40] | comiss xmm0, dword ptr [rip + 0x40] | comisd xmm0, qword ptr [rip + 0x40] | movss dword ptr [rbx+8], xmm3
movdqu xmmword ptr [rsp], xmm0 | movdqu xmm0, xmmword ptr [rsp] | inc dword ptr [rip + 0x40] | inc qword ptr [rax] | dec edx | neg rax
add dword ptr [rdi+0x38880], edx | add eax, 5 | sub rsp, 16 | xor eax, eax | test eax, eax | and eax, 0xff | or byte ptr [rax+1], 2
mov edx, dword ptr [0x4B3A8D5000] | mov dword ptr [r10+r11*8-0x10], r9d | lea rax, [rip + 0x40] | mulss xmm0, xmm1 | nop | ret | int3`;
test('the built-in assembler of the pretend console encodes what Keystone encodes', needsKeystone, () => {
const base = 0x4b3a8d4000n;
const text = (bytes) => sync.disassemble(bytes, base, 2).map((i) => i.text).join(' / ');
for (const line of EVERYDAY.split(/\||\n/).map((l) => l.trim())) {
const mine = jsAssemble(line, base);
const theirs = mockAssemble(line, base);
assert.equal(mine.error, undefined, line);
assert.equal(theirs.error, undefined, line);
// The same instruction, though not always the same bytes: jumps here are always the long form.
assert.equal(text(mine.bytes), text(theirs.bytes), line);
}
assert.match(jsAssemble('frobnicate rax', base).error, /invalid mnemonic \(frobnicate\)/);
assert.match(jsAssemble('mov eax, ebx, ecx', base).error, /invalid operand/);
assert.match(jsAssemble('jmp nowhere', base).error, /invalid operand \(nowhere\)/);
assert.match(mockAssemble('cpuid', base, { keystone: false }).error, /only knows the everyday instructions/);
});
test('without Keystone installed, rules and hand-written cheats still work on the pretend console', async () => {
pristine.copy(mockJs.game.image);
const dbg = new Ps5Debug(await openWebSocketStream(url(portJs, TCP_JS)), { timeoutMs: 8000 });
const assemble = async (base, text) => {
const tmp = new Ps5Debug(await openWebSocketStream(url(portJs, TCP_JS)), { timeoutMs: 8000 });
try {
return await tmp.assemble(base, text);
} finally {
if (!tmp.closed) tmp.close();
}
};
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86, assemble });
const rule = {
line: 'mov [rcx+0x390], eax', mem: 'rcx+0x390', memType: 'i32',
test: { operand: { kind: 'reg', reg: 'eax' }, type: 'i32', cmp: 'lt', value: '50', live: 'lowest' },
then: { do: 'store', value: '100', live: 'refill' },
otherwise: { do: 'original' },
};
const refill = { id: 'refill', type: 'inject', name: 'Refill', at: at('healthDrain'), placement: 'code', rule, ...ruleCode(rule) };
const enemies = { id: 'ohk', type: 'inject', name: 'Enemies have no stamina', symbol: 'stamina', at: at('staminaTick'), code: DRAIN_ENEMIES, placement: 'code' };
const weak = {
id: 'weak', type: 'inject', name: 'Weak', at: at('scoreAdd'), placement: 'code',
...ruleCode({ line: 'add dword ptr [rdi+0x38880], 0xA', mem: 'rdi+0x38880', memType: 'i32', test: { operand: { kind: 'mem', expr: 'rdi+0x20010' }, type: 'float', cmp: 'gt', value: '1000' }, then: { do: 'skip' } }),
};
const runner = new TableRunner(ctx, { entries: () => [refill, enemies, weak, CLAMP, USE_1] });
mockJs.game.heap.writeUInt32LE(52, HEALTH);
await runner.enable(refill);
await runner.enable(enemies);
await runner.enable(weak);
const s0 = stamina(mockJs, 0);
const score0 = mockJs.game.heap.readUInt32LE(GAME.vars.score);
const seen = [];
for (let i = 0; i < 5; i++) {
frames(mockJs, 1);
seen.push(health(mockJs));
}
assert.deepEqual(seen, [51, 50, 100, 99, 98]);
assert.deepEqual([stamina(mockJs, 0), stamina(mockJs, 1), stamina(mockJs, 2)], [s0 + 5, 0, 0]);
assert.equal(mockJs.game.heap.readUInt32LE(GAME.vars.score), score0, 'gold is above 1000, so the score line is skipped');
for (const e of [weak, enemies, refill]) await runner.disable(e);
// Shared code and a call into it.
mockJs.game.heap.writeUInt32LE(30, HEALTH);
await runner.enable(USE_1);
frames(mockJs, 1);
assert.equal(health(mockJs), 40);
await runner.disable(USE_1);
assert.deepEqual(Buffer.from(mockJs.game.image), pristine);
await assert.rejects(runner.enable({ ...enemies, code: 'cpuid\noriginal' }), /Line 1 \(cpuid\): the assembler does not know this instruction \[Assembler: invalid mnemonic \(cpuid\): this pretend console only knows the everyday instructions/);
runner.dispose();
dbg.close();
});
test('scripts: searching memory for a value and reading text', async () => {
const { ctx, m, done } = await setup([]);
try {
const api = { setEntry: async () => undefined };
const call = (method, ...args) => handleScriptCall(ctx, api, method, args);
const heap = BigInt(GAME.heapBase ?? 0);
const region = (await ctx.maps(true)).find((r) => r.start === heap) ?? null;
assert.ok(region, 'the mock game has a heap block');
// A value written twice, far enough apart to tell the limit and the range apart.
const marker = 0x5a17c0de0badf00dn;
m.game.heap.writeBigUInt64LE(marker, 0x3E000);
m.game.heap.writeBigUInt64LE(marker, 0x3F000);
m.game.heap.write('item_craftingmaterial_fiber_007_c\0junk', 0x3F800, 'utf8');
const all = await call('search', 'u64', marker.toString());
assert.deepEqual(all, [heap + 0x3E000n, heap + 0x3F000n]);
assert.deepEqual(await call('search', 'u64', marker.toString(), { limit: 1 }), [heap + 0x3E000n]);
assert.deepEqual(await call('search', 'u64', marker.toString(), { from: heap + 0x3E800n }), [heap + 0x3F000n]);
assert.deepEqual(await call('search', 'u64', marker.toString(), { from: heap + 0x3E800n, to: heap + 0x3F000n }), []);
await assert.rejects(call('search', 'u64', '1', { from: heap + 0x3E010n, to: heap + 0x3E000n }), /"to" must be above "from"/);
await assert.rejects(call('search', 'u64', '1', { limit: 0 }), /"limit" must be between 1/);
assert.equal(await call('readText', heap + 0x3F800n), 'item_craftingmaterial_fiber_007_c');
assert.equal(await call('readText', heap + 0x3F800n, 4), 'item');
await assert.rejects(call('readText', heap + 0x3F800n, 0), /between 1 and 65536/);
} finally {
done();
}
});