mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-17 20:27:01 +02:00
1330 lines
77 KiB
JavaScript
1330 lines
77 KiB
JavaScript
// The cheat engine inside the on-console payload: the payload is built for
|
|
// this computer and pointed at the mock console, cheat files are put into a
|
|
// folder standing in for the console's disk, and the mock game's memory shows
|
|
// what the engine did. Skipped when there is no C compiler or the web app is
|
|
// not built.
|
|
|
|
import test, { after, before } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createRequire } from 'node:module';
|
|
|
|
import { startMock, GAME } from '../mock/mock-ps5.mjs';
|
|
import { demoEngineStatus, startBridge } from '../bridge/server.mjs';
|
|
import { canKeep, emptyTable, keepProblem, keptEntries, parseOwnTable, saveFileText } from '../src/lib/table.ts';
|
|
import { tableFileName } from '../src/lib/tablestore.ts';
|
|
import { VALUE_KINDS, encodeValue, valueKind } from '../src/lib/scanner.ts';
|
|
import { keptOverview, keptStatus, parseEngineStatus } from '../src/lib/keep.ts';
|
|
import { createGameContext } from '../src/lib/locations.ts';
|
|
import { Ps5Debug } from '../src/lib/ps5debug.ts';
|
|
import { Tracer } from '../src/lib/tracer.ts';
|
|
import { openWebSocketStream } from '../src/lib/transport.ts';
|
|
import { asAsync, createX86 } from '../src/lib/x86.ts';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
|
// Lets several copies of the suite run at once on one machine.
|
|
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
|
const PORT = 18944 + PORT_OFFSET;
|
|
const MOCK = 17650 + PORT_OFFSET;
|
|
const EVENTS = 17966 + PORT_OFFSET;
|
|
// The engine's waits (2 s between looks at the running game, and so on) run this many times shorter.
|
|
const SPEED = 10;
|
|
/** Crash protection's look back (60 s on a console), at that speed. */
|
|
const CRASH_WINDOW_MS = 60000 / SPEED;
|
|
|
|
let skip = false;
|
|
try {
|
|
if (!fs.existsSync(path.join(ROOT, 'dist/index.html'))) throw new Error('dist missing');
|
|
execFileSync(process.execPath, [path.join(ROOT, 'scripts/build-payload.mjs'), 'host'], { stdio: 'pipe' });
|
|
} catch {
|
|
skip = 'needs a C compiler and a built web app (npm run build)';
|
|
}
|
|
|
|
const FAKE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-engine-'));
|
|
const CHEATS = path.join(FAKE_ROOT, 'data/cheatspecter/cheats');
|
|
const OWN_FILE = tableFileName(GAME.titleId, GAME.version);
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const api = (p, init) => fetch(`http://127.0.0.1:${PORT}${p}`, init);
|
|
const status = async () => (await api('/api/engine')).json();
|
|
|
|
/** Polls GET /api/engine until `check` likes what it says. */
|
|
async function until(what, check, ms = 10000) {
|
|
const deadline = Date.now() + ms;
|
|
let last;
|
|
for (;;) {
|
|
last = await status();
|
|
if (check(last)) return last;
|
|
if (Date.now() > deadline) assert.fail(`timed out waiting for ${what}; the engine says ${JSON.stringify(last)}`);
|
|
await sleep(20);
|
|
}
|
|
}
|
|
const cheat = (st, name) => st.cheats.find((c) => c.name === name);
|
|
const stateOf = (st, name) => cheat(st, name)?.state;
|
|
/** Every listed cheat has stopped being "waiting". */
|
|
const settled = (st) => st.cheats.length > 0 && st.cheats.every((c) => c.state !== 'waiting');
|
|
|
|
/** A .cspector file as the web app writes it. */
|
|
const fileText = (cheats, game = {}) =>
|
|
JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: { name: 'Specter Run', titleId: GAME.titleId, version: GAME.version, ...game }, cheats });
|
|
const putFile = (name, text) => {
|
|
fs.mkdirSync(CHEATS, { recursive: true });
|
|
// Written beside and moved into place, as the payload itself saves files.
|
|
fs.writeFileSync(path.join(CHEATS, `${name}.tmp`), text);
|
|
fs.renameSync(path.join(CHEATS, `${name}.tmp`), path.join(CHEATS, name));
|
|
};
|
|
|
|
const at = (offset, module) => ({ kind: 'module', offset: `0x${offset.toString(16)}`, ...(module ? { module } : {}) });
|
|
const code = (name) => mock.game.image.subarray(GAME.code[name].offset, GAME.code[name].offset + GAME.code[name].bytes.length);
|
|
const hex = (buf) => Buffer.from(buf).toString('hex').toUpperCase();
|
|
const NOPS = (name) => '90'.repeat(GAME.code[name].bytes.length);
|
|
const ORIGINAL = (name) => hex(GAME.code[name].bytes);
|
|
/** A kept patch that blanks out one of the mock game's instructions. */
|
|
const nopPatch = (name, codeName, extra = {}) => ({
|
|
name,
|
|
type: 'patch',
|
|
keep: true,
|
|
at: at(GAME.code[codeName].offset),
|
|
on: NOPS(codeName),
|
|
off: ORIGINAL(codeName),
|
|
...extra,
|
|
});
|
|
/** A kept held value reached through the mock game's static pointer to its player object. */
|
|
const held = (name, playerOffset, valueType, value, extra = {}) => ({
|
|
name,
|
|
type: 'value',
|
|
keep: true,
|
|
freeze: true,
|
|
at: { kind: 'pointer', base: `0x${(0x10000 + GAME.statics.player).toString(16)}`, offsets: [`0x${playerOffset.toString(16)}`] },
|
|
valueType,
|
|
value,
|
|
...extra,
|
|
});
|
|
|
|
let mock, proc;
|
|
let stderr = '';
|
|
/** Notifications the payload has shown since `from` (an earlier stderr length), one string each. */
|
|
const notifications = (from = 0) =>
|
|
stderr
|
|
.slice(from)
|
|
.split('[cheatspecter] ')
|
|
.slice(1)
|
|
.map((s) => s.trimEnd());
|
|
|
|
const waitForPort = async (port) => {
|
|
for (let i = 0; i < 100; i++) {
|
|
const ok = await new Promise((resolve) => {
|
|
const s = net.connect(port, '127.0.0.1');
|
|
s.once('connect', () => (s.destroy(), resolve(true)));
|
|
s.once('error', () => resolve(false));
|
|
});
|
|
if (ok) return;
|
|
await sleep(30);
|
|
}
|
|
throw new Error('payload did not start');
|
|
};
|
|
|
|
const startTheMock = async () => {
|
|
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: false, eventPort: EVENTS });
|
|
await mock.ready;
|
|
};
|
|
|
|
/** Puts the mock game's memory back to how a freshly started game has it. */
|
|
function resetGame() {
|
|
const { image, heap, data, regions } = mock.game;
|
|
image.fill(0xcc);
|
|
for (const c of Object.values(GAME.code)) {
|
|
c.bytes.copy(image, c.offset);
|
|
image[c.offset + c.bytes.length] = 0xc3;
|
|
}
|
|
regions.find((r) => r.name === 'libkernel.sprx').buf.fill(0x90);
|
|
heap.fill(0);
|
|
heap.writeUInt32LE(100, GAME.vars.health);
|
|
heap.writeUInt32LE(30, GAME.vars.ammo);
|
|
heap.writeFloatLE(2500, GAME.vars.gold);
|
|
data.fill(0);
|
|
data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.player), GAME.statics.player);
|
|
data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.world), GAME.statics.world);
|
|
heap.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.player), GAME.world + 8);
|
|
}
|
|
|
|
/**
|
|
* Closes the mock game, replaces the cheat files with `files`, and starts the
|
|
* game again under a new process id, as a real launch would. Returns once the
|
|
* engine has seen the new game. `prepare` can change the fresh game's memory
|
|
* before the engine gets to see it.
|
|
*/
|
|
async function launch(files, prepare) {
|
|
mock.state.foreground = false;
|
|
await until('the game to be gone', (st) => st.running && st.game === null);
|
|
fs.rmSync(CHEATS, { recursive: true, force: true });
|
|
// Games here close seconds after their cheats go on, which crash protection
|
|
// marks. Each launch starts without those marks; test/crashguard.test.mjs covers them.
|
|
fs.rmSync(path.join(FAKE_ROOT, 'data/cheatspecter/prefs'), { recursive: true, force: true });
|
|
for (const [name, text] of Object.entries(files)) putFile(name, text);
|
|
resetGame();
|
|
prepare?.();
|
|
GAME.pid += 1;
|
|
mock.state.foreground = true;
|
|
return until('the new game to be seen', (st) => st.game?.pid === GAME.pid);
|
|
}
|
|
|
|
before(async () => {
|
|
if (skip) return;
|
|
proc = spawn(BIN, [], {
|
|
env: {
|
|
...process.env,
|
|
CS_PORT: String(PORT),
|
|
CS_DEBUGGER_PORT: String(MOCK),
|
|
CS_EVENT_PORT: String(EVENTS),
|
|
CS_ROOT: FAKE_ROOT,
|
|
CS_SHADOWMOUNT_PORT: '1',
|
|
CS_ENGINE_SPEED: String(SPEED),
|
|
CS_IDLE_SECONDS: '2',
|
|
},
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
proc.stderr.on('data', (d) => (stderr += d));
|
|
await waitForPort(PORT);
|
|
});
|
|
|
|
after(async () => {
|
|
if (proc) proc.kill();
|
|
if (mock) await mock.close();
|
|
fs.rmSync(FAKE_ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
test('says so when the debugger is not there, and picks up when it appears', { skip }, async () => {
|
|
// Nothing is listening where the debugger should be.
|
|
assert.deepEqual(await status(), { running: false, crashGuard: true, game: null, file: '', cheats: [] });
|
|
await sleep(400);
|
|
assert.deepEqual(await status(), { running: false, crashGuard: true, game: null, file: '', cheats: [] });
|
|
|
|
await startTheMock();
|
|
mock.state.foreground = false;
|
|
await until('the debugger to be found', (st) => st.running);
|
|
assert.deepEqual(await status(), { running: true, crashGuard: true, game: null, file: '', cheats: [] });
|
|
|
|
// A game with no cheat file: seen, nothing to do, nothing said.
|
|
const mark = stderr.length;
|
|
mock.state.foreground = true;
|
|
const st = await until('the game to be seen', (s) => s.game !== null);
|
|
assert.deepEqual(st, { running: true, crashGuard: true, game: { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid }, file: '', cheats: [] });
|
|
await sleep(600);
|
|
assert.deepEqual(notifications(mark), []);
|
|
});
|
|
|
|
test('switches a kept patch on when the game starts, and says why for everything else marked to keep', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
const cheats = [
|
|
nopPatch('Infinite ammo', 'ammoSpend'),
|
|
// Not marked to keep: not the engine's business, and not listed.
|
|
{ ...nopPatch('God mode', 'healthDrain'), keep: undefined },
|
|
{ ...nopPatch('Keep is not true', 'healthDrain'), keep: 'yes' },
|
|
// Kinds of entry the engine never runs, even when marked.
|
|
{ name: 'Hook', type: 'inject', keep: true, reviewed: true, at: at(GAME.code.scoreAdd.offset), code: 'nop', placement: 'replace' },
|
|
{ name: 'Script', type: 'script', keep: true, reviewed: true, code: 'write(0, 1)' },
|
|
{ name: 'Call', type: 'call', keep: true, reviewed: true, fn: at(GAME.funcs.setGold), args: ['5'] },
|
|
// A value that is set once rather than held.
|
|
{ ...held('Set once', GAME.vars.health - GAME.player, 'u32', '7'), freeze: false },
|
|
// Held back for review, and a raw address from some earlier launch.
|
|
{ ...nopPatch('Not looked at', 'goldSpend'), reviewed: false },
|
|
{ ...nopPatch('Old address', 'goldSpend'), at: { kind: 'address', address: (GAME.imageBase + 0x30a0n).toString(16) } },
|
|
{ ...nopPatch('Old address among others', 'scoreAdd'), more: [{ at: { kind: 'address', address: '0x880001234' }, on: '00' }] },
|
|
{ ...held('Held at an old address', 0, 'u32', '7'), at: { kind: 'address', address: (GAME.heapBase + BigInt(GAME.vars.health)).toString(16) } },
|
|
// What the web app would not load at all is passed over, as before.
|
|
{ name: 'Odd type', type: 'trainer', keep: true },
|
|
{ type: 'patch', keep: true, at: at(0x7000), on: '90' },
|
|
];
|
|
await launch({ [OWN_FILE]: fileText(cheats) });
|
|
const st = await until('the patch to be on', settled);
|
|
assert.deepEqual(st.cheats, [
|
|
{ name: 'Infinite ammo', state: 'on', detail: '' },
|
|
{ name: 'Hook', state: 'refused', detail: 'Only patches and held values can be kept on by the PS5.' },
|
|
{ name: 'Script', state: 'refused', detail: 'Only patches and held values can be kept on by the PS5.' },
|
|
{ name: 'Call', state: 'refused', detail: 'Only patches and held values can be kept on by the PS5.' },
|
|
{ name: 'Set once', state: 'refused', detail: 'Only a value that is held in place can be kept on. Turn on "Hold it at this value" in CheatSpecter first.' },
|
|
{ name: 'Not looked at', state: 'refused', detail: 'This cheat has not been looked at yet. Open CheatSpecter and review it first.' },
|
|
{ name: 'Old address', state: 'refused', detail: 'This cheat uses a raw memory address, which changes every time the game starts. Give it a lasting location in CheatSpecter first.' },
|
|
{ name: 'Old address among others', state: 'refused', detail: 'This cheat uses a raw memory address, which changes every time the game starts. Give it a lasting location in CheatSpecter first.' },
|
|
{ name: 'Held at an old address', state: 'refused', detail: 'This value uses a raw memory address, which changes every time the game starts. Give it a pointer path in CheatSpecter first.' },
|
|
]);
|
|
assert.equal(st.file, OWN_FILE);
|
|
assert.equal(st.exact, true);
|
|
|
|
// The engine and the web app turn down the same cheats: what one refuses, the other does not let be kept.
|
|
for (const e of cheats.filter((c) => c.keep === true && typeof c.name === 'string' && c.type !== 'trainer')) {
|
|
const refused = stateOf(st, e.name) === 'refused';
|
|
const { reviewed, ...entry } = e;
|
|
assert.equal(canKeep({ ...entry, id: 'x', unreviewed: reviewed === false ? true : undefined }), !refused, e.name);
|
|
}
|
|
// A refused cheat is never mistaken for a kept one of the same name, and it is named in the summary.
|
|
const parsed = parseEngineStatus(st);
|
|
assert.equal(keptStatus(parsed, GAME.titleId, 'Infinite ammo').state, 'on');
|
|
assert.equal(keptStatus(parsed, GAME.titleId, 'Hook'), null);
|
|
const overview = keptOverview(parsed, GAME, 1);
|
|
assert.equal(overview.kind, 'warn');
|
|
assert.match(overview.text, /keeping 1 cheat on by itself/);
|
|
assert.equal(overview.lines.length, 8);
|
|
assert.match(overview.lines[0], /^"Hook" is marked to keep on but cannot be: Only patches/);
|
|
// The stand-in the demo console uses lists the same cheats for the same file.
|
|
const stand = demoEngineStatus({ titleId: GAME.titleId, version: GAME.version, pid: GAME.pid }, new Map([[OWN_FILE.replace('.cspector', ''), Buffer.from(fileText(cheats))]]));
|
|
assert.deepEqual(stand.cheats.map((c) => [c.name, c.state === 'refused' ? c.detail : '']), st.cheats.map((c) => [c.name, c.state === 'refused' ? c.detail : '']));
|
|
// Except "crashGuard": the stand-in marks nothing, so the page does that itself there.
|
|
const { crashGuard, ...shape } = st;
|
|
assert.equal(crashGuard, true);
|
|
assert.deepEqual({ ...stand, cheats: [] }, { ...shape, cheats: [] });
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
for (const untouched of ['healthDrain', 'goldSpend', 'scoreAdd']) assert.equal(hex(code(untouched)), ORIGINAL(untouched), untouched);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
|
// The byte after the patch is not part of it.
|
|
assert.equal(mock.game.image[GAME.code.ammoSpend.offset + 3], 0xc3);
|
|
|
|
// The game "runs": ammo no longer goes down, health still does.
|
|
mock.tick();
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.ammo), 30);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 99);
|
|
|
|
await sleep(500);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run\n8 cheats could not be switched on']);
|
|
});
|
|
|
|
test('a patch with several places goes on as a whole or not at all', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
const place = (codeName, off = ORIGINAL(codeName)) => ({ at: at(GAME.code[codeName].offset), on: NOPS(codeName), off });
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
{ ...nopPatch('Rich and deadly', 'goldSpend'), more: [place('scoreAdd')] },
|
|
// Its first place fits this game, its second does not: neither may be written.
|
|
{ ...nopPatch('Half right', 'healthDrain'), more: [place('ammoSpend', 'FF4B29')] },
|
|
// Longer "off" than "on" can never be confirmed.
|
|
{ name: 'Lopsided', type: 'patch', keep: true, at: at(GAME.code.ammoSpend.offset), on: '90', off: 'FF4B28' },
|
|
]),
|
|
});
|
|
const st = await until('both to settle', settled);
|
|
assert.equal(stateOf(st, 'Rich and deadly'), 'on');
|
|
assert.equal(hex(code('goldSpend')), NOPS('goldSpend'));
|
|
assert.equal(hex(code('scoreAdd')), NOPS('scoreAdd'));
|
|
assert.deepEqual(cheat(st, 'Half right'), { name: 'Half right', state: 'mismatch', detail: "The game's code is different here, so it was left alone" });
|
|
assert.equal(stateOf(st, 'Lopsided'), 'mismatch');
|
|
assert.equal(hex(code('healthDrain')), ORIGINAL('healthDrain'));
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run\n2 cheats do not match this version of the game']);
|
|
});
|
|
|
|
test('a patch that is already in the game is reported on and left as it is', { skip }, async () => {
|
|
mock.state.foreground = false;
|
|
await until('the game to be gone', (st) => st.game === null);
|
|
fs.rmSync(CHEATS, { recursive: true, force: true });
|
|
putFile(OWN_FILE, fileText([nopPatch('Infinite ammo', 'ammoSpend')]));
|
|
resetGame();
|
|
code('ammoSpend').fill(0x90);
|
|
GAME.pid += 1;
|
|
mock.state.foreground = true;
|
|
await until('the patch to be on', (st) => stateOf(st, 'Infinite ammo') === 'on');
|
|
// The engine did not put it there, so taking "keep" away does not take it out.
|
|
putFile(OWN_FILE, fileText([{ ...nopPatch('Infinite ammo', 'ammoSpend'), keep: false }]));
|
|
await until('the cheat to be dropped', (st) => st.cheats.length === 0);
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
});
|
|
|
|
test('finds a patch by its byte pattern, in the main executable or a named module', { skip }, async () => {
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
// "dec dword ptr [rbx+??]; ret", three bytes in from a wildcard start.
|
|
{ name: 'By pattern', type: 'patch', keep: true, at: { kind: 'signature', pattern: 'FF 4B ?? C3', offset: 0 }, on: '909090' },
|
|
{ name: 'By pattern, further on', type: 'patch', keep: true, at: { kind: 'signature', pattern: '89 81 90 03 ?? ??', offset: 6 }, on: '90', off: 'C3' },
|
|
{ name: 'Same pattern again', type: 'patch', keep: true, at: { kind: 'signature', pattern: '89 81 90 03 ?? ??', offset: -1 }, on: '90', off: 'CC' },
|
|
{ name: 'Matches twice', type: 'patch', keep: true, at: { kind: 'signature', pattern: 'CC CC CC CC', offset: 0 }, on: '90' },
|
|
{ name: 'In a library', type: 'patch', keep: true, at: at(0x40, 'libkernel.sprx'), on: 'C3C3', off: '9090' },
|
|
{ name: 'Library not loaded', type: 'patch', keep: true, at: at(0x40, 'libNotThere.sprx'), on: 'C3', off: '90' },
|
|
]),
|
|
});
|
|
const st = await until('the findable ones to be on', (s) => ['By pattern', 'By pattern, further on', 'Same pattern again', 'In a library'].every((n) => stateOf(s, n) === 'on') && stateOf(s, 'Matches twice') === 'error');
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
assert.equal(mock.game.image[GAME.code.healthDrain.offset + 6], 0x90);
|
|
assert.equal(mock.game.image[GAME.code.healthDrain.offset - 1], 0x90);
|
|
assert.equal(hex(code('healthDrain')), ORIGINAL('healthDrain'));
|
|
assert.match(cheat(st, 'Matches twice').detail, /more than one place/);
|
|
const lib = mock.game.regions.find((r) => r.name === 'libkernel.sprx').buf;
|
|
assert.equal(hex(lib.subarray(0x3f, 0x43)), '90C3C390');
|
|
assert.deepEqual(cheat(st, 'Library not loaded'), { name: 'Library not loaded', state: 'waiting', detail: 'Waiting for the game to load libNotThere.sprx' });
|
|
});
|
|
|
|
test('keeps looking for a pattern for a while, then gives up', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
{ name: 'Loads late', type: 'patch', keep: true, at: { kind: 'signature', pattern: 'DE AD BE EF 01', offset: 4 }, on: '02', off: '01' },
|
|
{ name: 'Never there', type: 'patch', keep: true, at: { kind: 'signature', pattern: 'DE AD BE EF 77', offset: 0 }, on: '90' },
|
|
]),
|
|
});
|
|
let st = await until('the first search to have run', (s) => /Still looking/.test(cheat(s, 'Loads late')?.detail ?? ''));
|
|
assert.equal(stateOf(st, 'Loads late'), 'waiting');
|
|
// The game loads more code a moment later.
|
|
Buffer.from('DEADBEEF01', 'hex').copy(mock.game.image, 0x5000);
|
|
st = await until('the late code to be patched', (s) => stateOf(s, 'Loads late') === 'on');
|
|
assert.equal(mock.game.image[0x5004], 0x02);
|
|
st = await until('the other search to be given up', (s) => stateOf(s, 'Never there') === 'error', 15000);
|
|
assert.match(cheat(st, 'Never there').detail, /was not found in this game/);
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run\n1 cheat could not be switched on']);
|
|
});
|
|
|
|
test('holds a value where a pointer path leads, and writes it again when the game changes it', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
const twoStep = { kind: 'pointer', base: `0x${(0x10000 + GAME.statics.world).toString(16)}`, offsets: ['0x8', `0x${(GAME.vars.ammo - GAME.player).toString(16)}`] };
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
held('Health', GAME.vars.health - GAME.player, 'u32', '999'),
|
|
{ ...held('Ammo', 0, 'u32', '0x4D'), at: twoStep },
|
|
held('Gold', GAME.vars.gold - GAME.player, 'float', '1234.5'),
|
|
]),
|
|
});
|
|
await until('all three to be held', (st) => st.cheats.length === 3 && st.cheats.every((c) => c.state === 'on'));
|
|
const { heap } = mock.game;
|
|
const values = () => [heap.readUInt32LE(GAME.vars.health), heap.readUInt32LE(GAME.vars.ammo), heap.readFloatLE(GAME.vars.gold)];
|
|
assert.deepEqual(values(), [999, 77, 1234.5]);
|
|
|
|
for (let round = 0; round < 3; round++) {
|
|
// The game takes damage, fires and spends.
|
|
mock.tick();
|
|
heap.writeUInt32LE(5, GAME.vars.health);
|
|
assert.notDeepEqual(values(), [999, 77, 1234.5]);
|
|
for (let i = 0; i < 100 && values().join() !== '999,77,1234.5'; i++) await sleep(10);
|
|
assert.deepEqual(values(), [999, 77, 1234.5]);
|
|
}
|
|
// Only the value itself is written: its neighbors are untouched.
|
|
assert.equal(heap.readUInt32LE(GAME.vars.health - 4), 0);
|
|
assert.equal(heap.readUInt32LE(GAME.vars.ammo + 4), 0);
|
|
|
|
// The player object goes away (a level is loading): nothing is written anywhere until it is back.
|
|
mock.game.data.writeBigUInt64LE(0n, GAME.statics.player);
|
|
let st = await until('the path to lead nowhere', (s) => stateOf(s, 'Health') === 'waiting');
|
|
assert.equal(cheat(st, 'Health').detail, 'The pointer path leads nowhere yet');
|
|
assert.equal(stateOf(st, 'Ammo'), 'on');
|
|
heap.writeUInt32LE(42, GAME.vars.health);
|
|
await sleep(300);
|
|
assert.equal(heap.readUInt32LE(GAME.vars.health), 42);
|
|
// It comes back somewhere else.
|
|
const moved = GAME.player + 0x4000;
|
|
mock.game.data.writeBigUInt64LE(GAME.heapBase + BigInt(moved), GAME.statics.player);
|
|
st = await until('the value to be held again', (s) => stateOf(s, 'Health') === 'on');
|
|
assert.equal(heap.readUInt32LE(moved + GAME.vars.health - GAME.player), 999);
|
|
assert.equal(heap.readUInt32LE(GAME.vars.health), 42);
|
|
|
|
// Changing the number (or the name) in the file changes what is held, and is not news.
|
|
const ammo = { ...held('Ammo', 0, 'u32', '0x4D'), at: twoStep };
|
|
const gold = held('Gold', GAME.vars.gold - GAME.player, 'float', '1234.5');
|
|
putFile(OWN_FILE, fileText([held('Lots of health', GAME.vars.health - GAME.player, 'u32', '250'), ammo, gold]));
|
|
await until('the renamed cheat to show', (s) => stateOf(s, 'Lots of health') === 'on');
|
|
for (let i = 0; i < 100 && heap.readUInt32LE(moved + GAME.vars.health - GAME.player) !== 250; i++) await sleep(10);
|
|
assert.equal(heap.readUInt32LE(moved + GAME.vars.health - GAME.player), 250);
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 3 cheats on for Specter Run']);
|
|
|
|
// Taking two of them out of the file stops them, and that is worth one notification.
|
|
const before = stderr.length;
|
|
putFile(OWN_FILE, fileText([held('Lots of health', GAME.vars.health - GAME.player, 'u32', '250')]));
|
|
await until('the others to be dropped', (s) => s.cheats.length === 1);
|
|
// Values that are no longer kept are no longer written.
|
|
heap.writeUInt32LE(3, GAME.vars.ammo);
|
|
heap.writeFloatLE(1, GAME.vars.gold);
|
|
await sleep(300);
|
|
assert.equal(heap.readUInt32LE(GAME.vars.ammo), 3);
|
|
assert.equal(heap.readFloatLE(GAME.vars.gold), 1);
|
|
assert.deepEqual(notifications(before), ['CheatSpecter: 1 cheat on for Specter Run']);
|
|
});
|
|
|
|
test('encodes held values exactly as the web app does', { skip }, async () => {
|
|
const cases = [
|
|
['u8', '255'], ['u8', '0x7f'], ['i8', '-128'], ['u16', '65535'], ['i16', '-2'], ['u32', '4294967295'], ['i32', '-2147483648'],
|
|
['u64', '18446744073709551615'], ['i64', '-9223372036854775808'], ['u64', '0xDEADBEEFCAFEF00D'], ['i32', ' 12 '],
|
|
['float', '0.1'], ['float', '-3.75e2'], ['float', '1e39'], ['double', '3.141592653589793'], ['double', '-0'], ['float', '.5'], ['double', '0x10'],
|
|
];
|
|
// Things the web app refuses to encode; the engine must refuse them too rather than write something.
|
|
const refused = [
|
|
['u8', '256'], ['u8', '-1'], ['i8', '128'], ['u32', '1.5'], ['u32', 'abc'], ['u32', ''], ['i32', '-0x5'], ['u64', '18446744073709551616'],
|
|
['float', 'Infinity'], ['float', 'NaN'], ['double', '1e999'], ['float', '1,5'], ['u128', '1'], ['u32', '12 13'],
|
|
];
|
|
for (const [type, text] of refused) {
|
|
assert.throws(() => encodeValue(valueKind(type), text), `${type} ${text} should be refused by the web app too`);
|
|
}
|
|
const slot = (i) => 0x1000 + i * 16;
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
...cases.map(([type, text], i) => held(`ok ${i}`, slot(i), type, text)),
|
|
...refused.map(([type, text], i) => held(`bad ${i}`, slot(cases.length + i), type, text)),
|
|
{ ...held('number, not text', slot(60), 'u32', '5'), value: 5 },
|
|
]),
|
|
});
|
|
const st = await until('them all to settle', settled);
|
|
const { heap } = mock.game;
|
|
cases.forEach(([type, text], i) => {
|
|
const want = encodeValue(valueKind(type), text);
|
|
assert.equal(stateOf(st, `ok ${i}`), 'on', `${type} ${text}`);
|
|
assert.equal(hex(heap.subarray(GAME.player + slot(i), GAME.player + slot(i) + 16)), hex(want).padEnd(32, '0'), `${type} ${text}`);
|
|
});
|
|
refused.forEach(([type, text], i) => {
|
|
assert.equal(stateOf(st, `bad ${i}`), 'error', `${type} ${text}`);
|
|
assert.equal(hex(heap.subarray(GAME.player + slot(cases.length + i), GAME.player + slot(cases.length + i) + 16)), '0'.repeat(32), `${type} ${text}`);
|
|
});
|
|
assert.equal(stateOf(st, 'number, not text'), 'error');
|
|
// Every type the web app offers is one the engine knows.
|
|
assert.deepEqual([...new Set(cases.map((c) => c[0]))].sort(), VALUE_KINDS.map((k) => k.id).sort());
|
|
});
|
|
|
|
test('taking "keep" away puts the original bytes back', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([nopPatch('Infinite ammo', 'ammoSpend'), nopPatch('God mode', 'healthDrain'), nopPatch('Score', 'scoreAdd', { off: undefined })]) });
|
|
await until('all to be on', (st) => st.cheats.length === 3 && st.cheats.every((c) => c.state === 'on'));
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
assert.equal(hex(code('scoreAdd')), NOPS('scoreAdd'));
|
|
|
|
// One loses its flag, one loses its entry; the third is renamed and stays.
|
|
const mark = stderr.length;
|
|
putFile(OWN_FILE, fileText([{ ...nopPatch('Infinite ammo', 'ammoSpend'), keep: false }, nopPatch('Invincible', 'healthDrain')]));
|
|
const st = await until('the list to shrink', (s) => s.cheats.length === 1);
|
|
assert.deepEqual(st.cheats, [{ name: 'Invincible', state: 'on', detail: '' }]);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
// A patch with no "off" in the file is undone from what the engine saw there before writing.
|
|
assert.equal(hex(code('scoreAdd')), ORIGINAL('scoreAdd'));
|
|
assert.equal(hex(code('healthDrain')), NOPS('healthDrain'));
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run']);
|
|
|
|
// If the user has since switched it off (or changed it) in the app, the engine does not overwrite that.
|
|
code('healthDrain').fill(0xcc);
|
|
const mark2 = stderr.length;
|
|
putFile(OWN_FILE, fileText([]));
|
|
await until('the list to empty', (s) => s.cheats.length === 0);
|
|
assert.equal(hex(code('healthDrain')), 'CC'.repeat(6));
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark2), ['CheatSpecter: no cheats are kept on for Specter Run any more']);
|
|
|
|
// Deleting the file altogether is the same as un-keeping everything in it.
|
|
putFile(OWN_FILE, fileText([nopPatch('Infinite ammo', 'ammoSpend')]));
|
|
await until('it to be on again', (s) => stateOf(s, 'Infinite ammo') === 'on');
|
|
fs.rmSync(path.join(CHEATS, OWN_FILE));
|
|
const gone = await until('the file to be noticed missing', (s) => s.file === '');
|
|
assert.deepEqual(gone.cheats, []);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
});
|
|
|
|
test('reloads when asked to and when the app saves a file', { skip }, async () => {
|
|
await launch({});
|
|
assert.deepEqual((await status()).cheats, []);
|
|
|
|
// The app saves through the payload's own file API.
|
|
const table = emptyTable(GAME.titleId, GAME.version, 'Specter Run');
|
|
table.entries.push({ id: 'a', name: 'Infinite ammo', type: 'patch', at: at(GAME.code.ammoSpend.offset), on: '909090', off: 'FF4B28', keep: true });
|
|
table.entries.push({ id: 'b', name: 'Not kept', type: 'patch', at: at(GAME.code.healthDrain.offset), on: NOPS('healthDrain'), off: ORIGINAL('healthDrain') });
|
|
const saved = await api(`/api/tables/${GAME.titleId}/${GAME.version}`, { method: 'PUT', body: saveFileText(table) });
|
|
assert.equal(saved.status, 204);
|
|
let st = await until('the saved cheat to be on', (s) => stateOf(s, 'Infinite ammo') === 'on');
|
|
assert.equal(st.file, OWN_FILE);
|
|
assert.equal(st.cheats.length, 1);
|
|
assert.equal(hex(code('healthDrain')), ORIGINAL('healthDrain'));
|
|
|
|
// Saving again with the flag gone undoes it.
|
|
delete table.entries[0].keep;
|
|
await api(`/api/tables/${GAME.titleId}/${GAME.version}`, { method: 'PUT', body: saveFileText(table) });
|
|
await until('the cheat to be dropped', (s) => s.cheats.length === 0);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
|
|
// A file changed some other way, then the reload endpoint.
|
|
putFile(OWN_FILE, fileText([nopPatch('God mode', 'healthDrain')]));
|
|
const reload = await api('/api/engine/reload', { method: 'POST' });
|
|
assert.equal(reload.status, 204);
|
|
assert.equal(await reload.text(), '');
|
|
st = await until('the reloaded cheat to be on', (s) => stateOf(s, 'God mode') === 'on');
|
|
assert.equal(hex(code('healthDrain')), NOPS('healthDrain'));
|
|
|
|
// Reloading with nothing changed does nothing and says nothing.
|
|
const mark = stderr.length;
|
|
for (let i = 0; i < 3; i++) assert.equal((await api('/api/engine/reload', { method: 'POST' })).status, 204);
|
|
await sleep(500);
|
|
assert.deepEqual((await status()).cheats, [{ name: 'God mode', state: 'on', detail: '' }]);
|
|
assert.deepEqual(notifications(mark), []);
|
|
|
|
// Only the app this payload served may ask, and only by POST.
|
|
const foreign = await api('/api/engine/reload', { method: 'POST', headers: { origin: 'http://evil.example' } });
|
|
assert.equal(foreign.status, 403);
|
|
assert.equal((await api('/api/engine/reload', { method: 'POST', headers: { origin: `http://127.0.0.1:${PORT}` } })).status, 204);
|
|
assert.equal((await api('/api/engine/reload')).status, 404);
|
|
assert.equal((await api('/api/engine', { method: 'POST' })).status, 405);
|
|
assert.equal((await api('/api/engine')).headers.get('content-type'), 'application/json; charset=utf-8');
|
|
});
|
|
|
|
test('with no file for this version, uses the newest one for the game, carefully', { skip }, async () => {
|
|
const older = (version, cheats) => fileText(cheats, { version });
|
|
const mark = stderr.length;
|
|
let st = await launch({
|
|
[tableFileName(GAME.titleId, '00.900.000')]: older('00.900.000', [nopPatch('Wrong file', 'healthDrain')]),
|
|
// Versions order as numbers: 1000 is after 900.
|
|
[tableFileName(GAME.titleId, '00.1000.000')]: older('00.1000.000', [
|
|
nopPatch('Checked', 'ammoSpend'),
|
|
{ name: 'By pattern', type: 'patch', keep: true, at: { kind: 'signature', pattern: 'F3 0F 5C C1 C3', offset: 0 }, on: '90909090' },
|
|
// Nothing to compare against in a game this file was not made for: left alone.
|
|
nopPatch('Unchecked', 'scoreAdd', { off: undefined }),
|
|
held('Health', GAME.vars.health - GAME.player, 'u32', '999'),
|
|
]),
|
|
// Another game's file, and things that only look like this game's.
|
|
[tableFileName('PPSA11111', '09.000.000')]: older('09.000.000', [nopPatch('Other game', 'healthDrain')]),
|
|
[`${GAME.titleId}-99.000.000.cspector.bak`]: older('99.000.000', [nopPatch('Backup', 'healthDrain')]),
|
|
[`${GAME.titleId}-99..cspector.txt`]: 'x',
|
|
});
|
|
st = await until('them to settle', settled);
|
|
assert.equal(st.file, `${GAME.titleId}-00.1000.000.cspector`);
|
|
assert.deepEqual(st.cheats.map((c) => [c.name, c.state]), [['Checked', 'on'], ['By pattern', 'on'], ['Unchecked', 'mismatch'], ['Health', 'mismatch']]);
|
|
assert.match(cheat(st, 'Unchecked').detail, /another version of the game/);
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
assert.equal(hex(code('goldSpend')), '90909090');
|
|
assert.equal(hex(code('scoreAdd')), ORIGINAL('scoreAdd'));
|
|
assert.equal(hex(code('healthDrain')), ORIGINAL('healthDrain'));
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 2 cheats on for Specter Run\n2 cheats do not match this version of the game']);
|
|
|
|
// Saved again while this version runs: now it is this version's own file, and everything in it is used.
|
|
putFile(OWN_FILE, fileText([nopPatch('Checked', 'ammoSpend'), nopPatch('Unchecked', 'scoreAdd', { off: undefined }), held('Health', GAME.vars.health - GAME.player, 'u32', '999')]));
|
|
st = await until('the exact file to be in use', (s) => s.file === OWN_FILE && settled(s) && s.cheats.length === 3);
|
|
assert.deepEqual(st.cheats.map((c) => c.state), ['on', 'on', 'on']);
|
|
assert.equal(hex(code('scoreAdd')), NOPS('scoreAdd'));
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 999);
|
|
// The pattern cheat is not in the new file, so it was undone.
|
|
assert.equal(hex(code('goldSpend')), ORIGINAL('goldSpend'));
|
|
});
|
|
|
|
test('code that is not there yet is waited for, then treated as a mismatch', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
// 0x18000 is past the end of the executable: the debugger reads zeros there, as it does for pages not loaded yet.
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
{ name: 'Out there', type: 'patch', keep: true, at: at(0x18000), on: '9090', off: '7405' },
|
|
// With nothing to compare, the write is simply attempted, and the console refuses it.
|
|
{ name: 'Unwritable', type: 'patch', keep: true, at: at(0x18100), on: '9090' },
|
|
{ name: 'Held nowhere', type: 'value', keep: true, freeze: true, at: at(0x18200), valueType: 'u32', value: '1' },
|
|
{ name: 'Way off', type: 'patch', keep: true, at: { kind: 'module', offset: '0x7FFFFFFFFFFF0000' }, on: '90', off: '00' },
|
|
nopPatch('Infinite ammo', 'ammoSpend'),
|
|
]),
|
|
});
|
|
let st = await until('the readable one to be on', (s) => stateOf(s, 'Infinite ammo') === 'on');
|
|
assert.deepEqual(cheat(st, 'Out there'), { name: 'Out there', state: 'waiting', detail: 'That part of the game is not loaded yet' });
|
|
st = await until('the wait to run out', settled, 15000);
|
|
assert.equal(stateOf(st, 'Out there'), 'mismatch');
|
|
assert.deepEqual(cheat(st, 'Unwritable'), { name: 'Unwritable', state: 'error', detail: "The console could not change that part of the game's memory" });
|
|
assert.deepEqual(cheat(st, 'Way off'), { name: 'Way off', state: 'error', detail: "This cheat points outside the game's memory" });
|
|
await sleep(300);
|
|
assert.deepEqual(cheat(st, 'Held nowhere'), { name: 'Held nowhere', state: 'error', detail: "The console could not change that part of the game's memory" });
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run\n1 cheat does not match this version of the game\n3 cheats could not be switched on']);
|
|
});
|
|
|
|
test('tells the user once, then once more for cheats that only became possible later', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
mock.state.foreground = false;
|
|
await until('the game to be gone', (st) => st.game === null);
|
|
fs.rmSync(CHEATS, { recursive: true, force: true });
|
|
fs.rmSync(path.join(FAKE_ROOT, 'data/cheatspecter/prefs'), { recursive: true, force: true });
|
|
putFile(OWN_FILE, fileText([nopPatch('Infinite ammo', 'ammoSpend'), held('Health', GAME.vars.health - GAME.player, 'u32', '999')]));
|
|
resetGame();
|
|
// At the title screen there is no player yet.
|
|
mock.game.data.writeBigUInt64LE(0n, GAME.statics.player);
|
|
GAME.pid += 1;
|
|
mock.state.foreground = true;
|
|
await until('the patch to be on', (st) => stateOf(st, 'Infinite ammo') === 'on');
|
|
// Nothing is said while a cheat may still come good...
|
|
await sleep(1000);
|
|
assert.deepEqual(notifications(mark), []);
|
|
// ...until the wait runs out (30 s on a console).
|
|
for (let i = 0; i < 400 && notifications(mark).length === 0; i++) await sleep(20);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run']);
|
|
assert.equal(stateOf(await status(), 'Health'), 'waiting');
|
|
|
|
// The player starts a level.
|
|
mock.game.data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.player), GAME.statics.player);
|
|
await until('the value to be held', (st) => stateOf(st, 'Health') === 'on');
|
|
for (let i = 0; i < 100 && notifications(mark).length === 1; i++) await sleep(20);
|
|
// And after that, silence, whatever the pointer does.
|
|
mock.game.data.writeBigUInt64LE(0n, GAME.statics.player);
|
|
await until('the path to break', (st) => stateOf(st, 'Health') === 'waiting');
|
|
mock.game.data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.player), GAME.statics.player);
|
|
await until('the value to be held again', (st) => stateOf(st, 'Health') === 'on');
|
|
await sleep(500);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run', 'CheatSpecter: 2 cheats on for Specter Run']);
|
|
});
|
|
|
|
test('a new launch of the game gets its cheats again; a closed game gets nothing', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([nopPatch('Infinite ammo', 'ammoSpend'), held('Health', GAME.vars.health - GAME.player, 'u32', '999')]) });
|
|
await until('both to be on', (st) => st.cheats.length === 2 && st.cheats.every((c) => c.state === 'on'));
|
|
// Played for longer than crash protection looks back (a minute on a console), so closing marks nothing.
|
|
await sleep(CRASH_WINDOW_MS + 500);
|
|
|
|
// The game is closed. Its memory is gone; nothing may be written to whatever has that address next.
|
|
const mark = stderr.length;
|
|
mock.state.foreground = false;
|
|
await until('the game to be gone', (st) => st.game === null);
|
|
assert.deepEqual(await status(), { running: true, crashGuard: true, game: null, file: '', cheats: [] });
|
|
resetGame();
|
|
await sleep(400);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
|
|
// Started again: a new process, the same file.
|
|
GAME.pid += 1;
|
|
mock.state.foreground = true;
|
|
const st = await until('both to be on again', (s) => s.game?.pid === GAME.pid && s.cheats.length === 2 && s.cheats.every((c) => c.state === 'on'));
|
|
assert.equal(st.game.pid, GAME.pid);
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 999);
|
|
await sleep(300);
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 2 cheats on for Specter Run']);
|
|
|
|
// The process id changes without the game ever being seen closed (a restart, after a while).
|
|
await sleep(CRASH_WINDOW_MS + 500);
|
|
resetGame();
|
|
GAME.pid += 1;
|
|
await until('the restart to be noticed', (s) => s.game?.pid === GAME.pid && s.cheats.length === 2 && s.cheats.every((c) => c.state === 'on'));
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
});
|
|
|
|
test('carries on when the debugger goes away and comes back', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([nopPatch('Infinite ammo', 'ammoSpend'), held('Health', GAME.vars.health - GAME.player, 'u32', '999')]) });
|
|
await until('both to be on', (st) => st.cheats.length === 2 && st.cheats.every((c) => c.state === 'on'));
|
|
const mark = stderr.length;
|
|
|
|
const keptMemory = mock.game;
|
|
await mock.close();
|
|
await until('the engine to notice', (st) => !st.running);
|
|
assert.deepEqual(await status(), { running: false, crashGuard: true, game: null, file: '', cheats: [] });
|
|
// The rest of the payload is unaffected.
|
|
assert.equal((await api('/api/info')).status, 200);
|
|
await sleep(500);
|
|
|
|
// The debugger is loaded again; the game never stopped. The engine still knows what it replaced.
|
|
await startTheMock();
|
|
for (const [i, r] of keptMemory.regions.entries()) r.buf.copy(mock.game.regions[i].buf);
|
|
const st = await until('both to be on again', (s) => s.running && s.cheats.length === 2 && s.cheats.every((c) => c.state === 'on'));
|
|
assert.equal(st.game.pid, GAME.pid);
|
|
mock.game.heap.writeUInt32LE(1, GAME.vars.health);
|
|
for (let i = 0; i < 100 && mock.game.heap.readUInt32LE(GAME.vars.health) !== 999; i++) await sleep(10);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 999);
|
|
putFile(OWN_FILE, fileText([]));
|
|
await until('the cheats to be dropped', (s) => s.cheats.length === 0);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: no cheats are kept on for Specter Run any more']);
|
|
});
|
|
|
|
test('a debugger that drops the connection mid-conversation is reconnected to', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([nopPatch('Infinite ammo', 'ammoSpend')]) });
|
|
await until('it to be on', (st) => stateOf(st, 'Infinite ammo') === 'on');
|
|
for (let round = 0; round < 3; round++) {
|
|
mock.dropConnections();
|
|
await sleep(150);
|
|
const st = await until('the engine to be back', (s) => s.running && s.game !== null);
|
|
assert.equal(stateOf(st, 'Infinite ammo'), 'on');
|
|
}
|
|
// Still the same launch as far as the engine is concerned: it can undo what it did.
|
|
putFile(OWN_FILE, fileText([]));
|
|
await until('the cheat to be dropped', (s) => s.cheats.length === 0);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
});
|
|
|
|
test('damaged and hostile files do no harm', { skip }, async () => {
|
|
const good = nopPatch('Infinite ammo', 'ammoSpend');
|
|
await launch({ [OWN_FILE]: fileText([good]) });
|
|
await until('it to be on', (st) => stateOf(st, 'Infinite ammo') === 'on');
|
|
const mark = stderr.length;
|
|
|
|
const deep = (n) => '['.repeat(n) + ']'.repeat(n);
|
|
const unusable = {
|
|
empty: '',
|
|
'cut short': fileText([good]).slice(0, -20),
|
|
'not an object': '[1,2,3]',
|
|
'a bare word': 'cheatspecter',
|
|
'another format': JSON.stringify({ format: 'goldhen', cheats: [good] }),
|
|
'no format': JSON.stringify({ cheats: [good] }),
|
|
'from the future': JSON.stringify({ format: 'cheatspecter', formatVersion: 2, cheats: [good] }),
|
|
'no cheats': JSON.stringify({ format: 'cheatspecter', formatVersion: 1 }),
|
|
'cheats not a list': JSON.stringify({ format: 'cheatspecter', cheats: { 0: good } }),
|
|
'trailing comma': fileText([good]).replace(']}', ',]}'),
|
|
'two documents': fileText([good]) + fileText([good]),
|
|
'single quotes': fileText([good]).replace(/"/g, "'"),
|
|
'a comment': '// mine\n' + fileText([good]),
|
|
'bad escape': fileText([{ ...good, name: 'A' }]).replace('"A"', '"\\x41"'),
|
|
'raw newline in a string': fileText([{ ...good, name: 'A' }]).replace('"A"', '"A\nB"'),
|
|
'bad number': fileText([good]).replace('"formatVersion":1', '"formatVersion":01'),
|
|
'unclosed string': '{"format":"cheatspecter","cheats":[{"name":"x',
|
|
'only brackets': '['.repeat(200000),
|
|
'nested too deep to walk': JSON.stringify({ format: 'cheatspecter', cheats: [good], extra: JSON.parse(deep(64)) }),
|
|
'binary junk': Buffer.from(Array.from({ length: 4096 }, (_, i) => (i * 131 + 7) & 0xff)),
|
|
'all zero bytes': Buffer.alloc(1000),
|
|
'too big': Buffer.concat([Buffer.from('{"format":"cheatspecter","pad":"'), Buffer.alloc(5 << 20, 0x61), Buffer.from('","cheats":[]}')]),
|
|
};
|
|
for (const [what, text] of Object.entries(unusable)) {
|
|
putFile(OWN_FILE, text);
|
|
assert.equal((await api('/api/engine/reload', { method: 'POST' })).status, 204);
|
|
const st = await until(`"${what}" to be turned down`, (s) => typeof s.error === 'string');
|
|
assert.match(st.error, /cheat file/, what);
|
|
// What was on stays on: a file that cannot be read is not an instruction to undo anything.
|
|
assert.deepEqual(st.cheats, [{ name: 'Infinite ammo', state: 'on', detail: '' }], what);
|
|
assert.equal(hex(code('ammoSpend')), '909090', what);
|
|
// A good file clears the complaint.
|
|
putFile(OWN_FILE, fileText([good]));
|
|
await until(`the good file to be accepted after "${what}"`, (s) => s.error === undefined && s.file === OWN_FILE);
|
|
}
|
|
assert.equal(proc.exitCode, null);
|
|
// Said once for the launch, not once per bad file.
|
|
assert.equal(notifications(mark).length, 1);
|
|
assert.match(notifications(mark)[0], /^CheatSpecter could not read the cheat file for this game\./);
|
|
|
|
// Files that are valid JSON with entries that make no sense: the bad entries are reported, the rest still work.
|
|
const p = (name, extra) => ({ name, type: 'patch', keep: true, at: at(0x3000), on: '90', off: 'CC', ...extra });
|
|
const odd = [
|
|
null, 7, 'patch', [], [good], {}, { name: 5, type: 'patch', keep: true }, { name: 'No type', keep: true }, { name: 'Odd type', type: 'trainer', keep: true },
|
|
p('odd hex', { on: '909' }),
|
|
p('no bytes', { on: '' }),
|
|
p('missing on', { on: undefined }),
|
|
p('on is a number', { on: 90 }),
|
|
p('huge', { on: '90'.repeat(5000) }),
|
|
p('enormous', { on: '90 '.repeat(200000) }),
|
|
p('no place', { at: undefined }),
|
|
p('place is a string', { at: '0x2040' }),
|
|
p('unknown kind', { at: { kind: 'symbol', name: 'main' } }),
|
|
p('no kind', { at: { offset: '0x10' } }),
|
|
p('offset not hex', { at: { kind: 'module', offset: 'twelve' } }),
|
|
p('offset too long', { at: { kind: 'module', offset: '0x11111111111111111' } }),
|
|
p('offset is a number', { at: { kind: 'module', offset: 4096 } }),
|
|
p('module is a number', { at: { kind: 'module', offset: '0x10', module: 7 } }),
|
|
p('module name too long', { at: { kind: 'module', offset: '0x10', module: 'x'.repeat(200) } }),
|
|
p('pattern of wildcards', { at: { kind: 'signature', pattern: '?? ?? ??', offset: 0 } }),
|
|
p('pattern of zeros', { at: { kind: 'signature', pattern: '00 00 ?? 00', offset: 0 } }),
|
|
p('pattern not bytes', { at: { kind: 'signature', pattern: '4G 12', offset: 0 } }),
|
|
p('pattern too long', { at: { kind: 'signature', pattern: 'AB '.repeat(300), offset: 0 } }),
|
|
p('pattern offset text', { at: { kind: 'signature', pattern: 'AB CD', offset: '4' } }),
|
|
p('pattern offset fraction', { at: { kind: 'signature', pattern: 'AB CD', offset: 1.5 } }),
|
|
p('pattern offset huge', { at: { kind: 'signature', pattern: 'AB CD', offset: 1e300 } }),
|
|
p('pointer without offsets', { at: { kind: 'pointer', base: '0x10100', offsets: [] } }),
|
|
p('pointer without base', { at: { kind: 'pointer', offsets: ['0x8'] } }),
|
|
p('pointer offsets not text', { at: { kind: 'pointer', base: '0x10100', offsets: [8] } }),
|
|
p('pointer too long', { at: { kind: 'pointer', base: '0x10100', offsets: Array(17).fill('0x8') } }),
|
|
p('bad second place', { more: [{ on: '90' }] }),
|
|
p('second place not an object', { more: [5] }),
|
|
p('too many places', { more: Array.from({ length: 80 }, (_, i) => ({ at: at(0x3000 + i), on: '90', off: 'CC' })) }),
|
|
{ ...held('unknown type', 0x1000, 'u128', '1') },
|
|
{ ...held('value missing', 0x1008, 'u32', '1'), value: undefined },
|
|
{ ...held('type missing', 0x1010, 'u32', '1'), valueType: undefined },
|
|
p('café "quoted" back\\slash 😀 tab\there'),
|
|
p('x'.repeat(500)),
|
|
// Separators in the bytes, lower case, and a key the engine has no use for.
|
|
{ ...good, junk: JSON.parse(deep(20)), on: '90 90,90', off: 'ff-4b-28' },
|
|
];
|
|
// A key given twice: the later copy wins, as it does for the web app.
|
|
const text = fileText(odd).replace('"name":"Infinite ammo","type"', '"name":"Infinite ammo","on":"00","type"');
|
|
assert.notEqual(text, fileText(odd));
|
|
putFile(OWN_FILE, text);
|
|
let st = await until('the odd file to be read', (s) => s.cheats.length > 5 && settled(s));
|
|
assert.equal(st.error, undefined);
|
|
const expectError = odd.filter((e) => e && typeof e === 'object' && e.keep === true && typeof e.name === 'string' && e.name.length < 400 && e.name !== 'Infinite ammo' && !e.name.startsWith('caf') && e.type !== 'trainer' && e.type);
|
|
for (const e of expectError) assert.equal(stateOf(st, e.name), 'error', e.name);
|
|
for (const e of expectError) assert.ok(cheat(st, e.name).detail.length > 10, e.name);
|
|
assert.equal(stateOf(st, 'Infinite ammo'), 'on');
|
|
// Names come back as they went in; one too long to keep whole is cut at a character boundary.
|
|
assert.equal(stateOf(st, 'café "quoted" back\\slash 😀 tab here'), 'on');
|
|
assert.equal(st.cheats.filter((c) => /^x+$/.test(c.name)).length, 1);
|
|
assert.equal(st.cheats.length, expectError.length + 3);
|
|
assert.equal(mock.game.image[0x3000], 0x90);
|
|
assert.equal(proc.exitCode, null);
|
|
|
|
// More kept cheats than the engine takes: the first 256 are used and it says so.
|
|
putFile(OWN_FILE, fileText(Array.from({ length: 300 }, (_, i) => p(`n${i}`, { at: at(0x6000 + i) }))));
|
|
st = await until('the long file to be read', (s) => s.cheats.length === 256 && settled(s));
|
|
assert.match(st.error, /first 256/);
|
|
assert.equal(hex(mock.game.image.subarray(0x6000, 0x6000 + 300)), '90'.repeat(256) + 'CC'.repeat(44));
|
|
assert.equal(proc.exitCode, null);
|
|
});
|
|
|
|
test('a game that starts with a damaged file gets no cheats and one message', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
await launch({ [OWN_FILE]: '{"format":"cheatspecter","cheats":[{"name":"Infinite ammo","type":"patch","keep":true,' });
|
|
const st = await until('the file to be turned down', (s) => typeof s.error === 'string');
|
|
assert.equal(st.file, OWN_FILE);
|
|
assert.deepEqual(st.cheats, []);
|
|
await sleep(500);
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter could not read the cheat file for this game.\nOpen CheatSpecter and save your cheats again.']);
|
|
});
|
|
|
|
test('the older, flat file layout is still read', { skip }, async () => {
|
|
await launch({
|
|
[OWN_FILE]: JSON.stringify({ format: 'cheatspecter/1', titleId: GAME.titleId, version: GAME.version, name: 'Old Layout', entries: [nopPatch('Infinite ammo', 'ammoSpend')], cheats: [nopPatch('Ignored', 'healthDrain')] }),
|
|
});
|
|
const st = await until('it to be on', settled);
|
|
assert.deepEqual(st.cheats, [{ name: 'Infinite ammo', state: 'on', detail: '' }]);
|
|
assert.equal(hex(code('healthDrain')), ORIGINAL('healthDrain'));
|
|
});
|
|
|
|
// ---- the contract with the web app (payload/README.md) ------------------------------
|
|
|
|
const ABSENT = (n) => `DE AD BE EF ${n.toString(16).padStart(2, '0').toUpperCase()}`;
|
|
const sig = (pattern, offset = 0) => ({ kind: 'signature', pattern, offset });
|
|
|
|
test('a pattern with a fixed position beside it: the position is tried first, and used last', { skip }, async () => {
|
|
const lib = () => mock.game.regions.find((r) => r.name === 'libkernel.sprx').buf;
|
|
const goneBut = (name, fallback, extra = {}) => ({ name, type: 'patch', keep: true, at: sig(ABSENT(name.length + 0x40)), fallback, ...extra });
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
// The pattern is all over the game, but it is also right where the cheat was made: one look there settles it,
|
|
// exactly as the web app does. (Were it searched for, "more than one place" would send it to its fixed
|
|
// position, where a patch with nothing to compare against is not written.)
|
|
{ name: 'Still in place', type: 'patch', keep: true, at: sig('CC CC CC CC', 2), fallback: at(0x7002), on: '90' },
|
|
// The pattern is somewhere else now: it wins over the old position.
|
|
{ name: 'Moved, pattern found', type: 'patch', keep: true, at: sig('FF 47 10'), fallback: at(0x7200), on: '909090', off: 'FF4710' },
|
|
// The pattern is gone. The fixed position is used only when the file says what the code there should be.
|
|
goneBut('Pattern gone, code as expected', at(GAME.code.goldSpend.offset), { on: NOPS('goldSpend'), off: ORIGINAL('goldSpend') }),
|
|
goneBut('Pattern gone, other code', at(GAME.code.ammoSpend.offset), { on: NOPS('ammoSpend'), off: 'AABBCC' }),
|
|
goneBut('Pattern gone, nothing to check', at(GAME.code.scoreAdd.offset), { on: NOPS('scoreAdd') }),
|
|
goneBut('Pattern gone, code kept', at(GAME.code.healthDrain.offset), {
|
|
on: NOPS('healthDrain'),
|
|
around: { bytes: `CCCC${ORIGINAL('healthDrain')}C3CC`, mask: 'xxxx....xx', at: 2 },
|
|
}),
|
|
goneBut('Pattern gone, in a library', at(0x80, 'libkernel.sprx'), { on: 'C3C3', off: '9090' }),
|
|
goneBut('Pattern gone, library not loaded', at(0x80, 'libNotThere.sprx'), { on: 'C3', off: '90' }),
|
|
// Only a fixed position inside a module can stand in for a pattern.
|
|
goneBut('Odd fallback', { kind: 'address', address: (GAME.imageBase + 0x7100n).toString(16) }, { on: '90', off: 'CC' }),
|
|
// Two places, one found by pattern and one by its old position.
|
|
{
|
|
name: 'Mixed',
|
|
type: 'patch',
|
|
keep: true,
|
|
at: sig('8B 44 B3 20'),
|
|
on: '90909090',
|
|
more: [{ at: sig(ABSENT(0x7f)), fallback: at(GAME.code.idle.offset), on: 'CC', off: '90' }],
|
|
},
|
|
]),
|
|
});
|
|
const st = await until('them to settle', (s) => s.cheats.every((c) => c.state !== 'waiting' || c.name === 'Pattern gone, library not loaded') && s.cheats.length === 10, 15000);
|
|
const states = Object.fromEntries(st.cheats.map((c) => [c.name, c.state]));
|
|
assert.deepEqual(states, {
|
|
'Still in place': 'on',
|
|
'Moved, pattern found': 'on',
|
|
'Pattern gone, code as expected': 'on',
|
|
'Pattern gone, other code': 'mismatch',
|
|
'Pattern gone, nothing to check': 'mismatch',
|
|
'Pattern gone, code kept': 'on',
|
|
'Pattern gone, in a library': 'on',
|
|
'Pattern gone, library not loaded': 'waiting',
|
|
'Odd fallback': 'error',
|
|
Mixed: 'on',
|
|
});
|
|
const { image } = mock.game;
|
|
assert.equal(hex(image.subarray(0x7000, 0x7006)), 'CCCC90CCCCCC');
|
|
assert.equal(hex(code('staminaTick')), '909090');
|
|
assert.equal(hex(image.subarray(0x7200, 0x7203)), 'CCCCCC', 'the old position is left alone when the pattern is found elsewhere');
|
|
assert.equal(hex(code('goldSpend')), NOPS('goldSpend'));
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
assert.equal(hex(code('scoreAdd')), ORIGINAL('scoreAdd'));
|
|
assert.equal(hex(code('healthDrain')), NOPS('healthDrain'));
|
|
assert.equal(hex(lib().subarray(0x7f, 0x83)), '90C3C390');
|
|
assert.equal(image[0x7100], 0xcc);
|
|
assert.equal(hex(code('slotRead')), '90909090');
|
|
assert.equal(image[GAME.code.idle.offset], 0xcc);
|
|
assert.equal(cheat(st, 'Pattern gone, other code').detail, "The game's code is different here, so it was left alone");
|
|
assert.equal(cheat(st, 'Pattern gone, library not loaded').detail, 'Waiting for the game to load libNotThere.sprx');
|
|
assert.match(cheat(st, 'Odd fallback').detail, /was not found in this game/);
|
|
|
|
// Un-keeping puts back what was written through a fixed position too.
|
|
putFile(OWN_FILE, fileText([]));
|
|
await until('the cheats to be dropped', (s) => s.cheats.length === 0);
|
|
for (const name of ['goldSpend', 'healthDrain', 'slotRead', 'staminaTick', 'idle']) assert.equal(hex(code(name)), ORIGINAL(name), name);
|
|
assert.equal(image[0x7002], 0xcc);
|
|
});
|
|
|
|
test('with no "off" bytes, the code kept around a patch says what must be there', { skip }, async () => {
|
|
const kept = (name, codeName, around, extra = {}) => ({ name, type: 'patch', keep: true, at: at(GAME.code[codeName].offset), on: NOPS(codeName), around, ...extra });
|
|
const good = (codeName) => ({ bytes: `CC${ORIGINAL(codeName)}C3`, mask: 'x'.repeat(GAME.code[codeName].bytes.length + 2), at: 1 });
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
kept('Fits', 'slotRead', good('slotRead')),
|
|
// One different byte where the patch goes.
|
|
kept('Differs', 'loadPlayer', { ...good('loadPlayer'), bytes: `CC${ORIGINAL('loadPlayer').replace('05F9', '05FA')}C3` }),
|
|
// Bytes that depend on where the game is loaded are not compared; the rest are.
|
|
kept('Load-dependent bytes', 'scoreAdd', { bytes: 'CC8387FFFFFFFF0AC3', mask: 'xxx????.x', at: 1 }),
|
|
kept('Load-dependent, rest differs', 'goldSpend', { bytes: 'CCF3EEFFFFC3', mask: 'xxx??x', at: 1 }),
|
|
// The code before and after the patch is not compared: another kept cheat may have changed it.
|
|
kept('Neighbors changed', 'staminaTick', { bytes: `0000${ORIGINAL('staminaTick')}0000`, mask: 'xxxxxxx', at: 2 }),
|
|
// "off" in the file wins over the kept code.
|
|
kept('Has off as well', 'ammoSpend', { bytes: 'CC000000C3', mask: 'xxxxx', at: 1 }, { off: ORIGINAL('ammoSpend') }),
|
|
]),
|
|
});
|
|
let st = await until('them to settle', settled);
|
|
assert.deepEqual(st.cheats.map((c) => [c.name, c.state]), [
|
|
['Fits', 'on'],
|
|
['Differs', 'mismatch'],
|
|
['Load-dependent bytes', 'on'],
|
|
['Load-dependent, rest differs', 'mismatch'],
|
|
['Neighbors changed', 'on'],
|
|
['Has off as well', 'on'],
|
|
]);
|
|
assert.equal(hex(code('slotRead')), NOPS('slotRead'));
|
|
assert.equal(hex(code('loadPlayer')), ORIGINAL('loadPlayer'));
|
|
assert.equal(hex(code('scoreAdd')), NOPS('scoreAdd'));
|
|
assert.equal(hex(code('goldSpend')), ORIGINAL('goldSpend'));
|
|
|
|
// In a file saved for another version, kept code counts as something to check against; a record that
|
|
// cannot be used counts as nothing, and such a cheat is left alone rather than written blindly.
|
|
const useless = {
|
|
'no mask': { bytes: `CC${ORIGINAL('idle')}C3`, at: 1 },
|
|
'mask of another length': { bytes: `CC${ORIGINAL('idle')}C3`, mask: 'xx', at: 1 },
|
|
'starts past the end': { bytes: `CC${ORIGINAL('idle')}C3`, mask: 'xxx', at: 3 },
|
|
'runs past the end': { bytes: ORIGINAL('loadPlayer').slice(0, 6), mask: 'xxx', at: 0 },
|
|
'negative start': { bytes: `CC${ORIGINAL('idle')}C3`, mask: 'xxx', at: -1 },
|
|
'fractional start': { bytes: `CC${ORIGINAL('idle')}C3`, mask: 'xxx', at: 0.5 },
|
|
'start is text': { bytes: `CC${ORIGINAL('idle')}C3`, mask: 'xxx', at: '1' },
|
|
'odd hex': { bytes: 'CC9', mask: 'xx', at: 0 },
|
|
'nothing comparable': { bytes: `CC${ORIGINAL('idle')}C3`, mask: 'x?x', at: 1 },
|
|
'not an object': 'CC90C3',
|
|
'a list': ['CC90C3', 'xxx', 1],
|
|
empty: {},
|
|
};
|
|
const older = tableFileName(GAME.titleId, '00.900.000');
|
|
st = await launch({
|
|
[older]: fileText(
|
|
[
|
|
kept('Checked by kept code', 'slotRead', good('slotRead')),
|
|
...Object.entries(useless).map(([name, around], i) =>
|
|
name === 'runs past the end' ? kept(name, 'loadPlayer', around) : { name, type: 'patch', keep: true, at: at(0x7400 + i), on: '90', around },
|
|
),
|
|
],
|
|
{ version: '00.900.000' },
|
|
),
|
|
});
|
|
st = await until('them to settle', settled);
|
|
assert.equal(st.exact, false);
|
|
assert.equal(stateOf(st, 'Checked by kept code'), 'on');
|
|
for (const name of Object.keys(useless)) {
|
|
assert.equal(stateOf(st, name), 'mismatch', name);
|
|
assert.match(cheat(st, name).detail, /another version of the game/, name);
|
|
}
|
|
assert.equal(hex(mock.game.image.subarray(0x7400, 0x7400 + 16)), 'CC'.repeat(16));
|
|
assert.equal(hex(code('loadPlayer')), ORIGINAL('loadPlayer'));
|
|
assert.equal(proc.exitCode, null);
|
|
});
|
|
|
|
test('the names the web app uses for the game itself mean the same to the engine', { skip }, async () => {
|
|
const names = ['game', 'base', 'eboot', 'eboot.bin', 'executable', 'EBOOT.BIN', 'Game'];
|
|
await launch({
|
|
[OWN_FILE]: fileText([
|
|
...names.map((module, i) => ({ name: `as ${module}`, type: 'patch', keep: true, at: at(0x7600 + i, module), on: '90', off: 'CC' })),
|
|
{ ...held('Health through game', GAME.vars.health - GAME.player, 'u32', '321'), at: { kind: 'pointer', module: 'game', base: `0x${(0x10000 + GAME.statics.player).toString(16)}`, offsets: [`0x${(GAME.vars.health - GAME.player).toString(16)}`] } },
|
|
// A library is found whatever the case of its name.
|
|
{ name: 'Library, other case', type: 'patch', keep: true, at: at(0x100, 'LIBKERNEL.SPRX'), on: 'C3', off: '90' },
|
|
]),
|
|
});
|
|
const st = await until('them all to be on', (s) => s.cheats.length === names.length + 2 && s.cheats.every((c) => c.state === 'on'));
|
|
assert.equal(hex(mock.game.image.subarray(0x7600, 0x7600 + names.length + 1)), '90'.repeat(names.length) + 'CC');
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 321);
|
|
assert.equal(mock.game.regions.find((r) => r.name === 'libkernel.sprx').buf[0x100], 0xc3);
|
|
assert.equal(st.exact, true);
|
|
});
|
|
|
|
test('a value with a list of choices is held at the one that is picked', { skip }, async () => {
|
|
const mark = stderr.length;
|
|
const choices = [{ label: 'A little', value: '50' }, { label: 'Plenty', value: '5000' }, { label: 'Hex', value: '0x10' }];
|
|
const difficulty = (value) => held('Gold', GAME.vars.gold - GAME.player, 'float', value, { choices });
|
|
await launch({ [OWN_FILE]: fileText([difficulty('5000')]) });
|
|
await until('it to be held', (st) => stateOf(st, 'Gold') === 'on');
|
|
const gold = () => mock.game.heap.readFloatLE(GAME.vars.gold);
|
|
assert.equal(gold(), 5000);
|
|
// Picking another one in the app saves the file with the new value: that is all the engine needs.
|
|
putFile(OWN_FILE, fileText([difficulty('50')]));
|
|
for (let i = 0; i < 200 && gold() !== 50; i++) await sleep(10);
|
|
assert.equal(gold(), 50);
|
|
mock.game.heap.writeFloatLE(1, GAME.vars.gold);
|
|
for (let i = 0; i < 100 && gold() !== 50; i++) await sleep(10);
|
|
assert.equal(gold(), 50);
|
|
putFile(OWN_FILE, fileText([difficulty('0x10')]));
|
|
for (let i = 0; i < 200 && gold() !== 16; i++) await sleep(10);
|
|
assert.equal(gold(), 16);
|
|
// The list itself is the app's business: odd ones do not put the engine off.
|
|
putFile(OWN_FILE, fileText([{ ...difficulty('7'), choices: [null, 5, { label: 3 }, { value: {} }, [[[]]]] }]));
|
|
for (let i = 0; i < 200 && gold() !== 7; i++) await sleep(10);
|
|
assert.equal(gold(), 7);
|
|
assert.equal(stateOf(await status(), 'Gold'), 'on');
|
|
await sleep(400);
|
|
// One launch, one cheat: changing the pick is not news.
|
|
assert.deepEqual(notifications(mark), ['CheatSpecter: 1 cheat on for Specter Run']);
|
|
});
|
|
|
|
test('a file is this version\'s own only when its name and what it says inside both agree', { skip }, async () => {
|
|
const cheats = [nopPatch('Checked', 'ammoSpend'), nopPatch('Unchecked', 'scoreAdd', { off: undefined }), held('Health', GAME.vars.health - GAME.player, 'u32', '999')];
|
|
const careful = (st) => {
|
|
assert.equal(st.exact, false);
|
|
assert.equal(st.file, OWN_FILE);
|
|
assert.deepEqual(st.cheats.map((c) => c.state), ['on', 'mismatch', 'mismatch']);
|
|
assert.match(cheat(st, 'Unchecked').detail, /another version of the game/);
|
|
assert.equal(hex(code('scoreAdd')), ORIGINAL('scoreAdd'));
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
|
// The app says so above the list, and which file it is.
|
|
const overview = keptOverview(parseEngineStatus(st), GAME, 3);
|
|
assert.equal(overview.kind, 'warn');
|
|
assert.match(overview.lines.join('\n'), /says it was made for another version of this game/);
|
|
};
|
|
// Renamed (or copied) from an older version's file.
|
|
await launch({ [OWN_FILE]: fileText(cheats, { version: '00.900.000' }) });
|
|
careful(await until('them to settle', settled));
|
|
// Zero padding and stray characters in the version are not a difference; they are not in the file name either.
|
|
putFile(OWN_FILE, fileText(cheats, { version: ' 01.000.000 ' }));
|
|
let st = await until('the file to count as this version\'s', (s) => s.exact === true && settled(s));
|
|
assert.deepEqual(st.cheats.map((c) => c.state), ['on', 'on', 'on']);
|
|
assert.equal(keptOverview(parseEngineStatus(st), GAME, 3).kind, 'good');
|
|
// Another game's file under this game's name.
|
|
await launch({ [OWN_FILE]: fileText(cheats, { titleId: 'PPSA11111' }) });
|
|
careful(await until('them to settle', settled));
|
|
// A file that does not say is taken at its name's word, as files from before it said always were.
|
|
await launch({ [OWN_FILE]: JSON.stringify({ format: 'cheatspecter', formatVersion: 1, cheats }) });
|
|
st = await until('them to settle', settled);
|
|
assert.equal(st.exact, true);
|
|
assert.deepEqual(st.cheats.map((c) => c.state), ['on', 'on', 'on']);
|
|
// The older, flat layout says it at the top level.
|
|
await launch({ [OWN_FILE]: JSON.stringify({ format: 'cheatspecter/1', titleId: GAME.titleId, version: '00.100.000', name: 'Old', entries: cheats }) });
|
|
careful(await until('them to settle', settled));
|
|
});
|
|
|
|
test('what the engine lists is what the web app lists as kept, cheat for cheat', { skip }, async () => {
|
|
// A table as the app holds it, saved the way the app saves it.
|
|
const table = emptyTable(GAME.titleId, GAME.version, 'Specter Run');
|
|
const patch = (id, name, codeName, extra = {}) => ({ id, name, type: 'patch', at: at(GAME.code[codeName].offset), on: NOPS(codeName), off: ORIGINAL(codeName), ...extra });
|
|
table.entries.push(
|
|
patch('1', 'Twin', 'ammoSpend', { keep: true }),
|
|
patch('2', 'Not kept', 'healthDrain'),
|
|
patch('3', 'Twin', 'goldSpend', { keep: true, off: 'AABBCCDD' }),
|
|
{ id: '4', name: 'Health', type: 'value', at: { kind: 'pointer', base: `0x${(0x10000 + GAME.statics.player).toString(16)}`, offsets: [`0x${(GAME.vars.health - GAME.player).toString(16)}`] }, valueType: 'u32', value: '777', freeze: true, keep: true, group: 'Player', hotkey: 'F6' },
|
|
patch('5', 'Twin', 'scoreAdd', { keep: true, more: [{ at: at(GAME.code.idle.offset), on: 'CC', off: '90' }] }),
|
|
);
|
|
const text = saveFileText(table);
|
|
await launch({ [OWN_FILE]: text });
|
|
const st = await until('them to settle', settled);
|
|
const parsed = parseEngineStatus(st);
|
|
// The app reads its own file back, and asks about each kept cheat the way the Cheats tab does.
|
|
const loaded = parseOwnTable(text, { own: true });
|
|
const kept = keptEntries(loaded);
|
|
assert.equal(kept.length, st.cheats.length);
|
|
const word = (entry) => keptStatus(parsed, GAME.titleId, entry.name, kept.filter((e) => e.name === entry.name).indexOf(entry));
|
|
assert.deepEqual(kept.map((e) => word(e).state), ['on', 'mismatch', 'on', 'on']);
|
|
assert.deepEqual(st.cheats.map((c) => c.name), kept.map((e) => e.name));
|
|
for (const e of loaded.entries) assert.equal(keepProblem(e), null);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 777);
|
|
assert.equal(hex(code('goldSpend')), ORIGINAL('goldSpend'));
|
|
const overview = keptOverview(parsed, GAME, kept.length);
|
|
assert.equal(overview.kind, 'warn');
|
|
assert.match(overview.text, /keeping 3 cheats on by itself.*1 does not fit this version of the game and was left off/);
|
|
assert.deepEqual(overview.lines, []);
|
|
});
|
|
|
|
// ---- sharing the console with a browser ---------------------------------------------
|
|
|
|
const viaPayload = async () => new Ps5Debug(await openWebSocketStream(`ws://127.0.0.1:${PORT}/ws`), { timeoutMs: 4000 });
|
|
|
|
test('holds back its own searches while a browser is in the middle of one', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([held('Health', GAME.vars.health - GAME.player, 'u32', '999')]) });
|
|
await until('the value to be held', (st) => stateOf(st, 'Health') === 'on');
|
|
assert.equal((await status()).busy, undefined);
|
|
|
|
// A browser connection that is just open, or busy with quick exchanges, does not count.
|
|
const quick = await viaPayload();
|
|
for (let i = 0; i < 20; i++) await quick.read(GAME.pid, GAME.heapBase, 16);
|
|
await sleep(200);
|
|
assert.equal((await status()).busy, undefined);
|
|
|
|
// A request the debugger takes its time over (here: one it is still waiting for the rest of), as a long search is.
|
|
const slow = await openWebSocketStream(`ws://127.0.0.1:${PORT}/ws`);
|
|
const head = Buffer.alloc(12);
|
|
head.writeUInt32LE(0xffaabbcc, 0);
|
|
head.writeUInt32LE(0xbdaa0002, 4);
|
|
head.writeUInt32LE(16, 8);
|
|
slow.write(head);
|
|
let st = await until('the engine to notice', (s) => s.busy === true);
|
|
assert.equal(st.running, true);
|
|
assert.match(keptOverview(parseEngineStatus(st), GAME, 1).lines.join('\n'), /holding back searches of its own/);
|
|
|
|
// Meanwhile: a new cheat that needs a search waits, one that needs none goes on, and held values stay held.
|
|
putFile(OWN_FILE, fileText([held('Health', GAME.vars.health - GAME.player, 'u32', '999'), { name: 'By pattern', type: 'patch', keep: true, at: sig('FF 4B ?? C3'), on: '909090' }, nopPatch('God mode', 'healthDrain')]));
|
|
st = await until('the new cheats to be read', (s) => s.cheats.length === 3 && stateOf(s, 'God mode') === 'on');
|
|
assert.deepEqual(cheat(st, 'By pattern'), { name: 'By pattern', state: 'waiting', detail: 'Waiting for a search in CheatSpecter to finish' });
|
|
mock.game.heap.writeUInt32LE(5, GAME.vars.health);
|
|
for (let i = 0; i < 100 && mock.game.heap.readUInt32LE(GAME.vars.health) !== 999; i++) await sleep(10);
|
|
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 999);
|
|
await sleep(600);
|
|
st = await status();
|
|
assert.equal(st.busy, true);
|
|
assert.equal(stateOf(st, 'By pattern'), 'waiting');
|
|
assert.equal(hex(code('ammoSpend')), ORIGINAL('ammoSpend'));
|
|
// The other connection is not held up by any of this.
|
|
assert.equal(Buffer.from(await quick.read(GAME.pid, GAME.heapBase + BigInt(GAME.vars.health), 4)).readUInt32LE(0), 999);
|
|
|
|
// The browser's search ends (here: its connection closes), and the engine gets on with its own.
|
|
slow.close();
|
|
st = await until('the search to be made', (s) => stateOf(s, 'By pattern') === 'on');
|
|
assert.equal(st.busy, undefined);
|
|
assert.equal(hex(code('ammoSpend')), '909090');
|
|
quick.close();
|
|
});
|
|
|
|
test('goes on keeping cheats on while a browser has the debugger attached', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([nopPatch('God mode', 'healthDrain'), held('Gold', GAME.vars.gold - GAME.player, 'float', '1234.5')]) });
|
|
await until('both to be on', (st) => st.cheats.length === 2 && st.cheats.every((c) => c.state === 'on'));
|
|
|
|
// A browser, through this payload, attaches the debugger and watches what writes the ammo count.
|
|
const x86 = asAsync(createX86(createRequire(import.meta.url)('iced-x86')));
|
|
const dbg = await viaPayload();
|
|
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 });
|
|
const tracer = new Tracer(ctx, { openDebugger: viaPayload, openEvents: () => openWebSocketStream(`ws://127.0.0.1:${PORT}/events`) });
|
|
await tracer.start(GAME.heapBase + BigInt(GAME.vars.ammo), 4, 'write', () => {});
|
|
assert.notEqual(mock.debug.owner, null, 'the browser holds the one debugging session');
|
|
const gold = () => mock.game.heap.readFloatLE(GAME.vars.gold);
|
|
for (let round = 0; round < 3; round++) {
|
|
const seen = tracer.total;
|
|
mock.tick();
|
|
for (let n = 0; n < 150 && !(tracer.total === seen + 1 && !mock.debug.stopped); n++) await sleep(20);
|
|
assert.equal(tracer.total, seen + 1, 'the browser gets its stop reports');
|
|
// The engine needs no session of its own: it keeps writing through plain memory commands.
|
|
mock.game.heap.writeFloatLE(1, GAME.vars.gold);
|
|
for (let i = 0; i < 100 && gold() !== 1234.5; i++) await sleep(10);
|
|
assert.equal(gold(), 1234.5);
|
|
const st = await status();
|
|
assert.equal(st.running, true);
|
|
assert.deepEqual(st.cheats.map((c) => c.state), ['on', 'on']);
|
|
}
|
|
// A cheat kept on while the session is open goes on as usual, and one taken away is undone.
|
|
putFile(OWN_FILE, fileText([held('Gold', GAME.vars.gold - GAME.player, 'float', '1234.5'), nopPatch('Score', 'scoreAdd')]));
|
|
await until('the change to be applied', (st) => st.cheats.length === 2 && stateOf(st, 'Score') === 'on');
|
|
assert.equal(hex(code('healthDrain')), ORIGINAL('healthDrain'));
|
|
assert.equal(hex(code('scoreAdd')), NOPS('scoreAdd'));
|
|
assert.notEqual(mock.debug.owner, null, 'the browser still holds the session');
|
|
|
|
await tracer.stop();
|
|
assert.equal(mock.debug.owner, null);
|
|
assert.equal(mock.state.crashed, null);
|
|
assert.deepEqual(mock.state.hazards, []);
|
|
assert.deepEqual((await status()).cheats.map((c) => c.state), ['on', 'on']);
|
|
dbg.close();
|
|
});
|
|
|
|
test('a connection that never sends a request is let go; an open WebSocket is not', { skip }, async () => {
|
|
const quiet = net.connect(PORT, '127.0.0.1');
|
|
let closedAt = 0;
|
|
const started = Date.now();
|
|
quiet.on('close', () => (closedAt = Date.now()));
|
|
quiet.on('error', () => {});
|
|
const ws = await openWebSocketStream(`ws://127.0.0.1:${PORT}/events`);
|
|
// Half a request is no better than none.
|
|
const half = net.connect(PORT, '127.0.0.1');
|
|
let halfClosed = false;
|
|
half.on('close', () => (halfClosed = true));
|
|
half.on('error', () => {});
|
|
half.write('GET /api/info HTTP/1.1\r\nHost: x\r\n');
|
|
for (let i = 0; i < 80 && !(closedAt && halfClosed); i++) await sleep(50);
|
|
// The test payload's limit is 2 seconds (15 on a console).
|
|
assert.ok(closedAt - started >= 1500 && closedAt - started < 3800, `closed after ${closedAt - started} ms`);
|
|
assert.equal(halfClosed, true);
|
|
await sleep(500);
|
|
assert.equal(ws.closed, false);
|
|
ws.close();
|
|
// Requests in the meantime were answered as ever.
|
|
assert.equal((await api('/api/info')).status, 200);
|
|
});
|
|
|
|
test('the bridge passes the engine endpoints through, and stands in for them in demo mode', { skip }, async () => {
|
|
await launch({ [OWN_FILE]: fileText([nopPatch('Infinite ammo', 'ammoSpend')]) });
|
|
await until('it to be on', (st) => stateOf(st, 'Infinite ammo') === 'on');
|
|
|
|
const bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, payloadPort: PORT });
|
|
const demo = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, payloadPort: 9, demo: { host: '127.0.0.1', port: MOCK, udpPort: 0, label: 'demo' } });
|
|
await bridge.ready;
|
|
await demo.ready;
|
|
try {
|
|
const base = `http://127.0.0.1:${bridge.server.address().port}`;
|
|
const through = await fetch(`${base}/api/engine?host=127.0.0.1`);
|
|
assert.equal(through.status, 200);
|
|
assert.match(through.headers.get('content-type'), /application\/json/);
|
|
assert.deepEqual(await through.json(), await status());
|
|
|
|
putFile(OWN_FILE, fileText([nopPatch('God mode', 'healthDrain')]));
|
|
const reload = await fetch(`${base}/api/engine/reload?host=127.0.0.1`, { method: 'POST' });
|
|
assert.equal(reload.status, 204);
|
|
await until('the reload to arrive', (st) => stateOf(st, 'God mode') === 'on');
|
|
|
|
// The allow-list stays strict: exact paths, the right method, a console on the local network, this page's origin.
|
|
assert.equal((await fetch(`${base}/api/engine/reload?host=127.0.0.1`)).status, 405);
|
|
assert.equal((await fetch(`${base}/api/engine?host=127.0.0.1`, { method: 'POST' })).status, 405);
|
|
assert.equal((await fetch(`${base}/api/engine?host=127.0.0.1`, { method: 'DELETE' })).status, 405);
|
|
assert.equal((await fetch(`${base}/api/engine?host=8.8.8.8`)).status, 400);
|
|
assert.equal((await fetch(`${base}/api/engine?host=example.com`)).status, 400);
|
|
assert.equal((await fetch(`${base}/api/engine/reload?host=127.0.0.1`, { method: 'POST', headers: { origin: 'http://evil.example' } })).status, 403);
|
|
for (const p of ['/api/engine/', '/api/engine/other', '/api/engine/reload/x', '/api/enginex']) {
|
|
const res = await fetch(`${base}${p}?host=127.0.0.1`);
|
|
assert.ok(res.status === 404 || res.status === 400, `${p} gave ${res.status}`);
|
|
}
|
|
|
|
// Demo mode has no engine. A stand-in answers in the same shape and takes a reload without complaint.
|
|
const demoBase = `http://127.0.0.1:${demo.server.address().port}`;
|
|
const stub = await fetch(`${demoBase}/api/engine?host=127.0.0.1`);
|
|
assert.equal(stub.status, 200);
|
|
// With no game named for the demo console: the engine's own answer for "no game running".
|
|
assert.deepEqual(await stub.json(), { running: true, game: null, file: '', cheats: [] });
|
|
assert.deepEqual(parseEngineStatus(await (await fetch(`${demoBase}/api/engine?host=127.0.0.1`)).json()), parseEngineStatus({ running: true, game: null, file: '', cheats: [] }));
|
|
|
|
// With a game: what the app saved is what it lists, each kept cheat saying plainly that the demo does not apply it.
|
|
const demoGame = { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid };
|
|
const playing = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, payloadPort: 9, demo: { host: '127.0.0.1', port: MOCK, udpPort: 0, label: 'demo', game: () => demoGame } });
|
|
await playing.ready;
|
|
try {
|
|
const at2 = `http://127.0.0.1:${playing.server.address().port}`;
|
|
const ask = async () => (await fetch(`${at2}/api/engine?host=127.0.0.1`)).json();
|
|
assert.deepEqual(await ask(), { running: true, game: demoGame, file: '', cheats: [] });
|
|
const table = emptyTable(GAME.titleId, GAME.version, 'Specter Run');
|
|
table.entries.push({ id: 'a', name: 'Infinite ammo', type: 'patch', at: at(GAME.code.ammoSpend.offset), on: '909090', off: 'FF4B28', keep: true });
|
|
table.entries.push({ id: 'b', name: 'Not kept', type: 'patch', at: at(GAME.code.healthDrain.offset), on: NOPS('healthDrain') });
|
|
assert.equal((await fetch(`${at2}/api/tables/${GAME.titleId}/${GAME.version}?host=127.0.0.1`, { method: 'PUT', body: saveFileText(table) })).status, 204);
|
|
const shown = await ask();
|
|
assert.deepEqual({ ...shown, cheats: [] }, { running: true, game: demoGame, file: OWN_FILE, exact: true, cheats: [] });
|
|
assert.equal(shown.cheats.length, 1);
|
|
assert.equal(shown.cheats[0].name, 'Infinite ammo');
|
|
assert.equal(shown.cheats[0].state, 'waiting');
|
|
assert.match(shown.cheats[0].detail, /demo console does not keep cheats on by itself/);
|
|
// The app reads it as it reads the real thing.
|
|
assert.equal(keptStatus(parseEngineStatus(shown), GAME.titleId, 'Infinite ammo').state, 'waiting');
|
|
assert.equal(mock.game.image[GAME.code.ammoSpend.offset], 0xff, 'the stand-in never touches the game');
|
|
// Another version's file, and one that is not a cheat file.
|
|
table.version = '00.900.000';
|
|
await fetch(`${at2}/api/tables/${GAME.titleId}/01.000.000?host=127.0.0.1`, { method: 'PUT', body: saveFileText(table) });
|
|
assert.equal((await ask()).exact, false);
|
|
await fetch(`${at2}/api/tables/${GAME.titleId}/01.000.000?host=127.0.0.1`, { method: 'PUT', body: '{"format":"cheatspecter","cheats":' });
|
|
const broken = await ask();
|
|
assert.match(broken.error, /cheat file/);
|
|
assert.deepEqual(broken.cheats, []);
|
|
} finally {
|
|
await playing.close();
|
|
}
|
|
assert.equal((await fetch(`${demoBase}/api/engine/reload?host=127.0.0.1`, { method: 'POST' })).status, 204);
|
|
assert.equal((await fetch(`${demoBase}/api/engine/reload?host=127.0.0.1`)).status, 405);
|
|
} finally {
|
|
await bridge.close();
|
|
await demo.close();
|
|
}
|
|
});
|
|
|
|
test('the rest of the payload still works with the engine running', { skip }, async () => {
|
|
assert.equal((await api('/api/info')).status, 200);
|
|
assert.equal((await api('/')).status, 200);
|
|
assert.deepEqual(await (await api(`/api/tables/${GAME.titleId}`)).json(), { folder: '/data/cheatspecter/cheats', versions: [GAME.version] });
|
|
assert.equal(proc.exitCode, null);
|
|
});
|