Files
Tiago Valente 08c9abb573 CheatSpecter 0.4.1
A quality pass after 0.4.0 was tried on a real console.

- Fix the Learn page crashing in current browsers when changing lesson steps
- Clear feedback for every search outcome; real progress with time left and Cancel
- Dialogs scroll inside the screen with Save and Cancel always in view
- Fix a console error on every closed connection to the payload
- Copy buttons work when the page is served from the PS5
- The practice game sits beside the lesson's tools and lasts between steps
- Damaged cheat files, vanishing error messages, unreadable memory shown as zeros
- Phone layout: one-line header, larger switches, readable rows
- Browser tests under test/ui and a guard against effects that return values
2026-10-10 15:19:56 -07:00

386 lines
19 KiB
JavaScript

// The on-console payload, built for this computer and pointed at the mock
// console. 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 http from 'node:http';
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 { startMock, GAME } from '../mock/mock-ps5.mjs';
import { buildParamJson, buildSfo } from '../bridge/demo-library.mjs';
import { parseParamJson, parseParamSfo } from '../src/lib/library.ts';
import { emptyTable, parseOwnTable, saveFileText } from '../src/lib/table.ts';
import { openWebSocketStream } from '../src/lib/transport.ts';
import { Ps5Debug } from '../src/lib/ps5debug.ts';
import { ScanSession, scannableRegions, valueKind, compareKind, encodeValue } from '../src/lib/scanner.ts';
import { hexToBytes } from '../src/lib/bytes.ts';
import { createRequire } from 'node:module';
import { createX86, asAsync } from '../src/lib/x86.ts';
import { createGameContext } from '../src/lib/locations.ts';
import { Tracer } from '../src/lib/tracer.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 = 18744 + PORT_OFFSET;
const MOCK = 17450 + PORT_OFFSET;
const EVENTS = 17766 + PORT_OFFSET;
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)';
}
// A stand-in for the console's file system: two PS5 games, a PS4 game and a system app.
const FAKE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-root-'));
const LAUNCH_LOG = path.join(FAKE_ROOT, 'launched.txt');
const put = (rel, data) => {
const file = path.join(FAKE_ROOT, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, data);
};
put('system_data/priv/appmeta/PPSA07391/param.json', buildParamJson('PPSA07391', 'Harbor Lights', '01.009.000'));
put('user/appmeta/PPSA07391/icon0.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]));
put('user/app/PPSA07391/app.pkg', Buffer.alloc(5000));
put('user/app/PPSA07391/sce_sys/extra.bin', Buffer.alloc(300));
put('user/patch/PPSA07391/patch.pkg', Buffer.alloc(700));
// The same installed files, also visible at a mount point: not to be counted twice.
put('system_ex/app/PPSA07391/app.pkg', Buffer.alloc(5000));
put('system_data/priv/appmeta/external/PPSA11111/param.json', buildParamJson('PPSA11111', 'On The Drive', '01.000.000'));
put('mnt/ext0/user/app/PPSA11111/app.pkg', Buffer.alloc(1234));
put('system_data/priv/appmeta/CUSA12345/param.sfo', buildSfo({ TITLE: 'Old Favorite', TITLE_ID: 'CUSA12345', APP_VER: '01.07' }));
put('system_data/priv/appmeta/NPXS40000/param.json', buildParamJson('NPXS40000', 'Settings', '01.000.000'));
put('system_data/priv/appmeta/secret.txt', 'not a title');
// A game kept elsewhere and mounted for the console: a small stub under /user/app, the real files under /system_ex/app.
put('user/appmeta/PPSA18605/param.json', buildParamJson('PPSA18605', 'Mounted Game', '01.001.008'));
put('user/app/PPSA18605/stub.bin', Buffer.alloc(40));
put('system_ex/app/PPSA18605/eboot.bin', Buffer.alloc(9000));
put('system_ex/app/PPSA18605/data/a/b/c/d/e/f/g/h/deep.pak', Buffer.alloc(1000));
// Games ShadowMount looks after: one is a disk image that is not mounted at the moment, one a folder that is.
put('user/appmeta/PPSA22222/param.json', buildParamJson('PPSA22222', 'Image Game', '01.000.000'));
put('user/app/PPSA22222/stub.bin', Buffer.alloc(10));
put('mnt/usb0/games/Image Game.ffpkg', Buffer.alloc(7777));
put('user/appmeta/PPSA33333/param.json', buildParamJson('PPSA33333', 'Folder Game', '01.000.000'));
put('data/homebrew/Folder Game/eboot.bin', Buffer.alloc(500));
put('system_ex/app/PPSA33333/eboot.bin', Buffer.alloc(500));
// What ShadowMount answers on its local port, slashes escaped the way its JSON library writes them.
const SHADOWMOUNT = 18745 + PORT_OFFSET;
const shadowmount = http.createServer((req, res) => {
const games = [
{ path: '/mnt/usb0/games/Image Game.ffpkg', runtime_path: '/system_ex/app/PPSA22222', title_id: 'PPSA22222', title_name: 'Image {Game}', mounted: false },
{ path: '/data/homebrew/Folder Game', runtime_path: '/system_ex/app/PPSA33333', title_id: 'PPSA33333', mounted: true },
// An ordinary installed game, which ShadowMount lists with its install folder.
{ path: '/user/app/PPSA07391', runtime_path: '', title_id: 'PPSA07391', mounted: false },
{ path: '/mnt/usb1/unplugged', runtime_path: '', title_id: 'PPSA11111', mounted: false },
];
res.writeHead(req.method === 'POST' && req.url === '/api/v1/games' ? 200 : 404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 0, count: games.length, games }, null, 1).replace(/\//g, '\\/'));
});
const api = (p, init) => fetch(`http://127.0.0.1:${PORT}${p}`, init);
let mock, proc;
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 new Promise((r) => setTimeout(r, 30));
}
throw new Error('payload did not start');
};
const connect = async () => new Ps5Debug(await openWebSocketStream(`ws://127.0.0.1:${PORT}/ws`), { timeoutMs: 3000 });
before(async () => {
if (skip) return;
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: false, eventPort: EVENTS });
await mock.ready;
await new Promise((r) => shadowmount.listen(SHADOWMOUNT, '127.0.0.1', r));
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: String(SHADOWMOUNT),
CS_LAUNCH_LOG: LAUNCH_LOG,
},
stdio: 'ignore',
});
await waitForPort(PORT);
});
after(async () => {
if (proc) proc.kill();
shadowmount.close();
if (mock) await mock.close();
});
test('payload serves the app and says it is the console', { skip }, async () => {
const info = await (await fetch(`http://127.0.0.1:${PORT}/api/info`)).json();
assert.equal(info.mode, 'console');
assert.match(info.version, /^\d+\.\d+\.\d+$/);
const page = await fetch(`http://127.0.0.1:${PORT}/`);
assert.equal(page.status, 200);
assert.match(page.headers.get('content-type'), /text\/html/);
const html = await page.text(); // fetch undoes the gzip
assert.match(html, /<title>CheatSpecter<\/title>/);
// Every file index.html refers to is served intact.
for (const [, ref] of html.matchAll(/(?:src|href)="\.\/([^"]+)"/g)) {
const res = await fetch(`http://127.0.0.1:${PORT}/${ref}`);
assert.equal(res.status, 200, ref);
const body = Buffer.from(await res.arrayBuffer());
assert.deepEqual(body, fs.readFileSync(path.join(ROOT, 'dist', ref)), ref);
if (ref.startsWith('assets/')) assert.match(res.headers.get('cache-control'), /immutable/);
}
assert.equal((await fetch(`http://127.0.0.1:${PORT}/nope`)).status, 404);
assert.equal((await fetch(`http://127.0.0.1:${PORT}/../package.json`)).status, 404);
assert.equal((await fetch(`http://127.0.0.1:${PORT}/`, { method: 'POST' })).status, 405);
});
test('payload relays the debugger protocol both ways', { skip }, async () => {
const dbg = await connect();
assert.equal(await dbg.version(), '1.3');
assert.equal((await dbg.foregroundApp()).titleId, GAME.titleId);
// Large transfers in both directions cross the 16-bit and chunk boundaries.
const big = await dbg.read(GAME.pid, GAME.heapBase, 0x40000);
assert.deepEqual(Buffer.from(big), mock.game.heap);
const blob = new Uint8Array(0x30000);
for (let i = 0; i < blob.length; i++) blob[i] = (i * 7 + 3) & 0xff;
await dbg.write(GAME.pid, GAME.heapBase + 0x1000n, blob);
assert.deepEqual(mock.game.heap.subarray(0x1000, 0x31000), Buffer.from(blob));
mock.game.heap.fill(0, 0x1000, 0x31000);
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
// Small writes of awkward sizes (frame length encodings 125/126/127 bytes).
for (const n of [1, 125, 126, 127, 65535, 65536]) {
const data = new Uint8Array(n).fill(n & 0xff);
await dbg.write(GAME.pid, GAME.heapBase + 0x2000n, data);
assert.deepEqual(mock.game.heap.subarray(0x2000, 0x2000 + n), Buffer.from(data), `write of ${n}`);
assert.deepEqual(Buffer.from(await dbg.read(GAME.pid, GAME.heapBase + 0x2000n, n)), Buffer.from(data), `read of ${n}`);
}
mock.game.heap.fill(0, 0x2000, 0x2000 + 65536);
mock.game.heap.writeUInt32LE(100, 0x9000);
const kind = valueKind('u32');
const scan = new ScanSession(() => dbg, GAME.pid, kind);
await scan.first(scannableRegions(await dbg.maps(GAME.pid)), compareKind('exact'), encodeValue(kind, '100'), () => {});
assert.ok(scan.matches >= 2);
mock.game.heap.writeUInt32LE(77, GAME.vars.health);
await scan.next(compareKind('exact'), encodeValue(kind, '77'), () => {});
assert.equal(scan.matches, 1);
dbg.close();
});
test('payload handles several connections at once', { skip }, async () => {
const clients = await Promise.all([connect(), connect(), connect(), connect()]);
const results = await Promise.all(clients.map((c) => c.read(GAME.pid, GAME.heapBase, 0x20000)));
for (const r of results) assert.equal(r.length, 0x20000);
clients.forEach((c) => c.close());
});
test('payload reports a missing debugger and survives it', { skip }, async () => {
await mock.close();
const stream = await openWebSocketStream(`ws://127.0.0.1:${PORT}/ws`);
await assert.rejects(new Ps5Debug(stream).version(), /Is the ps5debug payload loaded/);
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: false, eventPort: EVENTS });
await mock.ready;
const dbg = await connect();
assert.equal(await dbg.version(), '1.3');
dbg.close();
});
test('payload forwards debugger stop reports to /events', { skip }, async () => {
const info = await (await fetch(`http://127.0.0.1:${PORT}/api/info`)).json();
assert.equal(info.events, true);
const x86 = asAsync(createX86(createRequire(import.meta.url)('iced-x86')));
const dbg = await connect();
const ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 });
const tracer = new Tracer(ctx, { openDebugger: connect, openEvents: () => openWebSocketStream(`ws://127.0.0.1:${PORT}/events`) });
let latest = [];
await tracer.start(GAME.heapBase + BigInt(GAME.vars.ammo), 4, 'write', (hits) => (latest = hits));
for (let i = 0; i < 2; i++) {
const seen = tracer.total;
mock.tick();
for (let n = 0; n < 100 && !(tracer.total === seen + 1 && !mock.debug.stopped); n++) await new Promise((r) => setTimeout(r, 20));
assert.equal(tracer.total, seen + 1);
}
assert.equal(latest[0].insn.text, 'dec dword ptr [rbx+0x28]');
assert.equal(latest[0].count, 2);
await tracer.stop();
assert.equal(mock.debug.owner, null);
mock.game.heap.writeUInt32LE(100, GAME.vars.health);
dbg.close();
});
test('payload refuses WebSockets opened by other sites', { skip }, async () => {
const handshake = (origin) =>
new Promise((resolve, reject) => {
const s = net.connect(PORT, '127.0.0.1');
let data = '';
s.on('connect', () =>
s.write(
`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n${origin ? `Origin: ${origin}\r\n` : ''}\r\n`,
),
);
s.on('data', (d) => {
data += d.toString('latin1');
if (data.includes('\r\n\r\n')) {
s.destroy();
resolve(data);
}
});
s.on('error', reject);
});
assert.match(await handshake('http://evil.example'), /^HTTP\/1\.1 403/);
const ok = await handshake(`http://127.0.0.1:${PORT}`);
assert.match(ok, /^HTTP\/1\.1 101/);
// The accept value for this key is the worked example in RFC 6455.
assert.match(ok, /Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK\+xOo=/);
});
test('lists the installed games with their kind, size and where they live', { skip }, async () => {
const res = await api('/api/library');
assert.equal(res.status, 200);
const { titles } = await res.json();
assert.deepEqual(titles, [
{ id: 'CUSA12345', meta: 'sfo', size: 0, partial: false, path: '' },
{ id: 'PPSA07391', meta: 'json', size: 6000, partial: false, path: '/user/app/PPSA07391' },
{ id: 'PPSA11111', meta: 'json', size: 1234, partial: false, path: '/mnt/ext0/user/app/PPSA11111' },
// Counted where the files really are, however deep, and reported at the larger location.
{ id: 'PPSA18605', meta: 'json', size: 10000, partial: false, path: '/system_ex/app/PPSA18605' },
// An image that is not mounted is sized from the image file ShadowMount points to.
{ id: 'PPSA22222', meta: 'json', size: 7787, partial: false, path: '/mnt/usb0/games/Image Game.ffpkg' },
// A mounted folder is counted once, at its source, not again at its mount point.
{ id: 'PPSA33333', meta: 'json', size: 500, partial: false, path: '/data/homebrew/Folder Game' },
]);
});
test('hands over metadata files the web app can read names from', { skip }, async () => {
const json = await api('/api/appmeta/PPSA07391/param.json');
assert.deepEqual(parseParamJson(await json.text()), { name: 'Harbor Lights', version: '01.009.000', platform: 'PS5' });
const external = await api('/api/appmeta/PPSA11111/param.json');
assert.equal(parseParamJson(await external.text()).name, 'On The Drive');
const sfo = await api('/api/appmeta/CUSA12345/param.sfo');
assert.deepEqual(parseParamSfo(new Uint8Array(await sfo.arrayBuffer())), { name: 'Old Favorite', version: '01.07', platform: 'PS4' });
const icon = await api('/api/appmeta/PPSA07391/icon0.png');
assert.equal(icon.headers.get('content-type'), 'image/png');
assert.equal((await icon.arrayBuffer()).byteLength, 7);
});
test('metadata requests cannot wander off into other files', { skip }, async () => {
for (const p of [
'/api/appmeta/PPSA07391/app.pkg',
'/api/appmeta/PPSA07391/../secret.txt',
'/api/appmeta/../secret.txt/param.json',
'/api/appmeta/ppsa07391/param.json',
'/api/appmeta/PPSA0739/param.json',
'/api/appmeta/CUSA12345/param.json',
'/api/appmeta/PPSA99998/icon0.png',
]) {
// Sent raw: fetch would tidy the dots away before the server ever saw them.
const status = await new Promise((resolve, reject) => {
const s = net.connect(PORT, '127.0.0.1', () => s.write(`GET ${p} HTTP/1.1\r\nHost: x\r\n\r\n`));
let data = '';
s.on('data', (d) => (data += d));
s.on('end', () => resolve(Number(data.split(' ')[1])));
s.on('error', reject);
});
assert.equal(status, 404, p);
}
});
test('starts a game that is on the console, and nothing else', { skip }, async () => {
const ok = await api('/api/launch/PPSA07391', { method: 'POST' });
assert.deepEqual(await ok.json(), { ok: true });
assert.equal(fs.readFileSync(LAUNCH_LOG, 'utf8'), 'PPSA07391\n');
assert.equal((await api('/api/launch/PPSA55555', { method: 'POST' })).status, 404); // not installed
assert.equal((await api('/api/launch/NPXS40000', { method: 'POST' })).status, 404); // a system app
assert.equal((await api('/api/launch/PPSA07391')).status, 404); // GET does nothing
const foreign = await api('/api/launch/PPSA07391', { method: 'POST', headers: { origin: 'http://evil.example' } });
assert.equal(foreign.status, 403);
assert.equal(fs.readFileSync(LAUNCH_LOG, 'utf8'), 'PPSA07391\n');
});
test('keeps cheat files on the console, one per game version', { skip }, async () => {
assert.deepEqual(await (await api('/api/tables/PPSA07391')).json(), { folder: '/data/cheatspecter/cheats', versions: [] });
assert.equal((await api('/api/tables/PPSA07391/01.009.000')).status, 404);
const table = emptyTable('PPSA07391', '01.009.000', 'Harbor Lights');
table.entries.push({ id: 'x', name: 'Infinite ammo', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'FF4B28' });
// Bigger than one read, so the body arrives in pieces.
table.entries.push({ id: 'y', name: 'Big script', type: 'script', code: '// pad\n'.repeat(40000) });
const text = saveFileText(table);
const saved = await api('/api/tables/PPSA07391/01.009.000', { method: 'PUT', body: text });
assert.equal(saved.status, 204);
assert.equal(fs.readFileSync(path.join(FAKE_ROOT, 'data/cheatspecter/cheats/PPSA07391-01.009.000.cspector'), 'utf8'), text);
assert.deepEqual(fs.readdirSync(path.join(FAKE_ROOT, 'data/cheatspecter/cheats')), ['PPSA07391-01.009.000.cspector']);
await api('/api/tables/PPSA07391/01.010.000', { method: 'PUT', body: saveFileText(emptyTable('PPSA07391', '01.010.000')) });
const listed = await (await api('/api/tables/PPSA07391')).json();
assert.deepEqual(listed.versions.sort(), ['01.009.000', '01.010.000']);
assert.deepEqual((await (await api('/api/tables/PPSA11111')).json()).versions, []);
const back = parseOwnTable(await (await api('/api/tables/PPSA07391/01.009.000')).text(), { own: true });
assert.equal(back.name, 'Harbor Lights');
assert.deepEqual(back.entries.map((e) => e.name), ['Infinite ammo', 'Big script']);
assert.equal(back.entries[1].unreviewed, undefined);
const foreign = await api('/api/tables/PPSA07391/01.009.000', { method: 'PUT', body: '{}', headers: { origin: 'http://evil.example' } });
assert.equal(foreign.status, 403);
for (const bad of ['/api/tables/PPSA07391/..', '/api/tables/PPSA07391/.hidden', '/api/tables/PPSA07391/a%2Fb', '/api/tables/ppsa07391/1']) {
assert.notEqual((await api(bad, { method: 'PUT', body: '{}' })).status, 204, bad);
}
assert.equal(fs.readdirSync(path.join(FAKE_ROOT, 'data/cheatspecter/cheats')).length, 2);
});
test('closing a debugger connection gets one goodbye, not two', { skip }, async () => {
// Both ends of the relay used to send a close frame when the browser closed
// the socket (the echo, then "the console closed the connection"). Browsers
// report the second one as an error in their console on every normal close.
const frames = await new Promise((resolve, reject) => {
const s = net.connect(PORT, '127.0.0.1');
let data = Buffer.alloc(0);
let upgraded = false;
s.on('connect', () =>
s.write(`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`),
);
s.on('data', (d) => {
data = Buffer.concat([data, d]);
const end = data.indexOf('\r\n\r\n');
if (!upgraded && end >= 0) {
upgraded = true;
data = data.subarray(end + 4);
// A masked close frame with status 1000, as a browser sends.
s.write(Buffer.from([0x88, 0x82, 0, 0, 0, 0, 0x03, 0xe8]));
}
});
s.on('error', reject);
s.on('close', () => {
// Server frames are unmasked and these are all short: opcode byte, length byte, payload.
const seen = [];
for (let at = 0; at + 2 <= data.length; at += 2 + (data[at + 1] & 0x7f)) seen.push(data[at] & 0x0f);
resolve(seen);
});
});
assert.deepEqual(frames, [0x8]);
});