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