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

380 lines
18 KiB
JavaScript

// What the Engine tab stands on: the order engines are tried in, the demo
// console's made-up helper, hidden switches, field values, and bulk reads on
// a real debugger connection (against the mock console).
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
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 { createGameContext } from '../src/lib/locations.ts';
import { ENGINE_TITLES, SETTING_SHORTCUTS, detectEngine, hasSettings, helperNotes, knownEngine } from '../src/lib/engines.ts';
import { detectDemoEngine } from '../src/lib/engine-demo.ts';
import { decodeField, fieldValueKind, isPointerField, objectSpan } from '../src/lib/engine-values.ts';
import { findHiddenSwitches, whatUses } from '../src/lib/hidden.ts';
import { MemoryReader } from '../src/lib/rtti.ts';
import { FakeProcess, R, RW, RX } from './engines-memory.mjs';
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
const TCP = 17630 + PORT_OFFSET;
let mock, bridge, bridgePort;
const open = [];
const connect = async () => {
const stream = await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${TCP}`);
const dbg = new Ps5Debug(stream, { timeoutMs: 4000 });
open.push(dbg);
return dbg;
};
const mockCtx = async () => {
const dbg = await connect();
return createGameContext({ pid: GAME.pid, dbg: () => dbg, x86: null });
};
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: 'test' } });
bridgePort = await bridge.ready;
});
after(async () => {
for (const d of open) if (!d.closed) d.close();
await bridge.close();
await mock.close();
});
// ---- detectEngine ------------------------------------------------------------------
const fakeCtx = () => createGameContext({ pid: 1, dbg: () => ({}), x86: null });
const helperFor = (engine, label = engine) => ({ engine, label, describeObject: async () => null, classInfo: async () => null, findClasses: async () => [], findObjects: async () => [] });
function tracker(found = {}) {
const calls = [];
const detectors = {};
for (const id of ['unreal', 'unity', 'godot', 'gamemaker', 'cpp']) {
detectors[id] = async () => {
calls.push(id);
if (found[id] === 'throw') throw new Error('boom');
return found[id] ? helperFor(id) : null;
};
}
return { calls, detectors };
}
test('detectEngine: the first guess decides which helper is tried, with C++ names as the last resort', async () => {
// Guess names an engine and its helper works: nothing else is tried.
let t = tracker({ unity: true, cpp: true });
let r = await detectEngine(fakeCtx(), { identify: async () => ({ engine: 'unity', evidence: ['saw il2cpp'] }), detectors: t.detectors });
assert.deepEqual(t.calls, ['unity']);
assert.equal(r.helper.engine, 'unity');
assert.deepEqual(r.guess, { engine: 'unity', evidence: ['saw il2cpp'] });
assert.match(r.steps[0], /^Unity: found \(unity\), in /);
// Guess names an engine whose helper finds nothing: only C++ names are tried after it.
t = tracker({ cpp: true });
r = await detectEngine(fakeCtx(), { identify: async () => ({ engine: 'unreal', evidence: [] }), detectors: t.detectors });
assert.deepEqual(t.calls, ['unreal', 'cpp']);
assert.equal(r.helper.engine, 'cpp');
// The guess stands: it is still an Unreal game, read through its C++ names.
assert.equal(r.guess.engine, 'unreal');
// A definite "none of the known texts": Unreal and Unity, which go by other signs, then C++ names.
t = tracker({});
r = await detectEngine(fakeCtx(), { identify: async () => ({ engine: 'unknown', evidence: ['None of the texts were found.'] }), detectors: t.detectors });
assert.deepEqual(t.calls, ['unreal', 'unity', 'cpp']);
assert.equal(r.helper, null);
assert.equal(r.guess.engine, 'unknown');
t = tracker({ unreal: true, cpp: true });
r = await detectEngine(fakeCtx(), { identify: async () => ({ engine: 'unknown', evidence: ['None of the texts were found.'] }), detectors: t.detectors });
assert.deepEqual(t.calls, ['unreal']);
assert.equal(r.guess.engine, 'unreal');
// The guess ran out of time: every engine is tried in order until one answers.
t = tracker({ godot: true });
r = await detectEngine(fakeCtx(), { identify: async () => ({ engine: 'unknown', evidence: ['Stopped looking before every engine had been checked, because the search was taking too long.'] }), detectors: t.detectors });
assert.deepEqual(t.calls, ['unreal', 'unity', 'godot']);
assert.equal(r.helper.engine, 'godot');
assert.equal(r.guess.engine, 'godot');
});
test('detectEngine never throws, reports progress, and answers once per game', async () => {
const t = tracker({ unreal: 'throw', unity: 'throw', godot: 'throw', gamemaker: 'throw', cpp: 'throw' });
const said = [];
const ctx = fakeCtx();
assert.equal(knownEngine(ctx), null);
const opts = {
identify: async () => {
throw new Error('link dropped');
},
detectors: t.detectors,
onProgress: (text) => {
said.push(text);
throw new Error('a listener that misbehaves');
},
};
const first = detectEngine(ctx, opts);
const second = detectEngine(ctx, opts);
const r = await first;
assert.equal(await second, r, 'two askers share one search');
assert.equal(r.helper, null);
assert.deepEqual(t.calls, ['unreal', 'unity', 'godot', 'gamemaker', 'cpp']);
assert.match(r.guess.evidence[0], /link dropped/);
assert.equal(r.steps.length, 5);
assert.match(r.steps[0], /Unreal Engine: stopped with an error \(boom\)/);
assert.deepEqual(said, [
'Looking at what the game has loaded',
'Reading Unreal Engine data from the game',
'Reading Unity data from the game',
'Reading Godot data from the game',
'Reading GameMaker data from the game',
'Looking for C++ class names',
]);
assert.equal(knownEngine(ctx), r);
// Asking again is free; asking afresh looks again.
assert.equal(await detectEngine(ctx, opts), r);
assert.equal(t.calls.length, 5);
const again = await detectEngine(ctx, { ...opts, refresh: true });
assert.notEqual(again, r);
assert.equal(t.calls.length, 10);
// Another game process is another question.
assert.equal(knownEngine(fakeCtx()), null);
assert.ok(ENGINE_TITLES.unknown && SETTING_SHORTCUTS.some((s) => s.name === 't.MaxFPS'));
});
test('the real detectors find no engine in the mock game, and say why', async () => {
const ctx = await mockCtx();
const r = await detectEngine(ctx);
assert.equal(r.helper, null);
assert.equal(r.guess.engine, 'unknown');
assert.ok(r.guess.evidence.length > 0);
assert.match(r.steps.at(-1), /C\+\+ class names: not found/);
});
// ---- the demo console's helper ---------------------------------------------------------
test('the demo helper reads the mock game and is refused anywhere else', async () => {
const ctx = await mockCtx();
const r = await detectEngine(ctx, { demo: true });
const h = r.helper;
assert.ok(h);
assert.match(r.guess.evidence[0], /made-up game/);
assert.ok(helperNotes(h).length > 0);
assert.ok(hasSettings(h));
const classes = await h.findClasses('');
assert.deepEqual(classes.map((c) => c.name).sort(), ['Actor', 'Character', 'Player', 'World']);
const objects = await h.findObjects('Character');
assert.deepEqual(objects.map((o) => o.name), ['Player', 'Sentry', 'Dog']);
assert.deepEqual(objects.map((o) => o.address), GAME.characters.map((o) => GAME.heapBase + BigInt(o)));
const sentry = objects[1];
assert.deepEqual(await h.describeObject(sentry.address), sentry);
assert.equal(await h.describeObject(sentry.address + 8n), null);
assert.equal(await h.describeObject(0x10n), null);
assert.equal((await h.describeObject(GAME.heapBase + BigInt(GAME.player))).className, 'Player');
// Field values straight from the mock's memory.
const info = await h.classInfo(sentry.address);
assert.equal(info.name, 'Character');
const bytes = await ctx.dbg().read(ctx.pid, sentry.address, objectSpan(info.fields, info.size));
const value = (name) => decodeField(info.fields.find((f) => f.name === name), bytes);
assert.equal(value('Stamina').text, '2000');
assert.equal(value('Stamina').kind.id, 'i32');
assert.equal(value('Health').text, '80');
assert.equal(value('Speed').text, '3.25');
assert.equal(value('Target').pointer, GAME.heapBase + BigInt(GAME.characters[0]));
assert.equal(value('Position'), null);
const methods = await h.findMethods('gold');
assert.deepEqual(methods.map((m) => [m.name, m.address]), [['Wallet.SetGold', GAME.imageBase + BigInt(GAME.funcs.setGold)]]);
const [fps] = await h.consoleVariablesNamed(['t.maxfps']);
assert.equal(fps.value, 60);
assert.equal(fps.address, GAME.dataBase + BigInt(GAME.settings.maxFps));
assert.deepEqual((await h.consoleVariables('r.')).map((v) => [v.name, v.value]), [['r.ScreenPercentage', 75], ['r.MotionBlurQuality', 4]]);
// Any other game: no demo helper, whatever the caller says.
const p = new FakeProcess();
p.map('executable', 0x400000n, 0x10000, RX);
p.map('executable', 0x410000n, 0x10000, RW);
assert.equal(await detectDemoEngine(p.ctx()), null);
const other = await detectEngine(p.ctx(), { demo: true, identify: async () => ({ engine: 'unknown', evidence: ['nothing'] }), detectors: tracker().detectors });
assert.equal(other.helper, null);
});
// ---- field values -------------------------------------------------------------------
test('field types map to value types, and values are decoded', () => {
const f = (type, offset = 0, size) => ({ name: 'x', type, offset, size });
assert.equal(fieldValueKind(f('float')).id, 'float');
assert.equal(fieldValueKind(f('int32')).id, 'i32');
assert.equal(fieldValueKind(f('int')).id, 'i32');
assert.equal(fieldValueKind(f('System.Single')).id, 'float');
assert.equal(fieldValueKind(f('ulong')).id, 'u64');
assert.equal(fieldValueKind(f('bool')).id, 'u8');
assert.equal(fieldValueKind(f('enum EFireMode', 0, 1)).id, 'u8');
assert.equal(fieldValueKind(f('enum EFireMode')), null);
assert.equal(fieldValueKind(f('StructProperty FVector')), null);
assert.equal(fieldValueKind(f('int32 [4]')), null);
assert.ok(isPointerField(f('ObjectProperty -> Character')));
assert.ok(isPointerField(f('Weapon*')));
assert.ok(isPointerField({ ...f('PlayerWallet'), reference: true }));
assert.ok(!isPointerField(f('ArrayProperty of ObjectProperty -> Actor')));
const bytes = new Uint8Array(64);
const dv = new DataView(bytes.buffer);
dv.setFloat32(0, 0.1, true);
dv.setInt32(4, -7, true);
bytes[8] = 0b1000;
dv.setBigUint64(16, 0x880014000n, true);
dv.setFloat64(24, 1 / 3, true);
assert.deepEqual([decodeField(f('float', 0), bytes).text, decodeField(f('float', 0), bytes).input], ['0.1', '0.1']);
assert.equal(decodeField(f('int32', 4), bytes).text, '-7');
assert.equal(decodeField(f('uint32', 4), bytes).text, String(0xfffffff9));
assert.deepEqual(decodeField(f('bool (bit 3)', 8), bytes), { text: 'true', kind: null });
assert.equal(decodeField(f('bool (bit 2)', 8), bytes).text, 'false');
assert.equal(decodeField(f('bool', 8), bytes).text, 'true');
assert.equal(decodeField(f('ObjectProperty -> Actor', 16), bytes).pointer, 0x880014000n);
assert.equal(decodeField(f('Actor*', 32), bytes).text, 'nothing (null)');
assert.equal(decodeField(f('double', 24), bytes).text, '0.333333');
// Out of reach, static, or a slice that starts further in.
assert.equal(decodeField(f('int32', 62), bytes), null);
assert.equal(decodeField({ ...f('int32', 4), isStatic: true }, bytes), null);
assert.equal(decodeField(f('int32', 0x104), bytes, 0x100).text, '-7');
assert.equal(objectSpan([f('int', 0x10, 4), f('long', 0x340, 8)]), 0x348);
assert.equal(objectSpan([f('int', 0x10, 4)], 0x100000), 0x8000);
});
// ---- hidden switches -----------------------------------------------------------------
test('hidden switches: the mock game\'s texts are found, sorted into topics, and traced to the code that uses them', async () => {
const ctx = await mockCtx();
const seen = [];
const r = await findHiddenSwitches(ctx, { onProgress: (p) => seen.push(p) });
const at = (i) => GAME.imageBase + BigInt(GAME.texts.offset + i * 0x40);
assert.deepEqual(
r.hits.map((h) => [h.topic, h.text, h.word, h.encoding, h.address]),
[
['cheats', 'GodMode', 'GodMode', 'utf8', at(1)],
['cheats', 'cheat_infinite_ammo', 'cheat', 'utf8', at(2)],
['debug', 'debug_menu_enabled', 'debug_menu', 'utf8', at(0)],
['framerate', 'ShowFPS', 'FPS', 'utf8', at(3)],
['framerate', 't.MaxFPS', 'FPS', 'utf16', at(4)],
],
);
assert.equal(r.capped || r.canceled || r.truncated, false);
assert.equal(r.bytesSearched, 0x10000);
assert.equal(seen.at(-1).fraction, 1);
// The demo game keeps a pointer to the first text in its data; no code mentions any of them.
const uses = await whatUses(ctx, at(0));
assert.deepEqual(uses.code, []);
assert.deepEqual(uses.data, [GAME.dataBase + BigInt(GAME.texts.table)]);
assert.equal(uses.codeProblem, undefined);
// Give the game an instruction that uses another one: lea rax,[rip+...]; ret
const user = 0x3700;
const before = Buffer.from(mock.game.image.subarray(user, user + 8));
const lea = Buffer.from('488D0500000000C3', 'hex');
lea.writeInt32LE(GAME.texts.offset + 0x40 - (user + 7), 3);
lea.copy(mock.game.image, user);
try {
assert.deepEqual(await whatUses(ctx, at(1)), { code: [GAME.imageBase + BigInt(user)], data: [] });
} finally {
before.copy(mock.game.image, user);
}
// Your own words come first, in any capitals.
const own = await findHiddenSwitches(ctx, { words: [' INFINITE ', '', 'x'] });
assert.deepEqual([own.hits[0].topic, own.hits[0].text, own.hits[0].word], ['yours', 'cheat_infinite_ammo', 'infinite']);
assert.equal(own.hits.length, 5);
await assert.rejects(findHiddenSwitches(ctx, { module: 'nope.prx' }), /not loaded/);
});
test('hidden switches: texts across a seam, repeats, limits and canceling', async () => {
const p = new FakeProcess();
p.map('executable', 0x400000n, 0x4000, RX);
const ro = p.map('executable', 0x404000n, 0x300000, R);
const rw = p.map('executable', 0x704000n, 0x4000, RW);
const put = (off, text, wide = false) => {
for (let i = 0; i < text.length; i++) {
ro.bytes[off + i * (wide ? 2 : 1)] = text.charCodeAt(i);
}
};
put(0x100000 - 6, 'bEnableDebugMenu'); // straddles the first 1 MB piece
put(0x200000 - 9, 'GodModeEnabled', true); // 16-bit text across the second seam, at an odd address
put(0x1000, 'ShowFPS');
put(0x2000, 'ShowFPS');
put(0x3000, 'ShowFPS');
for (let i = 0; i < 40; i++) put(0x10000 + i * 0x40, `debug line number ${i}`);
put(0x5000, 'a'.repeat(300) + ' noclip ' + 'b'.repeat(300));
// Writable data and code are not text the program was built with.
for (let i = 0; i < 8; i++) rw.bytes[i] = 'cheats!!'.charCodeAt(i);
// A pointer to one of the texts, as a table of option names would hold.
p.w64(rw.start + 0x100n, ro.start + 0x1000n);
const ctx = p.ctx();
const r = await findHiddenSwitches(ctx, { perTopic: 25 });
const find = (text) => r.hits.find((h) => h.text === text);
assert.equal(find('bEnableDebugMenu').address, ro.start + BigInt(0x100000 - 6));
assert.equal(find('bEnableDebugMenu').topic, 'debug');
assert.deepEqual([find('GodModeEnabled').address, find('GodModeEnabled').encoding], [ro.start + BigInt(0x200000 - 9), 'utf16']);
assert.deepEqual([find('ShowFPS').address, find('ShowFPS').repeats], [ro.start + 0x1000n, 2]);
assert.equal(r.hits.filter((h) => h.topic === 'mentions').length, 25);
assert.equal(r.capped, true);
const long = r.hits.find((h) => h.word === 'noclip');
assert.ok(long.text.length <= 162 && long.text.includes(' noclip ') && long.text.startsWith('…'));
assert.ok(!r.hits.some((h) => h.text.includes('cheats!!')));
assert.equal(r.bytesSearched, 0x300000);
assert.equal(p.badReads, 0);
// Bounded: a limit on what is read is kept to, and said.
const small = await findHiddenSwitches(ctx, { maxBytes: 0x80000 });
assert.equal(small.bytesSearched, 0x80000);
assert.equal(small.truncated, true);
assert.ok(!small.hits.some((h) => h.text === 'bEnableDebugMenu'));
// Canceling keeps what was found up to then.
const stop = new AbortController();
const part = await findHiddenSwitches(ctx, { signal: stop.signal, onProgress: () => stop.abort() });
assert.equal(part.canceled, true);
assert.equal(part.bytesSearched, 0x100000);
assert.ok(part.hits.some((h) => h.text === 'ShowFPS'));
// "What uses this" without the code search (this made-up console has none): the table is still found.
const uses = await whatUses(ctx, ro.start + 0x1000n);
assert.deepEqual(uses.data, [rw.start + 0x100n]);
assert.ok(uses.codeProblem);
});
// ---- bulk reads on a real connection ---------------------------------------------------
test('MemoryReader.readMany uses the console\'s read-many command, and falls back if that fails', async () => {
const ctx = await mockCtx();
const mem = new MemoryReader(ctx);
const requests = GAME.characters.map((o) => ({ address: GAME.heapBase + BigInt(o + GAME.character.stamina), length: 4 }));
requests.push({ address: GAME.heapBase + BigInt(GAME.vars.health), length: 4 }, { address: GAME.dataBase + BigInt(GAME.statics.player), length: 8 }, { address: 0x10n, length: 8 });
const got = await mem.readMany(requests);
assert.equal(mem.bulk, 'works');
assert.equal(mem.roundTrips, 1);
assert.deepEqual(got.slice(0, 3).map((b) => new DataView(b.buffer, b.byteOffset).getUint32(0, true)), [1000, 2000, 3000]);
assert.equal(new DataView(got[4].buffer, got[4].byteOffset).getBigUint64(0, true), GAME.heapBase + BigInt(GAME.player));
assert.equal(got[5], null);
// A console where the command does not work: the same answers through plain reads, and it is not tried again.
const dbg = await connect();
let tries = 0;
dbg.raw = async () => {
tries++;
throw new Error('no such command');
};
const plain = new MemoryReader(createGameContext({ pid: GAME.pid, dbg: () => dbg, x86: null }));
const again = await plain.readMany(requests);
assert.equal(plain.bulk, 'broken');
assert.deepEqual(again.slice(0, 5), got.slice(0, 5));
await plain.readMany(requests);
assert.equal(tries, 1);
});