mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-26 01:14:39 +01:00
- Find: text search, grouped search, undo, went up/down by, where to search, before/after compare - Debug tab: breakpoints, stepping, registers, stack, threads, what code touches, structure compare, pointer re-check - Engine tab: Unreal, Unity, Godot, GameMaker and C++ class names; hidden settings and switches - Code cheats with labels, variables, references and shared code; rule builder; script commands - Cheats kept on by the PS5 without the browser - Learn section with twelve lessons and an in-page practice game - Section (master code) community cheats; SHN and MC4 export - Groups, dropdowns, hotkeys, first-cheat guide, re-find after update, restore everything - MCP server: 25 tools - Quick start, wiki, contributor and security guides, changelog
156 lines
8.5 KiB
JavaScript
156 lines
8.5 KiB
JavaScript
// Game metadata parsing, the .cspector file format, and the bridge's
|
|
// pass-through to the payload on the console.
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
|
|
import { startBridge } from '../bridge/server.mjs';
|
|
import { buildParamJson, buildSfo } from '../bridge/demo-library.mjs';
|
|
import { formatSize, parseParamJson, parseParamSfo, parseSfo } from '../src/lib/library.ts';
|
|
import { emptyTable, exportOwnTable, isOwnTableText, parseOwnTable, saveFileText } from '../src/lib/table.ts';
|
|
import { fileVersion, pickVersion, tableFileName } from '../src/lib/tablestore.ts';
|
|
|
|
test('reads a PS5 game name in the game\'s default language', () => {
|
|
assert.deepEqual(parseParamJson(buildParamJson('PPSA07391', 'Harbor Lights', '01.009.000')), {
|
|
name: 'Harbor Lights',
|
|
version: '01.009.000',
|
|
platform: 'PS5',
|
|
});
|
|
const multi = JSON.stringify({
|
|
localizedParameters: { defaultLanguage: 'ja-JP', 'en-US': { titleName: 'English' }, 'ja-JP': { titleName: '日本語' } },
|
|
});
|
|
assert.equal(parseParamJson(multi).name, '日本語');
|
|
const noDefault = JSON.stringify({ localizedParameters: { 'fr-FR': { titleName: 'Français' } } });
|
|
assert.equal(parseParamJson(noDefault).name, 'Français');
|
|
assert.equal(parseParamJson('{"localizedParameters":{}}'), null);
|
|
assert.equal(parseParamJson('not json'), null);
|
|
assert.equal(parseParamJson('[]'), null);
|
|
});
|
|
|
|
test('reads a PS4 param.sfo and survives damaged ones', () => {
|
|
const sfo = buildSfo({ TITLE: 'Old Favorite ™', TITLE_ID: 'CUSA12345', APP_VER: '01.07', PARENTAL_LEVEL: 5 });
|
|
assert.deepEqual(parseSfo(sfo), { APP_VER: '01.07', PARENTAL_LEVEL: 5, TITLE: 'Old Favorite ™', TITLE_ID: 'CUSA12345' });
|
|
assert.deepEqual(parseParamSfo(sfo), { name: 'Old Favorite ™', version: '01.07', platform: 'PS4' });
|
|
assert.equal(parseParamSfo(new Uint8Array(4)), null);
|
|
assert.equal(parseParamSfo(new TextEncoder().encode('{"this":"is json, not an sfo"}')), null);
|
|
for (let cut = 0; cut < sfo.length; cut += 7) assert.doesNotThrow(() => parseParamSfo(sfo.subarray(0, cut)));
|
|
const lying = sfo.slice();
|
|
new DataView(lying.buffer).setUint32(16, 0xffffffff, true); // claims four billion entries
|
|
assert.equal(parseParamSfo(lying), null);
|
|
});
|
|
|
|
test('sizes read the way a console shows them', () => {
|
|
assert.equal(formatSize(0), '');
|
|
assert.equal(formatSize(512), '512 B');
|
|
assert.equal(formatSize(1536), '1.5 KB');
|
|
assert.equal(formatSize(41.6 * 1024 ** 3), '41.6 GB');
|
|
assert.equal(formatSize(150 * 1024 ** 3), '150 GB');
|
|
});
|
|
|
|
const sample = () => {
|
|
const t = emptyTable('PPSA07391', '01.009.000', 'Harbor Lights');
|
|
t.entries.push({ id: 'a', name: 'Infinite ammo', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'FF4B28', note: '' });
|
|
t.entries.push({ id: 'b', name: 'Give gold', type: 'call', fn: { kind: 'module', offset: '0x4100' }, args: [] });
|
|
t.entries.push({ id: 'c', name: 'Looked at', type: 'script', code: 'await cs.sleep(1);' });
|
|
t.entries.push({ id: 'd', name: 'Not looked at', type: 'script', code: 'await cs.sleep(1);', unreviewed: true });
|
|
t.entries.push({ id: 'e', name: 'Session only', type: 'value', at: { kind: 'address', address: '0x880000000' }, valueType: 'u32', value: '1', freeze: false });
|
|
return t;
|
|
};
|
|
|
|
test('a .cspector file is minified JSON with a proper header', () => {
|
|
const text = saveFileText(sample());
|
|
const doc = JSON.parse(text);
|
|
assert.deepEqual(Object.keys(doc), ['format', 'formatVersion', 'game', 'cheats']);
|
|
assert.equal(doc.format, 'cheatspecter');
|
|
assert.equal(doc.formatVersion, 1);
|
|
assert.deepEqual(doc.game, { name: 'Harbor Lights', titleId: 'PPSA07391', version: '01.009.000' });
|
|
// Nothing session-only, nothing empty.
|
|
assert.deepEqual(doc.cheats[0], { name: 'Infinite ammo', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'FF4B28' });
|
|
assert.deepEqual(doc.cheats[1], { name: 'Give gold', type: 'call', fn: { kind: 'module', offset: '0x4100' }, reviewed: true });
|
|
assert.equal(text, JSON.stringify(doc), 'written minified');
|
|
assert.deepEqual(parseOwnTable(JSON.stringify(doc, null, 2), { own: true }).entries.length, doc.cheats.length, 'a hand-formatted file still loads');
|
|
assert.ok(isOwnTableText(text));
|
|
});
|
|
|
|
test('a saved file comes back as it was; a shared one has to be reviewed', () => {
|
|
const own = parseOwnTable(saveFileText(sample()), { own: true });
|
|
assert.deepEqual(own.entries.map((e) => [e.name, e.unreviewed]), [
|
|
['Infinite ammo', undefined],
|
|
['Give gold', undefined],
|
|
['Looked at', undefined],
|
|
['Not looked at', true],
|
|
['Session only', undefined],
|
|
]);
|
|
assert.deepEqual(own.entries[1].args, []);
|
|
assert.equal(new Set(own.entries.map((e) => e.id)).size, 5);
|
|
|
|
// The same bytes arriving as someone else's file: "reviewed" in the file counts for nothing.
|
|
const foreign = parseOwnTable(saveFileText(sample()));
|
|
assert.deepEqual(foreign.entries.filter((e) => e.unreviewed).map((e) => e.name), ['Give gold', 'Looked at', 'Not looked at']);
|
|
|
|
const shared = JSON.parse(exportOwnTable(sample()));
|
|
assert.deepEqual(shared.cheats.map((c) => c.name), ['Infinite ammo', 'Give gold', 'Looked at', 'Not looked at']);
|
|
assert.ok(shared.cheats.every((c) => !('reviewed' in c) && !('id' in c)));
|
|
});
|
|
|
|
test('older exports and newer formats are handled deliberately', () => {
|
|
const legacy = JSON.stringify({ format: 'cheatspecter/1', titleId: 'ppsa07391', version: '01.000.000', name: 'Old', entries: sample().entries });
|
|
const t = parseOwnTable(legacy);
|
|
assert.equal(t.titleId, 'PPSA07391');
|
|
assert.equal(t.entries.length, 5);
|
|
assert.throws(() => parseOwnTable(JSON.stringify({ format: 'cheatspecter', formatVersion: 2, game: {}, cheats: [] })), /newer CheatSpecter/);
|
|
assert.throws(() => parseOwnTable(JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: {} })), /no cheats/);
|
|
});
|
|
|
|
test('file names carry the title ID and game version', () => {
|
|
assert.equal(tableFileName('PPSA18605', '01.009.000'), 'PPSA18605-01.009.000.cspector');
|
|
assert.equal(tableFileName('PPSA18605', ''), 'PPSA18605-unknown.cspector');
|
|
assert.equal(fileVersion('../../etc'), 'etc');
|
|
assert.equal(pickVersion(['01.002.000', '01.010.000', '01.009.000'], '01.009.000'), '01.009.000');
|
|
assert.equal(pickVersion(['01.002.000', '01.010.000', '01.009.000'], '01.011.000'), '01.010.000');
|
|
assert.equal(pickVersion([], '01.000.000'), null);
|
|
});
|
|
|
|
test('the bridge passes library and cheat-file requests on to the console, and only those', async () => {
|
|
// Stands in for the payload on a console.
|
|
const seen = [];
|
|
const payload = http.createServer((req, res) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
seen.push(`${req.method} ${req.url} ${Buffer.concat(chunks).toString()}`.trim());
|
|
res.writeHead(req.method === 'PUT' ? 204 : 200, { 'content-type': 'application/json' });
|
|
res.end(req.method === 'PUT' ? undefined : '{"titles":[]}');
|
|
});
|
|
});
|
|
await new Promise((r) => payload.listen(0, '127.0.0.1', r));
|
|
const bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, payloadPort: payload.address().port });
|
|
await bridge.ready;
|
|
const base = `http://127.0.0.1:${bridge.server.address().port}`;
|
|
try {
|
|
assert.deepEqual(await (await fetch(`${base}/api/library?host=127.0.0.1`)).json(), { titles: [] });
|
|
assert.equal((await fetch(`${base}/api/tables/PPSA07391/01.009.000?host=127.0.0.1`, { method: 'PUT', body: 'hello' })).status, 204);
|
|
assert.equal((await fetch(`${base}/api/launch/PPSA07391?host=127.0.0.1`, { method: 'POST' })).status, 200);
|
|
assert.deepEqual(seen, ['GET /api/library', 'PUT /api/tables/PPSA07391/01.009.000 hello', 'POST /api/launch/PPSA07391']);
|
|
|
|
// Not a console on the local network, not a path the payload has, not this page's origin.
|
|
assert.equal((await fetch(`${base}/api/library?host=example.com`)).status, 400);
|
|
assert.equal((await fetch(`${base}/api/library?host=8.8.8.8`)).status, 400);
|
|
assert.equal((await fetch(`${base}/api/appmeta/PPSA07391/..%2F..%2Fetc?host=127.0.0.1`)).status, 400);
|
|
assert.equal((await fetch(`${base}/api/launch/PPSA07391?host=127.0.0.1`)).status, 405);
|
|
const foreign = await fetch(`${base}/api/launch/PPSA07391?host=127.0.0.1`, { method: 'POST', headers: { origin: 'http://evil.example' } });
|
|
assert.equal(foreign.status, 403);
|
|
assert.equal(seen.length, 3);
|
|
|
|
// No payload on that console.
|
|
payload.close();
|
|
payload.closeAllConnections();
|
|
const gone = await fetch(`${base}/api/library?host=127.0.0.1`);
|
|
assert.equal(gone.status, 502);
|
|
} finally {
|
|
await bridge.close();
|
|
payload.close();
|
|
}
|
|
});
|