mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-18 05:45:16 +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
451 lines
21 KiB
JavaScript
451 lines
21 KiB
JavaScript
// 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();
|
||
}
|
||
});
|