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

451 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// End-to-end tests: the real client code, through the real bridge, against the
// mock console. Run with `npm test`.
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { startMock, GAME } from '../mock/mock-ps5.mjs';
import { startBridge, isPrivateAddress } from '../bridge/server.mjs';
import { openWebSocketStream } from '../src/lib/transport.ts';
import { Session } from '../src/lib/session.ts';
import { Ps5Debug, authKeystream, STATUS } from '../src/lib/ps5debug.ts';
import { ScanSession, scannableRegions, valueKind, compareKind, encodeValue, decodeValue } from '../src/lib/scanner.ts';
import { parseCheatTable, findImageBase, readModState, applyMod, sameVersion } from '../src/lib/cheats.ts';
import { parseIndexLine } from '../src/lib/catalog.ts';
import { hexToBytes, bytesToHex } from '../src/lib/bytes.ts';
// Lets several copies of the suite run at once on one machine.
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
const TCP = 17440 + PORT_OFFSET;
const TCP_LEGACY = 17441 + PORT_OFFSET;
let mock, legacy, bridge, bridgePort;
const connect = async (port = TCP) => {
const stream = await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${port}`);
return new Ps5Debug(stream, { timeoutMs: 3000 });
};
before(async () => {
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
legacy = startMock({ tcpPort: TCP_LEGACY, udpPort: 0, tick: false, legacy: true });
await Promise.all([mock.ready, legacy.ready]);
// The bridge allows exactly one non-744 target (the demo console), so the
// legacy mock is reached through a second bridge.
bridge = startBridge({ port: 0, host: '127.0.0.1', demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 'test' } });
bridgePort = await bridge.ready;
});
after(async () => {
await bridge.close();
await mock.close();
await legacy.close();
});
test('status constants are the bit-swapped wire values', () => {
const swap = (x) => (((x >>> 1) & 0x55555555) | ((x << 1) & 0xaaaaaaaa)) >>> 0;
assert.equal(swap(0x40000000), STATUS.SUCCESS);
assert.equal(swap(0xf0000002), STATUS.ERROR);
assert.equal(swap(0xf0000003), STATUS.DATA_NULL);
assert.equal(swap(0xf0000008), STATUS.ALREADY_DEBUG);
assert.equal(swap(0xf000000a), STATUS.INVALID_INDEX);
});
test('auth keystream is deterministic and non-trivial', () => {
const a = authKeystream();
assert.equal(a.length, 256);
assert.deepEqual(a, authKeystream());
assert.ok(new Set(a).size > 64);
});
test('info commands', async () => {
const dbg = await connect();
assert.equal(await dbg.version(), '1.3');
assert.equal(await dbg.firmware(), 1000);
const b = await dbg.branding();
assert.match(b.brand, /^ps5debug-NG/);
assert.equal(b.capability, '1.1');
assert.equal(await dbg.supports('1.0'), true);
assert.equal(await dbg.supports('2.0'), false);
await dbg.ping();
dbg.close();
});
test('foreground app, process list, info and maps', async () => {
const dbg = await connect();
const app = await dbg.foregroundApp();
assert.deepEqual(app, { pid: GAME.pid, titleId: GAME.titleId, contentId: GAME.contentId, name: 'eboot.bin', version: GAME.version });
const procs = await dbg.processList();
assert.ok(procs.some((p) => p.name === 'eboot.bin' && p.pid === GAME.pid));
const info = await dbg.processInfo(GAME.pid);
assert.equal(info.titleId, GAME.titleId);
const maps = await dbg.maps(GAME.pid);
assert.equal(maps.length, 7);
assert.equal(maps[0].start, GAME.imageBase);
assert.equal(maps[0].end, GAME.imageBase + 0x10000n);
assert.equal(maps[0].prot, 5);
assert.deepEqual(findImageBase(maps, 'eboot.bin'), { address: GAME.imageBase, from: 'executable' });
dbg.close();
});
test('read, write and bulk write', async () => {
const dbg = await connect();
const addr = GAME.heapBase + BigInt(GAME.vars.ammo);
assert.equal(bytesToHex(await dbg.read(GAME.pid, addr, 4)), '1E000000');
await dbg.write(GAME.pid, addr, hexToBytes('E7030000'));
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.ammo), 999);
// A read larger than the server's 64 KiB chunk exercises reassembly.
const big = await dbg.read(GAME.pid, GAME.heapBase, 0x30000);
assert.equal(big.length, 0x30000);
assert.equal(new DataView(big.buffer).getUint32(GAME.vars.ammo, true), 999);
// Writing outside any mapping fails with the second status word.
await assert.rejects(dbg.write(GAME.pid, 0x10n, hexToBytes('00')), /Writing memory failed/);
// ...and the stream is still usable afterward.
await dbg.ping();
await dbg.writeMulti(GAME.pid, [
{ address: addr, data: hexToBytes('1E000000') },
{ address: GAME.heapBase + BigInt(GAME.vars.score), data: hexToBytes('2A000000') },
]);
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.ammo), 30);
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.score), 42);
// One bad entry fails the call, but the others are still written.
await assert.rejects(
dbg.writeMulti(GAME.pid, [
{ address: addr, data: hexToBytes('05000000') },
{ address: 0x10n, data: hexToBytes('00') },
]),
/Writing memory failed/,
);
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.ammo), 5);
await dbg.ping();
await assert.rejects(dbg.writeMulti(GAME.pid, new Array(0x10000).fill({ address: addr, data: hexToBytes('00') })), /Too many values/);
mock.game.heap.writeUInt32LE(30, GAME.vars.ammo);
dbg.close();
});
test('commands queue instead of interleaving', async () => {
const dbg = await connect();
const results = await Promise.all([dbg.version(), dbg.firmware(), dbg.read(GAME.pid, GAME.heapBase, 8), dbg.ping(), dbg.version()]);
assert.equal(results[0], '1.3');
assert.equal(results[1], 1000);
assert.equal(results[2].length, 8);
assert.equal(results[4], '1.3');
dbg.close();
});
test('notify', async () => {
const dbg = await connect();
await dbg.notify('CheatSpecter connected');
assert.equal(mock.state.notifications.at(-1), 'CheatSpecter connected');
dbg.close();
});
test('scan: exact first pass, then narrow by change', async () => {
const dbg = await connect();
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
const kind = valueKind('u32');
const regions = scannableRegions(await dbg.maps(GAME.pid));
assert.ok(regions.length >= 3);
assert.ok(regions.every((r) => r.start !== GAME.imageBase), 'code is not scanned');
const scan = new ScanSession(() => dbg, GAME.pid, kind);
await scan.first(regions, compareKind('exact'), encodeValue(kind, '100'), () => {});
assert.equal(dbg.authed, true);
assert.equal(scan.matches, 6); // health plus five decoys
const healthAddr = GAME.heapBase + BigInt(GAME.vars.health);
assert.ok((await scan.hits(100)).some((h) => h.address === healthAddr));
mock.game.heap.writeUInt32LE(93, GAME.vars.health);
await scan.next(compareKind('decreased'), new Uint8Array(0), () => {});
assert.equal(scan.matches, 1);
const [hit] = await scan.hits(10);
assert.equal(hit.address, healthAddr);
assert.equal(decodeValue(kind, hit.value), '93');
await scan.next(compareKind('unchanged'), new Uint8Array(0), () => {});
assert.equal(scan.matches, 1);
await scan.next(compareKind('exact'), encodeValue(kind, '93'), () => {});
assert.equal(scan.matches, 1);
await scan.next(compareKind('changed'), new Uint8Array(0), () => {});
assert.equal(scan.matches, 0);
dbg.close();
});
test('scan: float, bigger/smaller/between', async () => {
const dbg = await connect();
mock.game.heap.writeFloatLE(2500, GAME.vars.gold);
const kind = valueKind('float');
const regions = scannableRegions(await dbg.maps(GAME.pid));
const scan = new ScanSession(() => dbg, GAME.pid, kind);
await scan.first(regions, compareKind('exact'), encodeValue(kind, '2500'), () => {});
assert.equal(scan.matches, 1);
const goldHit = (await scan.hits(1))[0];
assert.equal(goldHit.address, GAME.heapBase + BigInt(GAME.vars.gold));
assert.equal(decodeValue(kind, goldHit.value), '2500');
const between = new Uint8Array(8);
between.set(encodeValue(kind, '2000'), 0);
between.set(encodeValue(kind, '3000'), 4);
const scan2 = new ScanSession(() => dbg, GAME.pid, kind);
await scan2.first(regions, compareKind('between'), between, () => {});
assert.ok((await scan2.hits(1000)).some((h) => h.address === GAME.heapBase + BigInt(GAME.vars.gold)));
await scan2.next(compareKind('bigger'), encodeValue(kind, '2499'), () => {});
await scan2.next(compareKind('smaller'), encodeValue(kind, '2501'), () => {});
assert.equal(scan2.matches, 1);
dbg.close();
});
test('value encoding', () => {
assert.equal(bytesToHex(encodeValue(valueKind('u32'), '100')), '64000000');
assert.equal(bytesToHex(encodeValue(valueKind('u32'), '0xFF')), 'FF000000');
assert.equal(bytesToHex(encodeValue(valueKind('i16'), '-2')), 'FEFF');
assert.equal(bytesToHex(encodeValue(valueKind('u64'), '18446744073709551615')), 'FFFFFFFFFFFFFFFF');
assert.equal(bytesToHex(encodeValue(valueKind('float'), '1')), '0000803F');
assert.equal(decodeValue(valueKind('i8'), hexToBytes('FF')), '-1');
assert.equal(decodeValue(valueKind('double'), encodeValue(valueKind('double'), '0.1')), '0.1');
assert.throws(() => encodeValue(valueKind('u8'), '256'), /does not fit/);
assert.throws(() => encodeValue(valueKind('u8'), 'abc'), /not a whole number/);
assert.throws(() => encodeValue(valueKind('u32'), ''), /Enter a value/);
});
test('cheat table: parse, detect state, toggle, refuse mismatch', async () => {
const dbg = await connect();
const table = parseCheatTable(
JSON.stringify({
name: 'Specter Test Game',
id: 'PPSA99999',
version: '01.000.000',
process: 'eboot.bin',
credits: ['tester'],
mods: [
{ name: 'Infinite Health', type: 'checkbox', memory: [{ offset: '1000', on: '909090909090', off: '898190030000' }] },
{ name: 'Wrong build', type: 'checkbox', memory: [{ offset: '5000', on: '9090', off: 'DEAD' }] },
{ name: 'Cave', type: 'checkbox', memory: [{ offset: '10', section: '2', on: '90', off: '00' }] },
{ name: 'Give gold', type: 'button', hint: 'once', memory: [{ offset: '2040', on: 'FF4B28', off: 'FF4B28' }] },
],
}),
);
assert.equal(table.titleId, 'PPSA99999');
assert.equal(table.mods.length, 4);
assert.equal(table.mods[2].patches[0].section, 2);
assert.equal(table.mods[3].type, 'button');
assert.equal(table.mods[3].description, 'once');
const base = findImageBase(await dbg.maps(GAME.pid), table.process).address;
const [health, wrong, cave, button] = table.mods;
assert.equal(await readModState(dbg, GAME.pid, base, health), 'off');
await applyMod(dbg, GAME.pid, base, health, true);
assert.equal(bytesToHex(mock.game.image.subarray(0x1000, 0x1006)), '909090909090');
assert.equal(await readModState(dbg, GAME.pid, base, health), 'on');
await applyMod(dbg, GAME.pid, base, health, false);
assert.equal(await readModState(dbg, GAME.pid, base, health), 'off');
assert.equal(await readModState(dbg, GAME.pid, base, wrong), 'mismatch');
assert.equal(await readModState(dbg, GAME.pid, base, cave), 'unknown');
assert.equal(await readModState(dbg, GAME.pid, base, button), 'unknown');
await assert.rejects(applyMod(dbg, GAME.pid, base, cave, true), /outside the game's main executable/);
dbg.close();
});
test('cheat table: rejects junk', () => {
assert.throws(() => parseCheatTable('not json'), /not valid JSON/);
assert.throws(() => parseCheatTable('{"name":"x"}'), /no "mods" list/);
assert.equal(parseCheatTable('{"mods":[]}').mods.length, 0);
});
test('version and index matching', () => {
assert.ok(sameVersion('01.026.000', '1.026.000'));
assert.ok(sameVersion('01.07', '1.7'));
assert.ok(sameVersion('01.00', '01.000.000'));
assert.ok(!sameVersion('01.07', '01.08'));
assert.ok(!sameVersion('', '01.00'));
assert.deepEqual(parseIndexLine('PPSA01628_1.026.000.json=Some Game'), { file: 'PPSA01628_1.026.000.json', titleId: 'PPSA01628', version: '1.026.000', gameName: 'Some Game', format: 'json' });
assert.equal(parseIndexLine('CUSA00018_01.21_default.elf.json=X').version, '01.21');
assert.equal(parseIndexLine('CUSA09193_01.05_2.json=X').version, '01.05');
assert.equal(parseIndexLine('CUSA00002_01.00_c20ae0e8.json=A=B').gameName, 'A=B');
assert.equal(parseIndexLine('README.md'), null);
});
test('bridge refuses targets that are not local consoles', async () => {
assert.ok(isPrivateAddress('192.168.1.20'));
assert.ok(isPrivateAddress('10.0.0.5'));
assert.ok(isPrivateAddress('172.20.1.1'));
assert.ok(!isPrivateAddress('8.8.8.8'));
assert.ok(!isPrivateAddress('172.32.0.1'));
assert.ok(!isPrivateAddress('example.com'));
const stream = await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=8.8.8.8&port=744`);
await assert.rejects(new Ps5Debug(stream).version(), /not a console on your local network/);
// A local address on a port that is not the debugger's is refused too.
const stream2 = await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=22`);
await assert.rejects(new Ps5Debug(stream2).version(), /not a console/);
});
test('bridge reports an unreachable console in plain words', async () => {
const b2 = startBridge({ port: 0, host: '127.0.0.1', demo: { host: '127.0.0.1', port: 17499, udpPort: 0, label: 'nothing here' } });
const p2 = await b2.ready;
const stream = await openWebSocketStream(`ws://127.0.0.1:${p2}/ws?host=127.0.0.1&port=17499`);
await assert.rejects(new Ps5Debug(stream).version(), /Is the ps5debug payload loaded/);
await b2.close();
});
test('original ps5debug: no capability level, so extras are not used', async () => {
const b2 = startBridge({ port: 0, host: '127.0.0.1', demo: { host: '127.0.0.1', port: TCP_LEGACY, udpPort: 0, label: 'legacy' } });
const p2 = await b2.ready;
const stream = await openWebSocketStream(`ws://127.0.0.1:${p2}/ws?host=127.0.0.1&port=${TCP_LEGACY}`);
const dbg = new Ps5Debug(stream, { timeoutMs: 400 });
assert.equal((await dbg.branding()).capability, '');
assert.equal(await dbg.supports('1.0'), false);
const procs = await dbg.processList();
assert.ok(procs.some((p) => p.name === 'eboot.bin'));
// Sending an extension anyway gets no answer; the client times out and
// closes the stream rather than hanging forever.
await assert.rejects(dbg.foregroundApp(), /stopped responding/);
assert.equal(dbg.closed, true);
await b2.close();
});
test('scan regions: overlapping map entries are merged, code is skipped', () => {
const m = (start, end, prot, name = '') => ({ name, start, end, offset: 0n, prot });
const regions = scannableRegions([
m(0x5000n, 0x9000n, 3, '(aux)'), // overlaps the next two
m(0x1000n, 0x3000n, 5, 'executable'),
m(0x4000n, 0x6000n, 3),
m(0x6000n, 0x8000n, 3),
m(0xa000n, 0xb000n, 1), // read-only
m(0xc000n, 0xd000n, 7), // writable code
m(0xe000n, 0xf000n, 3),
]);
assert.deepEqual(regions, [
{ start: 0x4000n, length: 0x5000 },
{ start: 0xe000n, length: 0x1000 },
]);
});
test('scan values are size-checked before they reach the console', async () => {
const dbg = await connect();
await dbg.auth();
const base = { pid: GAME.pid, address: GAME.heapBase, length: 0x1000, valueType: 4 };
await assert.rejects(dbg.scanStart({ ...base, compareType: 4, value: new Uint8Array(4) }, () => {}), /wrong size/);
await assert.rejects(dbg.scanStart({ ...base, compareType: 0, value: new Uint8Array(0) }, () => {}), /needs a value/);
await dbg.ping(); // nothing was sent, so the stream is intact
dbg.close();
});
test('session: a console-side error drops the connection and the session reopens it', async () => {
const url = `ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${TCP}`;
const session = await Session.connect({ host: '127.0.0.1', port: TCP }, url);
let lost = null;
session.onLost = (r) => (lost = r);
assert.equal(session.extended, true);
assert.equal(session.firmware, 1000);
assert.equal((await session.currentGame()).titleId, GAME.titleId);
const before = session.main;
await assert.rejects(session.main.maps(4242), /Reading the memory map failed/);
// Give the reopen a moment, then the same session works again on a new connection.
for (let i = 0; i < 50 && session.main === before; i++) await new Promise((r) => setTimeout(r, 20));
assert.notEqual(session.main, before);
assert.equal((await session.main.maps(GAME.pid)).length, 7);
assert.equal(lost, null);
// A scan session keeps working across the swap because it asks for the connection each pass.
const kind = valueKind('u32');
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
const scan = new ScanSession(() => session.scan, GAME.pid, kind);
await scan.first(scannableRegions(await session.scan.maps(GAME.pid)), compareKind('exact'), encodeValue(kind, '100'), () => {});
const oldScan = session.scan;
await assert.rejects(session.scan.maps(4242));
for (let i = 0; i < 50 && session.scan === oldScan; i++) await new Promise((r) => setTimeout(r, 20));
await scan.next(compareKind('unchanged'), new Uint8Array(0), () => {});
assert.equal(scan.matches, 6);
session.close();
assert.equal(lost, null);
});
test('session: gives up when the console keeps dropping the connection', async () => {
const url = `ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${TCP}`;
const session = await Session.connect({ host: '127.0.0.1', port: TCP }, url);
let lost = null;
session.onLost = (r) => (lost = r);
for (let i = 0; i < 8 && lost === null; i++) {
const cur = session.main;
await cur.maps(4242).catch(() => {});
for (let j = 0; j < 50 && session.main === cur && lost === null; j++) await new Promise((r) => setTimeout(r, 20));
}
assert.notEqual(lost, null);
});
test('session: payload that never answers BRANDING still connects, without extras', async () => {
const old = startMock({ tcpPort: 17442, udpPort: 0, tick: false, legacy: true, noBranding: true });
await old.ready;
const b2 = startBridge({ port: 0, host: '127.0.0.1', demo: { host: '127.0.0.1', port: 17442, udpPort: 0, label: 'old' } });
const p2 = await b2.ready;
const session = await Session.connect({ host: '127.0.0.1', port: 17442 }, `ws://127.0.0.1:${p2}/ws?host=127.0.0.1&port=17442`);
assert.equal(session.extended, false);
assert.equal(session.firmware, 0);
const game = await session.currentGame();
assert.equal(game.titleId, GAME.titleId);
assert.equal(game.version, '');
session.close();
await b2.close();
await old.close();
});
test('bridge serves info and the local cheats index', async () => {
const info = await (await fetch(`http://127.0.0.1:${bridgePort}/api/info`)).json();
assert.equal(info.mode, 'bridge');
assert.equal(info.demo.port, TCP);
const index = await (await fetch(`http://127.0.0.1:${bridgePort}/api/local-cheats`)).text();
assert.match(index, /PPSA99999_01\.000\.000\.json=Specter Test Game/);
const res = await fetch(`http://127.0.0.1:${bridgePort}/local-cheats/..%2Fpackage.json`);
assert.equal(res.status, 404);
});
test('Auto search finds a value whatever type it is stored as', async () => {
const { AutoScan } = await import('../src/lib/scanner.ts');
const dbg = await connect();
try {
const regions = scannableRegions(await dbg.maps(GAME.pid));
// The mock's gold is a float and its ammo a 4-byte integer; set both to the same number.
const gold = GAME.heapBase + BigInt(GAME.vars.gold);
const ammo = GAME.heapBase + BigInt(GAME.vars.ammo);
const f = new Uint8Array(4); new DataView(f.buffer).setFloat32(0, 7777, true);
const i = new Uint8Array(4); new DataView(i.buffer).setUint32(0, 7777, true);
await dbg.write(GAME.pid, gold, f);
await dbg.write(GAME.pid, ammo, i);
const scan = new AutoScan(() => dbg, GAME.pid);
scan.input('7777');
const seen = [];
await scan.first(regions, compareKind('exact'), new Uint8Array(0), (p) => seen.push(p.detail));
assert.ok(seen.includes('as float') && seen.includes('as 4 bytes'));
let hits = await scan.hits(50);
const at = (addr) => hits.find((h) => h.address === addr)?.kind.id;
assert.equal(at(gold), 'float');
assert.equal(at(ammo), 'u32');
// The integer changes, the float does not: narrowing keeps each type's own candidates.
new DataView(i.buffer).setUint32(0, 7770, true);
await dbg.write(GAME.pid, ammo, i);
scan.input('7770');
await scan.next(compareKind('exact'), new Uint8Array(0), () => {});
hits = await scan.hits(50);
assert.deepEqual(hits.map((h) => [h.address, h.kind.id]), [[ammo, 'u32']]);
// A value with a fraction cannot be a whole-number type; those are simply not searched.
const frac = new AutoScan(() => dbg, GAME.pid);
frac.input('99.5');
await frac.first(regions, compareKind('exact'), new Uint8Array(0), () => {});
assert.deepEqual(frac.kinds.map((k) => k.id).sort(), ['double', 'float']);
const none = new AutoScan(() => dbg, GAME.pid);
none.input('abc');
await assert.rejects(none.first(regions, compareKind('exact'), new Uint8Array(0), () => {}), /not a number/);
} finally {
dbg.close();
}
});