Files
Tiago Valente 814e5bdfc5 CheatSpecter 0.4.0
- Find: text search, grouped search, undo, went up/down by, where to search, before/after compare
- Debug tab: breakpoints, stepping, registers, stack, threads, what code touches, structure compare, pointer re-check
- Engine tab: Unreal, Unity, Godot, GameMaker and C++ class names; hidden settings and switches
- Code cheats with labels, variables, references and shared code; rule builder; script commands
- Cheats kept on by the PS5 without the browser
- Learn section with twelve lessons and an in-page practice game
- Section (master code) community cheats; SHN and MC4 export
- Groups, dropdowns, hotkeys, first-cheat guide, re-find after update, restore everything
- MCP server: 25 tools
- Quick start, wiki, contributor and security guides, changelog
2026-10-10 12:06:50 -07:00

654 lines
33 KiB
JavaScript

// The grown-up "Your cheats" table: the optional fields of the file format,
// "keep on" rules, update-proofing and re-finding patches, the write journal,
// hotkeys and the console engine's status, against the mock console.
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 { startBridge } from '../bridge/server.mjs';
import { openWebSocketStream } from '../src/lib/transport.ts';
import { Ps5Debug } from '../src/lib/ps5debug.ts';
import { createX86, asAsync, parsePattern } from '../src/lib/x86.ts';
import { createGameContext, findPattern } from '../src/lib/locations.ts';
import {
TableRunner, canKeep, emptyTable, exportCommunityTable, exportOwnTable, groupNames, keepProblem, keptEntries, parseOwnTable, resolveSite, saveFileText,
} from '../src/lib/table.ts';
import { patchLocating, proofEntry, proofSite, refindEntry, refindTable } from '../src/lib/proof.ts';
import { nopEntry } from '../src/lib/make.ts';
import { WriteJournal } from '../src/lib/journal.ts';
import { hotkeyClashes, hotkeyFromEvent, hotkeyProblem } from '../src/lib/hotkeys.ts';
import { keptStatus, parseEngineStatus } from '../src/lib/keep.ts';
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 = 17480 + PORT_OFFSET;
let mock, bridge, port;
const connect = async () => new Ps5Debug(await openWebSocketStream(`ws://127.0.0.1:${port}/ws?host=127.0.0.1&port=${TCP}`), { timeoutMs: 4000 });
const context = async () => {
const dbg = await connect();
return { dbg, ctx: createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 }) };
};
// A stretch of ordinary compiled code to patch, unlike the mock's own
// instructions, which sit alone in padding. The cheat removes "dec [rbx+0x28]".
// mov [rsp+8],rbx / push rdi / sub rsp,0x20 / mov rbx,rcx / mov eax,[rip+0x3412]
// test eax,eax / je +10 / dec dword [rbx+0x28] / call +0x1000
// mov rbx,[rsp+0x30] / add rsp,0x20 / pop rdi / ret
const CODE = '48895C2408 57 4883EC20 488BD9 8B0512340000 85C0 740A FF4B28 E800100000 488B5C2430 4883C420 5F C3';
const CODE_BYTES = hexToBytes(CODE);
const SITE_IN_CODE = 23;
const HOME = 0x6000;
/** Puts the code at an offset of the image, optionally altered, as a game update would. */
function plant(offset, change) {
const bytes = Buffer.from(CODE_BYTES);
change?.(bytes);
bytes.copy(mock.game.image, offset);
}
const wipe = (offset, length = CODE_BYTES.length) => mock.game.image.fill(0xcc, offset, offset + length);
const image = (offset, length) => bytesToHex(mock.game.image.subarray(offset, offset + length));
const moduleAt = (offset) => ({ kind: 'module', offset: offset.toString(16).toUpperCase() });
const patchAt = (offset, extra = {}) => ({ id: 'p' + offset, type: 'patch', name: 'Infinite ammo', at: moduleAt(offset), on: '909090', off: 'FF4B28', ...extra });
before(async () => {
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
await mock.ready;
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 't' } });
port = await bridge.ready;
});
after(async () => {
await bridge.close();
await mock.close();
});
// ---- the file format -------------------------------------------------------------
const FULL = {
...emptyTable('PPSA99999', '01.000.000', 'Specter Run'),
entries: [
{
id: 'a', type: 'patch', name: 'Infinite ammo', group: 'Weapons', hotkey: 'F6', keep: true,
at: { kind: 'signature', pattern: '85 C0 74 0A ?? ?? ?? E8', offset: 4 }, fallback: { kind: 'module', offset: '6017' },
around: { bytes: '85C0740AFF4B28E800100000', mask: 'xxxxxx.x????', at: 4 }, on: '909090', off: 'FF4B28',
more: [{ at: { kind: 'signature', pattern: 'AA ?? BB', offset: 1 }, fallback: { kind: 'module', offset: '7000' }, around: { bytes: 'AA01BB', mask: 'x.x', at: 1 }, on: '90', off: '01' }],
},
{
id: 'b', type: 'value', name: 'Difficulty', group: 'Weapons', hotkey: 'Ctrl+Shift+1', keep: true,
at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, valueType: 'u32', value: '1', freeze: true,
choices: [{ label: 'Easy', value: '0' }, { label: 'Normal', value: '1' }, { label: 'Hard', value: '2' }],
},
{ id: 'c', type: 'script', name: 'Loop', code: 'await cs.sleep(1)', group: 'Other', hotkey: 'F7' },
],
};
test('format: every new field survives a save and load, and is simply absent in old files', () => {
const text = saveFileText(FULL);
const doc = JSON.parse(text);
assert.equal(doc.formatVersion, 1, 'the format version is not bumped');
const back = parseOwnTable(text, { own: true });
const strip = (e) => ({ ...e, id: undefined, unreviewed: undefined });
assert.deepEqual(back.entries.map(strip), FULL.entries.map(strip));
assert.deepEqual(groupNames(back), ['Weapons', 'Other']);
// An old file has none of the fields, and loading it adds none.
const old = '{"format":"cheatspecter","formatVersion":1,"game":{"name":"G","titleId":"PPSA99999","version":"01.000.000"},"cheats":[' +
'{"name":"Ammo","type":"patch","at":{"kind":"module","offset":"0x2040"},"on":"909090","off":"FF4B28"},' +
'{"name":"HP","type":"value","at":{"kind":"module","offset":"10"},"valueType":"u32","value":"5","freeze":true}]}';
const loaded = parseOwnTable(old, { own: true });
for (const e of loaded.entries) {
for (const key of ['group', 'hotkey', 'keep', 'choices', 'fallback', 'around']) assert.equal(key in e, false, `${key} was added to an old entry`);
}
assert.deepEqual(JSON.parse(saveFileText(loaded)), JSON.parse(old), 'an old file is written back with nothing added');
});
test('format: a reader that drops the fields it does not know still gets a working table', () => {
// What the previous version of the app understood about an entry.
const KNOWN = new Set(['name', 'note', 'type', 'at', 'on', 'off', 'more', 'valueType', 'value', 'freeze', 'code', 'placement', 'fn', 'args', 'reviewed']);
const doc = JSON.parse(saveFileText(FULL));
const prune = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => KNOWN.has(k)));
doc.cheats = doc.cheats.map((c) => ({ ...prune(c), ...(c.more ? { more: c.more.map(prune) } : {}) }));
const back = parseOwnTable(JSON.stringify(doc), { own: true });
assert.equal(back.entries.length, 3);
assert.deepEqual(back.entries[0].at, FULL.entries[0].at);
assert.equal(back.entries[0].more[0].on, '90');
assert.equal(back.entries[1].value, '1');
assert.equal(back.entries[1].freeze, true);
assert.equal(back.entries.some((e) => e.keep || e.group || e.hotkey), false);
// And the previous version kept unknown fields when it re-saved (it copied each entry whole), which this checks stays true here.
const future = JSON.parse(saveFileText(FULL));
future.cheats[0].somethingNewer = { a: 1 };
assert.deepEqual(JSON.parse(saveFileText(parseOwnTable(JSON.stringify(future), { own: true }))).cheats[0].somethingNewer, { a: 1 });
});
test('format: malformed optional fields are dropped rather than trusted', () => {
const doc = JSON.parse(saveFileText(FULL));
doc.cheats[0].group = 7;
doc.cheats[0].hotkey = '';
doc.cheats[0].fallback = 'game+1';
doc.cheats[0].around = { bytes: 'AABB', mask: 'x', at: 0 };
doc.cheats[1].choices = [{ label: 'Easy', value: '0' }, { label: 5 }, null, 'x'];
const back = parseOwnTable(JSON.stringify(doc), { own: true });
assert.equal('group' in back.entries[0], false);
assert.equal('hotkey' in back.entries[0], false);
assert.equal('fallback' in back.entries[0], false);
assert.equal('around' in back.entries[0], false);
assert.deepEqual(back.entries[1].choices, [{ label: 'Easy', value: '0' }]);
});
test('keep: only patches and held values with a lasting location, never unreviewed, never from someone else\'s file', () => {
const patch = { id: '1', type: 'patch', name: 'p', at: { kind: 'module', offset: '10' }, on: '90' };
const held = { id: '2', type: 'value', name: 'v', at: { kind: 'pointer', base: '10', offsets: ['0'] }, valueType: 'u32', value: '1', freeze: true };
assert.equal(canKeep(patch), true);
assert.equal(canKeep({ ...patch, at: { kind: 'signature', pattern: '90 91', offset: 0 } }), true);
assert.equal(canKeep(held), true);
assert.equal(canKeep({ ...held, at: { kind: 'module', offset: '10' } }), true);
assert.match(keepProblem({ ...patch, at: { kind: 'address', address: '880000000' } }), /raw memory address/);
assert.match(keepProblem({ ...patch, more: [{ at: { kind: 'address', address: '1' }, on: '90' }] }), /raw memory address/);
assert.match(keepProblem({ ...held, at: { kind: 'address', address: '880000000' } }), /raw memory address/);
assert.match(keepProblem({ ...held, freeze: false }), /held in place/);
assert.match(keepProblem({ ...patch, unreviewed: true }), /Review/);
for (const e of [
{ id: '3', type: 'script', name: 's', code: 'x' },
{ id: '4', type: 'inject', name: 'i', at: { kind: 'module', offset: '10' }, code: 'nop', placement: 'before' },
{ id: '5', type: 'call', name: 'c', fn: { kind: 'module', offset: '10' }, args: [] },
]) assert.match(keepProblem(e), /Only patches and held values/);
const table = { ...emptyTable('PPSA99999', '1'), entries: [
{ ...patch, keep: true },
{ ...held, id: '6', freeze: false, keep: true },
{ ...patch, id: '7', at: { kind: 'address', address: '1' }, keep: true },
{ id: '8', type: 'script', name: 's', code: 'x', keep: true },
{ ...held, keep: true },
] };
assert.deepEqual(keptEntries(table).map((e) => e.id), ['1', '2']);
// The user's own saved file keeps the flag where it is allowed, and loses it where it is not.
const own = parseOwnTable(saveFileText(table), { own: true });
assert.deepEqual(own.entries.map((e) => e.keep === true), [true, false, false, false, true]);
// A file from anyone else never arrives with it on.
assert.equal(parseOwnTable(saveFileText(table)).entries.some((e) => 'keep' in e), false);
// And a shared copy does not carry it out.
assert.equal(JSON.parse(exportOwnTable(table)).cheats.some((c) => 'keep' in c), false);
// keep: false is not written at all.
assert.equal(saveFileText({ ...table, entries: [{ ...patch, keep: false }] }).includes('keep'), false);
});
test('a patch located by pattern can still be exported for GoldHEN through its fixed position', () => {
const out = exportCommunityTable({ ...FULL, entries: [FULL.entries[0]] });
assert.equal(out.included, 1);
assert.deepEqual(JSON.parse(out.text).mods[0].memory.map((m) => m.offset), ['6017', '7000']);
});
// ---- update-proofing ---------------------------------------------------------------
test('proofing: a fixed position in code becomes a pattern around the patch that matches with the cheat on or off', async () => {
plant(HOME);
const { dbg, ctx } = await context();
const site = HOME + SITE_IN_CODE;
const proofed = await proofEntry(ctx, patchAt(site));
assert.equal(proofed.at.kind, 'signature');
assert.deepEqual(proofed.fallback, moduleAt(site));
assert.equal(patchLocating(proofed), 'pattern');
assert.equal(patchLocating(patchAt(site)), 'fixed');
// The patched bytes are wildcards in the pattern, and nothing of them is fixed.
const { mask } = parsePattern(proofed.at.pattern);
assert.deepEqual([...mask.subarray(proofed.at.offset, proofed.at.offset + 3)], [0, 0, 0]);
assert.ok(proofed.at.offset > 0, 'the pattern starts before the patch');
assert.ok(mask.length > proofed.at.offset + 3, 'and ends after it');
// The load-address-dependent parts of the neighbors are wildcards too (the call target here).
assert.match(proofed.at.pattern, /E8( \?\?){4}/);
// The stored context is the original code, with the patch position marked.
assert.equal(proofed.around.bytes.substr(proofed.around.at * 2, 6), 'FF4B28');
assert.equal(proofed.around.mask.length * 2, proofed.around.bytes.length);
assert.match(proofed.around.mask, /^[x.?]+$/);
const address = GAME.imageBase + BigInt(site);
const start = address - BigInt(proofed.at.offset);
assert.deepEqual(await findPattern(ctx, proofed.at.pattern), { address: start, unique: true });
const runner = new TableRunner(ctx);
await runner.enable(proofed);
assert.equal(image(site, 3), '909090');
assert.equal(runner.usedFallback(proofed.id), false);
// Still exactly one match while the cheat is on, searched afresh.
ctx.signatureCache.clear();
assert.deepEqual(await findPattern(ctx, proofed.at.pattern), { address: start, unique: true });
assert.deepEqual(await resolveSite(ctx, proofed), { address, via: 'pattern' });
// A page reload later, the patch is noticed as already on and can be switched off.
const later = new TableRunner(ctx);
assert.equal(await later.adopt([proofed]), true);
await later.disable(proofed);
assert.equal(image(site, 3), 'FF4B28');
// Saved while switched on, the result is the same: it is built from the original bytes.
await runner.enable(proofed);
const whileOn = await proofEntry(ctx, patchAt(site));
assert.equal(whileOn.at.pattern, proofed.at.pattern);
assert.deepEqual(whileOn.around, proofed.around);
await runner.disable(proofed);
runner.dispose();
later.dispose();
dbg.close();
});
test('proofing: every extra place is converted, and what cannot be is left alone without failing', async () => {
plant(HOME);
const { dbg, ctx } = await context();
const site = HOME + SITE_IN_CODE;
const multi = patchAt(site, {
more: [
// "test eax, eax" two instructions earlier, inside the first place's context.
{ at: moduleAt(HOME + 19), on: '9090', off: '85C0' },
// Data, not code.
{ at: { kind: 'module', offset: '10100' }, on: '00', off: '00' },
// Does not hold what the cheat expects.
{ at: moduleAt(HOME), on: '90', off: 'AA' },
// Not a fixed position at all.
{ at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, on: '01' },
],
});
const out = await proofEntry(ctx, multi);
assert.equal(out.at.kind, 'signature');
assert.equal(out.more[0].at.kind, 'signature');
assert.deepEqual(out.more[0].fallback, moduleAt(HOME + 19));
assert.deepEqual(out.more.slice(1), multi.more.slice(1));
assert.equal(patchLocating(out), 'fixed');
// Nothing to do: the same object comes back.
const pointer = { id: 'v', type: 'patch', name: 'x', at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, on: '01' };
assert.equal(await proofEntry(ctx, pointer), pointer);
assert.equal(await proofEntry(ctx, out), out);
// A lone instruction in padding (the mock's own code) has no unique surroundings:
// it stays at its fixed position, with the context stored for later, and still works.
const lonely = await proofSite(ctx, { at: { kind: 'module', offset: '2040' }, on: '909090', off: 'FF4B28' });
if (lonely.at.kind === 'signature') assert.equal((await findPattern(ctx, lonely.at.pattern)).unique, true);
else assert.equal(lonely.around.bytes.substr(lonely.around.at * 2, 6), 'FF4B28');
dbg.close();
});
test('proofing: the fixed position is used when the pattern stops matching', async () => {
plant(HOME);
const { dbg, ctx } = await context();
const site = HOME + SITE_IN_CODE;
const proofed = await proofEntry(ctx, patchAt(site));
const address = GAME.imageBase + BigInt(site);
// Something else rewrites a neighboring instruction (another cheat, say): the pattern is gone.
mock.game.image.fill(0x90, HOME + 19, HOME + 21);
ctx.signatureCache.clear();
assert.equal(await findPattern(ctx, proofed.at.pattern), null);
assert.deepEqual(await resolveSite(ctx, proofed), { address, via: 'fallback' });
const runner = new TableRunner(ctx);
await runner.enable(proofed);
assert.equal(image(site, 3), '909090');
assert.equal(runner.usedFallback(proofed.id), true);
await runner.disable(proofed);
assert.equal(image(site, 3), 'FF4B28');
// The pattern matching in two other places is no better than not at all.
plant(0x7000);
plant(0x7800);
ctx.signatureCache.clear();
assert.equal((await findPattern(ctx, proofed.at.pattern)).unique, false);
assert.deepEqual(await resolveSite(ctx, proofed), { address, via: 'fallback' });
wipe(0x7000);
wipe(0x7800);
// Without a fallback a pattern that fails is an error, as before.
ctx.signatureCache.clear();
await assert.rejects(resolveSite(ctx, { ...proofed, fallback: undefined }), /not found/);
// With one, a fixed position that holds the wrong bytes is still refused.
wipe(HOME);
ctx.signatureCache.clear();
await assert.rejects(runner.enable(proofed), /not what this cheat expects/);
runner.dispose();
dbg.close();
});
// ---- finding cheats again after a game update ------------------------------------
test('re-find: found again at a new place, several possible places, not found', async () => {
const { dbg, ctx } = await context();
const site = HOME + SITE_IN_CODE;
const fresh = async () => {
wipe(0x7000, 0x900);
plant(HOME);
ctx.signatureCache.clear();
return proofEntry(ctx, patchAt(site, { note: 'mine' }));
};
// A cheat whose surroundings gave no pattern when it was saved: a fixed position plus the stored context.
const fixedOnly = (e) => ({ ...e, at: e.fallback, fallback: undefined });
// Nothing moved: nothing to do.
let entry = await fresh();
assert.equal((await refindEntry(ctx, entry)).outcome, 'ok');
assert.equal((await refindEntry(ctx, fixedOnly(entry))).outcome, 'ok');
// The update moved the function and, as updates do, changed where its
// rip-relative load and its call point. The old place holds something else.
const moveTo = (offset, change) => {
wipe(HOME);
plant(offset, (b) => {
b.writeUInt32LE(0x5678, 15); // mov eax,[rip+…]
b.writeUInt32LE(0x2222, 27); // call …
change?.(b);
});
ctx.signatureCache.clear();
};
// A cheat located by pattern takes that in its stride: this is what the pattern is for.
moveTo(0x7000);
assert.deepEqual(await resolveSite(ctx, entry), { address: GAME.imageBase + BigInt(0x7000 + SITE_IN_CODE), via: 'pattern' });
assert.equal((await refindEntry(ctx, entry)).outcome, 'ok');
// One at a fixed position is lost, and found again from its stored context.
const before = Buffer.from(mock.game.image);
let result = await refindEntry(ctx, fixedOnly(entry), '01.000.000');
assert.equal(result.outcome, 'found');
assert.match(result.detail, /Moved from game\+0x6017 to game\+0x7017/);
assert.deepEqual(result.entry.fallback, moduleAt(0x7000 + SITE_IN_CODE));
assert.equal(result.entry.at.kind, 'signature');
assert.equal(result.entry.off, 'FF4B28');
assert.equal(result.entry.id, entry.id);
assert.match(result.entry.note, /^mine\nRe-found after a game update \(made on v01\.000\.000\): moved from game\+0x6017 to game\+0x7017\.$/);
assert.ok(before.equals(mock.game.image), 'nothing was written to the game');
// The cheat it hands back works at the new place.
const runner = new TableRunner(ctx);
await runner.enable(result.entry);
assert.equal(image(0x7000 + SITE_IN_CODE, 3), '909090');
await runner.disable(result.entry);
assert.equal((await refindEntry(ctx, result.entry)).outcome, 'ok');
// A constant in a neighboring instruction changed as well: the strict search misses, the loose one finds it.
entry = await fresh();
moveTo(0x7100, (b) => (b[SITE_IN_CODE + 12] = 0x38)); // mov rbx,[rsp+0x30] -> [rsp+0x38]
result = await refindEntry(ctx, fixedOnly(entry));
assert.equal(result.outcome, 'found');
assert.deepEqual(result.entry.fallback, moduleAt(0x7100 + SITE_IN_CODE));
// The patched instruction itself changed (the field moved from +0x28 to +0x30): found, the
// expected bytes are updated, and the user is told it is not quite the same code.
entry = await fresh();
moveTo(0x7200, (b) => (b[SITE_IN_CODE + 2] = 0x30));
result = await refindEntry(ctx, entry);
assert.equal(result.outcome, 'found');
assert.equal(result.entry.off, 'FF4B30');
assert.match(result.detail, /slightly different/);
// ... but not when the instruction changed size, since the old patch would cut one in half.
entry = await fresh();
moveTo(0x7300, (b) => Buffer.from('FF8B28010000E8', 'hex').copy(b, SITE_IN_CODE)); // dec dword [rbx+0x128]
for (const e of [entry, fixedOnly(entry)]) {
result = await refindEntry(ctx, e);
assert.equal(result.outcome, 'missing');
assert.equal(result.entry, undefined);
}
// The cheat is already applied at the new place (left on, or put there by the console).
entry = await fresh();
moveTo(0x7400, (b) => b.fill(0x90, SITE_IN_CODE, SITE_IN_CODE + 3));
result = await refindEntry(ctx, fixedOnly(entry));
assert.equal(result.outcome, 'found');
assert.equal(result.entry.off, 'FF4B28');
assert.equal(image(0x7400 + SITE_IN_CODE, 3), '909090');
// The short pattern now matches a second, similar function, so it is no longer safe;
// the longer stored context still tells the two apart.
entry = await fresh();
moveTo(0x7500);
plant(0x7600, (b) => (b[5] = 0x56)); // push rsi instead of push rdi, further from the patch than the pattern reaches
assert.equal((await findPattern(ctx, entry.at.pattern)).unique, false);
result = await refindEntry(ctx, entry);
assert.equal(result.outcome, 'found');
assert.deepEqual(result.entry.fallback, moduleAt(0x7500 + SITE_IN_CODE));
assert.equal((await findPattern(ctx, result.entry.at.pattern)).unique, true, 'the new pattern is long enough to be unique again');
// Two places look the same: no guessing.
entry = await fresh();
moveTo(0x7700);
plant(0x7800);
for (const e of [entry, fixedOnly(entry)]) {
result = await refindEntry(ctx, e);
assert.equal(result.outcome, 'several');
assert.equal(result.entry, undefined);
assert.match(result.detail, /more than one place/);
}
// Gone altogether.
entry = await fresh();
wipe(HOME);
ctx.signatureCache.clear();
for (const e of [entry, fixedOnly(entry)]) {
result = await refindEntry(ctx, e);
assert.equal(result.outcome, 'missing');
assert.equal(result.entry, undefined);
}
runner.dispose();
dbg.close();
});
test('re-find: fixed positions with known original bytes, old cheats without stored context, and whole tables', async () => {
const { dbg, ctx } = await context();
wipe(0x7000, 0x900);
plant(HOME);
ctx.signatureCache.clear();
// A cheat from an older file: a fixed position and original bytes, nothing else.
// Its own bytes are searched for when they are long enough to mean something.
const old = { id: 'old', type: 'patch', name: 'Old', at: moduleAt(HOME + 10), on: '909090909090909090', off: '488BD98B0512340000' };
assert.equal((await refindEntry(ctx, old)).outcome, 'ok');
wipe(HOME);
plant(0x7000);
let result = await refindEntry(ctx, old);
assert.equal(result.outcome, 'found');
assert.deepEqual(result.entry.fallback ?? result.entry.at, moduleAt(0x7000 + 10));
// Three bytes that occur all over the place are not searched for at all.
const short = { id: 'short', type: 'patch', name: 'Short', at: moduleAt(HOME + SITE_IN_CODE), on: '909090', off: 'FF4B28' };
result = await refindEntry(ctx, short);
assert.equal(result.outcome, 'missing');
assert.match(result.detail, /Nothing was saved/);
// Things that have no place in the code to check are passed over, not reported as broken.
const pointer = { id: 'ptr', type: 'patch', name: 'Ptr', at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, on: '01' };
assert.equal((await refindEntry(ctx, pointer)).outcome, 'skipped');
const table = { ...emptyTable('PPSA99999', '01.000.000'), entries: [old, short, pointer, { id: 's', type: 'script', name: 'S', code: 'x' }] };
const progress = [];
const all = await refindTable(ctx, table, (done, total) => progress.push(`${done}/${total}`));
assert.deepEqual(all.map((r) => `${r.name}:${r.outcome}`), ['Old:found', 'Short:missing', 'Ptr:skipped']);
assert.deepEqual(progress, ['0/3', '1/3', '2/3', '3/3']);
wipe(0x7000);
dbg.close();
});
test('make: "disable this instruction" gives a patch located by the code around it', async () => {
plant(HOME);
const { dbg, ctx } = await context();
const address = GAME.imageBase + BigInt(HOME + SITE_IN_CODE);
const [insn] = sync.disassemble(hexToBytes('FF4B28'), address, 1);
const entry = await nopEntry(ctx, insn);
assert.equal(entry.name, 'Skip "dec dword ptr [rbx+0x28]"');
assert.equal(entry.at.kind, 'signature');
assert.deepEqual(entry.fallback, moduleAt(HOME + SITE_IN_CODE));
assert.equal(entry.on, '909090');
assert.equal(entry.off, 'FF4B28');
// Switched on and off twice: the second time is what the old instruction-anchored pattern could not do after a reload.
for (let i = 0; i < 2; i++) {
const runner = new TableRunner(ctx);
ctx.signatureCache.clear();
await runner.enable(entry);
assert.equal(image(HOME + SITE_IN_CODE, 3), '909090');
ctx.signatureCache.clear();
const again = new TableRunner(ctx);
await again.enable(entry);
await again.disable(entry);
assert.equal(image(HOME + SITE_IN_CODE, 3), 'FF4B28');
runner.dispose();
again.dispose();
}
dbg.close();
});
// ---- dropdown values ----------------------------------------------------------------
test('runner: picking a value writes it, and a held entry goes on being held at the new value', async () => {
const { dbg, ctx } = await context();
const runner = new TableRunner(ctx);
const entry = { id: 'd', type: 'value', name: 'Difficulty', at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, valueType: 'u32', value: '1', freeze: true };
const health = () => mock.game.heap.readUInt32LE(GAME.vars.health);
const settle = async (want) => {
for (let i = 0; i < 100 && health() !== want; i++) await new Promise((r) => setTimeout(r, 20));
assert.equal(health(), want);
};
// Off: written once, not held.
await runner.writeValue({ ...entry, value: '2' });
assert.equal(health(), 2);
assert.equal(runner.isOn('d'), false);
mock.game.heap.writeUInt32LE(50, GAME.vars.health);
await new Promise((r) => setTimeout(r, 350));
assert.equal(health(), 50);
// On: the hold moves to the new value.
await runner.enable(entry);
await runner.writeValue({ ...entry, value: '0' });
assert.equal(runner.isOn('d'), true);
mock.game.heap.writeUInt32LE(50, GAME.vars.health);
await settle(0);
await runner.disable(entry);
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
runner.dispose();
dbg.close();
});
// ---- the write journal -----------------------------------------------------------------
test('journal: remembers what was there and puts it back newest first', async () => {
const { dbg, ctx } = await context();
const log = [];
const mem = {
read: (address, length) => dbg.read(GAME.pid, address, length),
write: async (address, bytes) => {
log.push(`${(address - GAME.heapBase).toString(16)}=${bytesToHex(bytes)}`);
await dbg.write(GAME.pid, address, bytes);
},
};
const heap = mock.game.heap;
const at = (offset) => GAME.heapBase + BigInt(offset);
heap.writeUInt32LE(0x11111111, 0x500);
heap.writeUInt32LE(0x22222222, 0x504);
const journal = new WriteJournal();
await journal.write(mem, at(0x500), hexToBytes('AAAAAAAA'));
await journal.write(mem, at(0x502), hexToBytes('BBBBBBBB')); // overlaps the first and the next value
await journal.write(mem, at(0x500), hexToBytes('CCCCCCCC')); // the same spot again: no second record
await journal.write(mem, at(0x600), hexToBytes('00000000')); // changes nothing: nothing to undo
assert.equal(journal.size, 2);
assert.equal(bytesToHex(heap.subarray(0x500, 0x508)), 'CCCCCCCCBBBB2222');
assert.ok(journal.list()[0].time <= Date.now());
log.length = 0;
const result = await journal.restore(mem);
// Newest first; the overlap comes out right because the older original is written last.
assert.deepEqual(log, ['502=AAAA2222', '500=11111111']);
assert.equal(bytesToHex(heap.subarray(0x500, 0x508)), '1111111122222222');
assert.equal(result.restored.length, 2);
assert.equal(result.failed.length, 0);
assert.equal(journal.size, 0);
// What cannot be put back stays in the journal and is reported; the rest is still restored.
await journal.write(mem, at(0x500), hexToBytes('01'));
await journal.write(mem, at(0x510), hexToBytes('02'));
const flaky = { ...mem, write: (address, bytes) => (address === at(0x510) ? Promise.reject(new Error('The game has closed')) : mem.write(address, bytes)) };
const partial = await journal.restore(flaky);
assert.equal(partial.restored.length, 1);
assert.deepEqual(partial.failed.map((f) => f.error), ['The game has closed']);
assert.equal(journal.size, 1);
assert.equal(heap[0x500], 0x11);
await journal.restore(mem);
assert.equal(heap[0x510], 0);
// A failed read means no write: nothing is changed that could not be undone.
const blind = { read: () => Promise.reject(new Error('unreadable')), write: mem.write };
log.length = 0;
await assert.rejects(journal.write(blind, at(0x500), hexToBytes('FF')), /unreadable/);
assert.deepEqual(log, []);
dbg.close();
});
// ---- hotkeys -------------------------------------------------------------------------------
test('hotkeys: naming a key press, reserved combinations, clashes', () => {
const press = (key, code, mods = {}) => hotkeyFromEvent({ key, code, ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, ...mods });
assert.equal(press('F6', 'F6'), 'F6');
assert.equal(press('!', 'Digit1', { ctrlKey: true, shiftKey: true }), 'Ctrl+Shift+1');
assert.equal(press('g', 'KeyG', { altKey: true }), 'Alt+G');
assert.equal(press('q', 'KeyA'), 'A', 'the physical key, whatever the layout prints on it');
assert.equal(press(' ', 'Space', { ctrlKey: true }), 'Ctrl+Space');
assert.equal(press('ArrowUp', 'ArrowUp', { ctrlKey: true, altKey: true, shiftKey: true, metaKey: true }), 'Ctrl+Alt+Shift+Meta+Up');
assert.equal(press('5', 'Numpad5'), 'Num5');
for (const modifier of ['Control', 'Shift', 'Alt', 'Meta']) assert.equal(press(modifier, modifier + 'Left', { ctrlKey: true }), null);
assert.equal(hotkeyProblem('F6'), null);
assert.equal(hotkeyProblem('Ctrl+Shift+1'), null);
assert.equal(hotkeyProblem('Alt+G'), null);
assert.match(hotkeyProblem('Ctrl+W'), /browsers keep Ctrl\+W/);
assert.match(hotkeyProblem('F5'), /browsers keep F5/);
assert.match(hotkeyProblem('Ctrl+1'), /browsers keep/);
assert.match(hotkeyProblem('Meta+K'), /Windows or Command key/);
assert.match(hotkeyProblem('Tab'), /move around/);
assert.match(hotkeyProblem('G'), /not typing/);
const entries = [{ id: '1', name: 'Ammo', hotkey: 'F6' }, { id: '2', name: 'Health', hotkey: 'F6' }, { id: '3', name: 'Gold' }];
assert.deepEqual(hotkeyClashes(entries, '1', 'F6'), ['Health']);
assert.deepEqual(hotkeyClashes(entries, '3', 'F6'), ['Ammo', 'Health']);
assert.deepEqual(hotkeyClashes(entries, '1', 'F7'), []);
});
// ---- the console engine's status ---------------------------------------------------------
test('keep: reading the engine status and matching it to cheats', () => {
const doc = {
running: true,
game: { titleId: 'PPSA99999', version: '01.000.000', pid: 91 },
file: 'PPSA99999-01.000.000.cspector',
cheats: [
{ name: 'Infinite ammo', state: 'on', detail: '' },
{ name: 'Twin', state: 'waiting', detail: 'Waiting for the game to load that part.' },
{ name: 'Twin', state: 'mismatch', detail: 'The game code is different.' },
{ name: 'Odd', state: 'sideways' },
{ state: 'on' },
null,
],
};
const status = parseEngineStatus(doc);
assert.equal(status.cheats.length, 4);
assert.deepEqual(status.game, { titleId: 'PPSA99999', version: '01.000.000', pid: 91 });
assert.deepEqual(keptStatus(status, 'PPSA99999', 'Infinite ammo'), { name: 'Infinite ammo', state: 'on', detail: '' });
assert.equal(keptStatus(status, 'ppsa99999', 'Twin', 0).state, 'waiting');
assert.equal(keptStatus(status, 'PPSA99999', 'Twin', 1).state, 'mismatch');
assert.equal(keptStatus(status, 'PPSA99999', 'Twin', 2), null);
assert.equal(keptStatus(status, 'PPSA99999', 'Odd').state, 'error', 'a state this version does not know is a problem, not "on"');
assert.equal(keptStatus(status, 'PPSA99999', 'Not in the file'), null);
// The engine is looking at another game, or none.
assert.equal(keptStatus(status, 'PPSA11111', 'Infinite ammo'), null);
assert.equal(keptStatus(parseEngineStatus({ ...doc, game: null }), 'PPSA99999', 'Infinite ammo'), null);
// It cannot reach the debugger: every kept cheat says so.
assert.match(keptStatus(parseEngineStatus({ running: false, game: null, file: '', cheats: [] }), 'PPSA99999', 'Infinite ammo').detail, /cannot reach its debugger/);
assert.equal(keptStatus(null, 'PPSA99999', 'Infinite ammo'), null);
for (const bad of [null, 'x', {}, { running: 'yes' }]) assert.equal(parseEngineStatus(bad), null);
});