mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-26 11:26:40 +01:00
- Find: text search, grouped search, undo, went up/down by, where to search, before/after compare - Debug tab: breakpoints, stepping, registers, stack, threads, what code touches, structure compare, pointer re-check - Engine tab: Unreal, Unity, Godot, GameMaker and C++ class names; hidden settings and switches - Code cheats with labels, variables, references and shared code; rule builder; script commands - Cheats kept on by the PS5 without the browser - Learn section with twelve lessons and an in-page practice game - Section (master code) community cheats; SHN and MC4 export - Groups, dropdowns, hotkeys, first-cheat guide, re-find after update, restore everything - MCP server: 25 tools - Quick start, wiki, contributor and security guides, changelog
654 lines
33 KiB
JavaScript
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);
|
|
});
|