Files
2026-10-12 01:39:58 -07:00

572 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Game patches in the PS4-PS5-Game-Patch XML format (src/lib/gamepatch.ts):
// reading files, what each kind of line writes, which patches fit the running
// version, finding a game's file in the repository with as few downloads as
// possible, and turning patches into .cspector entries against the mock game.
// The fixtures under test/fixtures/gamepatch/ are small hand-made files in
// the real format.
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 { createRequire } from 'node:module';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
// Browser storage, for what the lookups remember between visits.
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
import { startMock, GAME } from '../mock/mock-ps5.mjs';
import { startBridge } from '../bridge/server.mjs';
import {
DEFAULT_IMAGE_BASE,
GAME_PATCH_SOURCE_ID,
PatchRefused,
convertPatch,
findGamePatches,
folderFor,
checkGamePatches,
gamePatchOrigin,
gamePatchSource,
lineBytes,
nameScore,
otherVersions,
parseGamePatchFile,
parseTree,
patchesForGame,
patchesForVersion,
rawUrl,
samePatch,
savedGamePatches,
versionMatch,
} from '../src/lib/gamepatch.ts';
import { bytesToHex } from '../src/lib/bytes.ts';
import { createGameContext } from '../src/lib/locations.ts';
import { Ps5Debug } from '../src/lib/ps5debug.ts';
import { TableRunner, emptyTable, keepProblem, parseOwnTable, saveFileText, withSource } from '../src/lib/table.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 FIXTURES = path.join(ROOT, 'test/fixtures/gamepatch');
const fixture = (name) => fs.readFileSync(path.join(FIXTURES, name), 'utf8');
const SPECTER = parseGamePatchFile(fixture('SpecterRun.xml'));
const byName = (file, name, appVer) => file.patches.find((p) => p.name === name && (!appVer || p.appVer === appVer));
const hex = (bytes) => bytesToHex(bytes);
const line = (type, value, extra = {}) => ({ type, address: '0x400000', value, offset: '', target: '', size: '', ...extra });
// ---- reading files -----------------------------------------------------------------
test('a file: its title IDs, and every patch with what it says about itself', () => {
assert.deepEqual(SPECTER.titleIds, ['PPSA99999', 'PPSA99998']);
assert.equal(SPECTER.patches.length, 8);
const ammo = byName(SPECTER, 'Infinite ammo', '01.000.000');
assert.deepEqual(
{ ...ammo, key: undefined },
{
title: 'Specter Run',
name: 'Infinite ammo',
note: 'Shots no longer use ammo.',
author: 'tester',
patchVer: '1.0',
appVer: '01.000.000',
appElf: 'eboot.bin',
imageBase: '0x10000000',
lines: [{ type: 'bytes', address: '0x10002040', value: '909090', offset: '', target: '', size: '' }],
key: undefined,
},
);
// Entities are decoded; comments inside and after lines are ignored.
assert.equal(SPECTER.patches[1].name, '60 FPS & no motion blur');
assert.equal(SPECTER.patches[1].lines.length, 6);
// Keys tell the patches apart, including the two "Infinite ammo" for different versions, and do not change between reads.
assert.equal(new Set(SPECTER.patches.map((p) => p.key)).size, SPECTER.patches.length);
assert.deepEqual(parseGamePatchFile(fixture('SpecterRun.xml')).patches.map((p) => p.key), SPECTER.patches.map((p) => p.key));
assert.match(ammo.key, /^[0-9a-f]{16}$/);
});
test('damaged and foreign files are refused with a reason, not guessed at', () => {
assert.throws(() => parseGamePatchFile(fixture('Broken.xml')), /cut short \(<PatchList> is never closed\)/);
assert.throws(() => parseGamePatchFile('<Cheats><ID>PPSA99999</ID></Cheats>'), /not a game patch file/);
assert.throws(() => parseGamePatchFile('<Patch></Metadata></Patch>'), /not valid XML/);
assert.throws(() => parseGamePatchFile('<Patch><!-- never closed'), /cut short/);
assert.throws(() => parseGamePatchFile('{"name":"json"}'), /not a game patch file/);
// A byte-order mark, CDATA, single quotes and character references are fine.
const ok = parseGamePatchFile("<?xml version='1.0'?><Patch><TitleID><ID>cusa00001</ID><ID>junk</ID></TitleID><Metadata Name='A &#x26; B' AppVer='01.00'><PatchList><Line Type='bytes' Address='0x400000' Value='90'/></PatchList></Metadata><![CDATA[<ignored/>]]></Patch>");
assert.deepEqual(ok.titleIds, ['CUSA00001']);
assert.equal(ok.patches[0].name, 'A & B');
});
// ---- what each line writes ---------------------------------------------------------
test('whole numbers: hex with 0x, # or $, decimal (negative too), little-endian, and nothing that does not fit', () => {
assert.equal(hex(lineBytes(line('byte', '0x7f'))), '7F');
assert.equal(hex(lineBytes(line('byte', '255'))), 'FF');
assert.equal(hex(lineBytes(line('byte', '-1'))), 'FF');
assert.equal(hex(lineBytes(line('bytes16', '#1234'))), '3412');
assert.equal(hex(lineBytes(line('bytes32', '0x3c'))), '3C000000');
assert.equal(hex(lineBytes(line('mask_bytes32', '60'))), '3C000000');
assert.equal(hex(lineBytes(line('bytes64', '$0102030405060708'))), '0807060504030201');
assert.equal(hex(lineBytes(line('bytes64', '0xffffffffffffffff'))), 'FFFFFFFFFFFFFFFF');
assert.throws(() => lineBytes(line('byte', '0x1ff')), (e) => e instanceof PatchRefused && /0x1ff does not fit in 1 byte/.test(e.message));
assert.throws(() => lineBytes(line('bytes16', '-40000')), /does not fit in 2 bytes/);
assert.throws(() => lineBytes(line('bytes32', 'lots')), /"lots" is not a number/);
assert.throws(() => lineBytes(line('bytes32', '')), /no value/);
});
test('bytes, floats and strings', () => {
assert.equal(hex(lineBytes(line('bytes', 'e937010000'))), 'E937010000');
assert.equal(hex(lineBytes(line('mask', '90 90'))), '9090', 'spaces are let through');
assert.equal(hex(lineBytes(line('bytes', 'abc'))), '0ABC', 'an odd count gets a leading zero, as the plugins read it');
assert.throws(() => lineBytes(line('bytes', '90zz')), /not written as hex bytes/);
assert.equal(hex(lineBytes(line('float32', '1.0'))), '0000803F');
assert.equal(hex(lineBytes(line('mask_float64', '-0.5'))), '000000000000E0BF');
assert.throws(() => lineBytes(line('float32', 'fast')), /not a number/);
// Strings end with a zero, as the plugins write them; escapes are theirs.
assert.equal(hex(lineBytes(line('utf8', 'Hi'))), '486900');
assert.equal(hex(lineBytes(line('utf8', 'a\\nb\\x21\\\\'))), '610A62215C00');
assert.equal(hex(lineBytes(line('utf8', 'é😀'))), 'C3A9F09F988000');
assert.equal(hex(lineBytes(line('utf16', 'Hi\\x21'))), '4800690021000000');
});
test('lines that cannot be applied safely are refused with a plain reason', () => {
assert.throws(() => lineBytes(line('mask_jump32', 'c3')), /writes new code into free space/);
assert.throws(() => lineBytes(line('patchCall', 'c3')), /changes the function a call in the game leads to/);
assert.throws(() => lineBytes(line('mask_patchCall', 'c3')), /changes the function a call/);
assert.throws(() => lineBytes(line('teleport', '1')), /uses a kind of line \("teleport"\) CheatSpecter does not know/);
assert.throws(() => lineBytes(line('', '1')), /does not say what kind it is/);
});
// ---- places ------------------------------------------------------------------------
test('addresses become offsets into the game, by the patch\'s ImageBase, 0x400000 when it has none, as is when it is 0', () => {
assert.equal(DEFAULT_IMAGE_BASE, 0x400000n);
assert.deepEqual(convertPatch(byName(SPECTER, 'Infinite ammo', '01.000.000')), [{ at: { kind: 'module', offset: '2040' }, on: '909090' }]);
const ps4 = parseGamePatchFile(fixture('TestGame-Orbis.xml'));
assert.deepEqual(convertPatch(byName(ps4, 'Skip intro')), [{ at: { kind: 'module', offset: '2040' }, on: 'EB' }]);
assert.deepEqual(convertPatch(byName(ps4, 'Relative')), [{ at: { kind: 'module', offset: '2040' }, on: 'EB' }]);
assert.throws(() => convertPatch(byName(ps4, 'Below the base')), /the address 0x00300000 is before the start of the game's code/);
// Every line is one place, in order.
const fps = convertPatch(byName(SPECTER, '60 FPS & no motion blur'));
assert.deepEqual(fps.map((s) => [s.at.offset, s.on]), [
['10800', '3C000000'],
['10810', '0000C842'],
['10820', '00'],
['10822', 'FEFF'],
['10828', '0807060504030201'],
['10830', '000000000000E0BF'],
]);
});
test('"mask" lines become byte patterns with the distance from the match', () => {
assert.deepEqual(convertPatch(byName(SPECTER, 'Free shopping')), [{ at: { kind: 'signature', pattern: 'F3 0F 5C C1', offset: 0 }, on: '90909090' }]);
const sites = convertPatch(byName(SPECTER, 'Rename the debug flag'));
assert.deepEqual(sites[2], { at: { kind: 'signature', pattern: 'FF 4B ??', offset: -3 }, on: '01000000' });
assert.equal(sites[0].on, hex(new TextEncoder().encode('debug_menu_shown\0')));
const bad = { ...byName(SPECTER, 'Free shopping'), lines: [line('mask', '90', { address: 'not a pattern' })] };
assert.throws(() => convertPatch(bad), /"not a pattern" is not a byte pattern/);
const far = { ...byName(SPECTER, 'Free shopping'), lines: [line('mask', '90', { address: 'F3 0F', offset: 'later' })] };
assert.throws(() => convertPatch(far), /"later" is not a distance/);
});
test('a patch is used whole or not at all: one bad line refuses all of it, and so does another program', () => {
assert.throws(() => convertPatch(byName(SPECTER, 'Bigger draw distance')), /writes new code/);
assert.throws(() => convertPatch(byName(SPECTER, 'Photo mode in the launcher')), /it is for launcher\.elf, a different program of the game/);
// Unless that program is the one running.
assert.equal(convertPatch(byName(SPECTER, 'Photo mode in the launcher'), 'launcher.elf').length, 1);
});
test('which patches fit the running version: exactly its own, and the ones made for any version', () => {
assert.equal(versionMatch('01.000.000', '01.000.000'), 'exact');
assert.equal(versionMatch('1.0', '01.000.000'), 'exact', 'the same version written with less padding');
assert.equal(versionMatch('01.000.001', '01.000.000'), null);
assert.equal(versionMatch('mask', '01.000.000'), 'any');
assert.equal(versionMatch('All', '9'), 'any');
assert.equal(versionMatch('01.000.000', ''), null, 'with no running version known, nothing is exact');
const lookup = { titleId: 'PPSA99999', file: { path: 'x', sha: 's', url: 'u' }, patches: SPECTER.patches, stale: false, read: 1, unread: 0 };
const choices = patchesForVersion(lookup, '01.000.000');
assert.deepEqual(
choices.map((c) => [c.patch.name, c.match, c.problem && c.problem.replace(/:.*/, '')]),
[
['Infinite ammo', 'exact', null],
['60 FPS & no motion blur', 'exact', null],
['Rename the debug flag', 'exact', null],
['Bigger draw distance', 'exact', 'This patch cannot be used'],
['Photo mode in the launcher', 'exact', 'This patch cannot be used'],
['Too big', 'exact', 'This patch cannot be used'],
['Free shopping', 'any', null],
],
);
assert.deepEqual(otherVersions(lookup, '01.000.000'), ['00.900.000']);
assert.deepEqual(otherVersions(lookup, '00.900.000'), ['01.000.000'], '"1.0" is the same version, written shorter');
assert.deepEqual(patchesForVersion(lookup, '02.000.000').map((c) => c.patch.name), ['Free shopping']);
});
// ---- finding a game's file ----------------------------------------------------------
/** Stands in for GitHub: the listing and the fixture files, counting what is asked for. */
function fakeGitHub(overrides = {}) {
const asked = [];
const fetchText = async (url) => {
asked.push(url);
if (overrides[url]) return overrides[url]();
if (url.startsWith('https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main')) return fixture('tree.json');
const m = /^https:\/\/raw\.githubusercontent\.com\/illusion0001\/PS4-PS5-Game-Patch\/main\/patches\/xml(?:_prospero)?\/(.+)$/.exec(url);
if (m && fs.existsSync(path.join(FIXTURES, decodeURIComponent(m[1])))) return fixture(decodeURIComponent(m[1]));
throw new Error('HTTP 404');
};
return { fetchText, asked, files: () => asked.filter((u) => u.startsWith('https://raw.')).map((u) => u.slice(u.lastIndexOf('/') + 1)) };
}
test('the listing: only patch files directly in the PS4 and PS5 folders', () => {
assert.deepEqual(parseTree(fixture('tree.json')).map((f) => f.path), [
'patches/xml/TestGame-Orbis.xml',
'patches/xml_prospero/Broken.xml',
'patches/xml_prospero/OtherGame.xml',
'patches/xml_prospero/SpecterRun.xml',
]);
assert.throws(() => parseTree('<html>rate limited</html>'), /not a file list/);
assert.throws(() => parseTree('{"message":"API rate limit exceeded"}'), /not a file list/);
assert.equal(folderFor('PPSA01575'), 'patches/xml_prospero/');
assert.equal(folderFor('CUSA00001'), 'patches/xml/');
assert.equal(rawUrl('patches/xml/Marvel\'s Spider-Man.xml'), "https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/patches/xml/Marvel's%20Spider-Man.xml");
// The likeliest files by name are read first.
assert.ok(nameScore('patches/xml_prospero/SpecterRun.xml', 'Specter Run') > nameScore('patches/xml_prospero/OtherGame.xml', 'Specter Run'));
assert.ok(nameScore('patches/xml/DemonSouls-Orbis.xml', "Demon's Souls") >= 50);
assert.equal(nameScore('patches/xml/X.xml', ''), 0);
});
test('finds the game\'s file reading the likeliest first, then remembers it: the next look downloads nothing', async () => {
store.clear();
const gh = fakeGitHub();
const progress = [];
const found = await findGamePatches('ppsa99999', { gameName: 'Specter Run', fetchText: gh.fetchText, onProgress: (d, t) => progress.push([d, t]) });
assert.equal(found.titleId, 'PPSA99999');
assert.equal(found.file.path, 'patches/xml_prospero/SpecterRun.xml');
assert.equal(found.file.url, 'https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/patches/xml_prospero/SpecterRun.xml');
assert.equal(found.patches.length, 8);
assert.equal(found.stale, false);
// The PS5 folder only, SpecterRun.xml first by its name; the others may be read alongside it, at most.
assert.equal(gh.files()[0], 'SpecterRun.xml');
assert.ok(!gh.files().includes('TestGame-Orbis.xml'));
assert.deepEqual(progress[0], [0, 3]);
const again = fakeGitHub();
const second = await findGamePatches('PPSA99999', { fetchText: again.fetchText });
assert.equal(second.file.path, 'patches/xml_prospero/SpecterRun.xml');
assert.deepEqual(again.asked, [], 'the listing is trusted for a while, and the file was kept');
assert.equal(second.read, 0);
});
test('a game no file covers: every unseen file of its folder is read once, then the answer comes without downloads', async () => {
store.clear();
const gh = fakeGitHub();
const none = await findGamePatches('PPSA55555', { fetchText: gh.fetchText });
assert.equal(none.file, null);
assert.deepEqual(none.patches, []);
assert.deepEqual(gh.files().sort(), ['Broken.xml', 'OtherGame.xml', 'SpecterRun.xml']);
const again = fakeGitHub();
assert.equal((await findGamePatches('PPSA55555', { fetchText: again.fetchText })).file, null);
assert.deepEqual(again.asked, []);
// Another game in the same folder is now known without reading anything either.
const other = await findGamePatches('PPSA11111', { fetchText: again.fetchText });
assert.equal(other.file.path, 'patches/xml_prospero/OtherGame.xml');
assert.deepEqual(again.files(), ['OtherGame.xml'], 'only the file itself, to have its patches');
// A PS4 game looks in the PS4 folder.
const ps4 = await findGamePatches('CUSA00001', { fetchText: again.fetchText });
assert.equal(ps4.file.path, 'patches/xml/TestGame-Orbis.xml');
});
test('a look can be told not to read a whole folder without asking: it says how many files are left', async () => {
store.clear();
const gh = fakeGitHub();
const ask = await findGamePatches('PPSA55555', { fetchText: gh.fetchText, maxReads: 2 });
assert.deepEqual([ask.file, ask.unread], [null, 3]);
assert.deepEqual(gh.files(), []);
// Once the person agrees.
const all = await findGamePatches('PPSA55555', { fetchText: gh.fetchText });
assert.deepEqual([all.file, all.unread], [null, 0]);
// From then on nothing is left to read, so the limit no longer stops it.
assert.equal((await findGamePatches('PPSA11111', { fetchText: gh.fetchText, maxReads: 0 })).file.path, 'patches/xml_prospero/OtherGame.xml');
});
test('a file that changed online is read again; one that is gone is forgotten', async () => {
store.clear();
await findGamePatches('PPSA99999', { fetchText: fakeGitHub().fetchText });
// Twelve hours on, the listing is asked for again, and SpecterRun.xml has a new hash.
const tree = JSON.parse(fixture('tree.json'));
tree.tree.find((t) => t.path.endsWith('SpecterRun.xml')).sha = 'f'.repeat(40);
tree.tree = tree.tree.filter((t) => !t.path.endsWith('OtherGame.xml'));
const saved = JSON.parse(store.get('cheatspecter.gamepatch.tree'));
store.set('cheatspecter.gamepatch.tree', JSON.stringify({ ...saved, at: 0 }));
const gh = fakeGitHub({ 'https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main?recursive=1': async () => JSON.stringify(tree) });
const found = await findGamePatches('PPSA99999', { fetchText: gh.fetchText });
assert.equal(found.file.sha, 'f'.repeat(40));
assert.deepEqual(gh.files(), ['SpecterRun.xml']);
const ids = JSON.parse(store.get('cheatspecter.gamepatch.ids'));
assert.equal(ids['patches/xml_prospero/OtherGame.xml'], undefined);
});
test('offline: the saved copy is used and said to be old; with nothing saved, the reason comes through', async () => {
store.clear();
await findGamePatches('PPSA99999', { fetchText: fakeGitHub().fetchText });
const saved = JSON.parse(store.get('cheatspecter.gamepatch.tree'));
store.set('cheatspecter.gamepatch.tree', JSON.stringify({ ...saved, at: 0 }));
const offline = async () => {
throw new Error('the download timed out');
};
const stale = await findGamePatches('PPSA99999', { fetchText: offline });
assert.equal(stale.stale, true);
assert.equal(stale.patches.length, 8);
store.clear();
await assert.rejects(findGamePatches('PPSA99999', { fetchText: offline }), /timed out/);
// GitHub refusing to list (rate limited) is a failure like any other, said in its words.
await assert.rejects(findGamePatches('PPSA99999', { fetchText: async () => '{"message":"API rate limit exceeded"}' }), /not a file list/);
});
test('a look can be canceled; what was learned up to then is kept', async () => {
store.clear();
const stop = new AbortController();
const gh = fakeGitHub();
const slow = async (url, signal) => {
if (url.startsWith('https://raw.')) {
stop.abort();
if (signal?.aborted) throw new DOMException('Canceled', 'AbortError');
}
return gh.fetchText(url);
};
await assert.rejects(findGamePatches('PPSA55555', { fetchText: slow, signal: stop.signal }), (e) => e.name === 'AbortError');
});
// ---- entries, against the mock game ------------------------------------------------------
const require = createRequire(import.meta.url);
const x86 = asAsync(createX86(require('iced-x86')));
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
const TCP = 17891 + PORT_OFFSET;
let mock, bridge, port;
before(async () => {
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
await mock.ready;
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 't' } });
port = await bridge.ready;
});
after(async () => {
await bridge?.close();
await mock?.close();
});
const context = async () => {
const dbg = new Ps5Debug(await openWebSocketStream(`ws://127.0.0.1:${port}/ws?host=127.0.0.1&port=${TCP}`), { timeoutMs: 4000 });
return { dbg, ctx: createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 }) };
};
const imageHex = (offset, length) => bytesToHex(mock.game.image.subarray(offset, offset + length));
test('patchesForGame: exact-version patches as entries, with what the game holds now as "off", and where they came from', async () => {
store.clear();
const { dbg, ctx } = await context();
try {
const res = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { ctx, fetchText: fakeGitHub().fetchText, gameName: 'Specter Run' });
assert.equal(res.source.path, 'patches/xml_prospero/SpecterRun.xml');
assert.deepEqual(res.otherVersions, ['00.900.000']);
assert.deepEqual(res.entries.map((e) => e.name), ['Infinite ammo', '60 FPS & no motion blur', 'Rename the debug flag', 'Free shopping']);
assert.deepEqual(res.refused.map((r) => r.name), ['Bigger draw distance', 'Photo mode in the launcher', 'Too big']);
assert.match(res.refused[0].reason, /^This patch cannot be used: it writes new code into free space/);
assert.match(res.refused[2].reason, /0x1ff does not fit in 1 byte/);
const ammo = res.entries[0];
assert.equal(ammo.type, 'patch');
assert.deepEqual(ammo.at, { kind: 'module', offset: '2040' });
assert.equal(ammo.on, '909090');
assert.equal(ammo.off, 'FF4B28', 'read from the game');
assert.equal(ammo.more, undefined);
assert.equal(ammo.note, 'Shots no longer use ammo. By tester.');
// The same origin every cheat saved from elsewhere has (EntryOrigin in table.ts), plus the patch's own key, version and author.
assert.deepEqual(ammo.origin, {
source: GAME_PATCH_SOURCE_ID,
file: 'patches/xml_prospero/SpecterRun.xml',
format: 'xml',
key: byName(SPECTER, 'Infinite ammo', '01.000.000').key,
version: '01.000.000',
author: 'tester',
});
assert.equal(gamePatchOrigin(ammo), ammo.origin);
assert.equal(gamePatchOrigin({ origin: { source: 'etahen', file: 'x', format: 'json' } }), null);
// What is true of the whole file is said once, on the source the entries are saved under.
assert.equal(res.gameName, 'Specter Run');
const src = gamePatchSource(res, GAME, new Date('2026-10-12T00:00:00Z'));
assert.deepEqual({ ...src, cheats: undefined, digest: undefined }, {
id: 'gamepatch/patches/xml_prospero/SpecterRun.xml',
kind: 'gamepatch',
source: 'gamepatch',
sourceName: 'PS4-PS5-Game-Patch',
file: 'patches/xml_prospero/SpecterRun.xml',
format: 'xml',
url: 'https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/patches/xml_prospero/SpecterRun.xml',
sha: 'b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2',
version: GAME.version,
savedAt: '2026-10-12T00:00:00.000Z',
name: 'Specter Run',
credits: ['tester', 'tester, helper'],
cheats: undefined,
digest: undefined,
});
assert.equal(src.cheats, res.entries);
// Several lines: the first place, then the rest.
const fps = res.entries[1];
assert.equal(fps.more.length, 5);
// These places are in the game's data, which starts 0x10000 into it.
const dataHex = (offset, length) => bytesToHex(mock.game.data.subarray(offset - 0x10000, offset - 0x10000 + length));
assert.equal(fps.off, dataHex(0x10800, 4));
assert.deepEqual(fps.more.map((m) => m.off), [[0x10810, 4], [0x10820, 1], [0x10822, 2], [0x10828, 8], [0x10830, 8]].map(([o, n]) => dataHex(o, n)));
// A pattern gets the fixed position it was found at.
const shop = res.entries[3];
assert.deepEqual(shop.at, { kind: 'signature', pattern: 'F3 0F 5C C1', offset: 0 });
assert.deepEqual(shop.fallback, { kind: 'module', offset: '30A0' });
assert.equal(shop.off, 'F30F5CC1');
// Saved into a .cspector file as a source they come back the same, origin included, and so does the source.
const table = withSource(emptyTable(GAME.titleId, GAME.version, 'Specter Run'), src);
const back = parseOwnTable(saveFileText(table), { own: true });
const plain = (list) => JSON.parse(JSON.stringify(list.map((e) => ({ ...e, id: undefined }))));
assert.equal(back.entries.length, 0, 'not among the person\'s own cheats');
assert.deepEqual(plain(back.sources[0].cheats), plain(res.entries));
assert.deepEqual({ ...back.sources[0], cheats: undefined }, { ...src, cheats: undefined });
assert.equal(savedGamePatches(back.sources, GAME.version).id, src.id);
assert.equal(savedGamePatches(back.sources, '02.000.000'), null, 'only for the version they were saved for');
assert.ok(back.sources[0].cheats.every((e) => gamePatchOrigin(e)), 'still told apart as game patches');
// Copied into the person's own cheats ("Add to your cheats") a patch is theirs: it loses its origin, can be kept on, and is still known as the same patch.
const mine = parseOwnTable(saveFileText({ ...emptyTable(GAME.titleId, GAME.version), entries: res.entries }), { own: true });
assert.ok(mine.entries.every((e) => e.origin === undefined));
for (const e of mine.entries) assert.equal(keepProblem(e), null, e.name);
assert.ok(samePatch(mine.entries[0], back.sources[0].cheats[0]));
assert.ok(!samePatch(mine.entries[0], back.sources[0].cheats[1]));
// Switched on and off like any patch.
const runner = new TableRunner(ctx);
await runner.enable(ammo);
assert.equal(imageHex(0x2040, 3), '909090');
await runner.disable(ammo);
assert.equal(imageHex(0x2040, 3), 'FF4B28');
await runner.enable(shop);
assert.equal(imageHex(0x30a0, 4), '90909090');
await runner.disable(shop);
assert.equal(imageHex(0x30a0, 4), 'F30F5CC1');
// A patch already in the game (applied at boot by another tool): "off" cannot be known, so it is left out.
mock.game.image.fill(0x90, 0x2040, 0x2043);
const again = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { ctx, fetchText: fakeGitHub().fetchText });
assert.equal(again.entries[0].off, undefined);
GAME.code.ammoSpend.bytes.copy(mock.game.image, 0x2040);
} finally {
dbg.close();
}
});
test('without the game, entries are made all the same, just without "off"; another version gets none', async () => {
store.clear();
const res = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { fetchText: fakeGitHub().fetchText });
assert.equal(res.entries.length, 4);
assert.ok(res.entries.every((e) => e.off === undefined && (e.more ?? []).every((m) => m.off === undefined)));
const other = await patchesForGame({ titleId: GAME.titleId, version: '02.000.000' }, { fetchText: fakeGitHub().fetchText });
assert.deepEqual(other.entries.map((e) => e.name), ['Free shopping']);
assert.deepEqual(other.otherVersions, ['00.900.000', '01.000.000']);
const none = await patchesForGame({ titleId: 'PPSA55555', version: '01.000.000' }, { fetchText: fakeGitHub().fetchText });
assert.deepEqual(none, { source: null, gameName: '', entries: [], refused: [], otherVersions: [], stale: false });
assert.equal(gamePatchSource(none, GAME), null, 'nothing to save');
assert.deepEqual((await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { lookup: { titleId: GAME.titleId, file: null, patches: [], stale: false, read: 0, unread: 9 } })).entries, []);
});
test('Check for updates on saved game patches: an unchanged file is not read again; a changed one says what changed', async () => {
store.clear();
const first = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { fetchText: fakeGitHub().fetchText });
const src = gamePatchSource(first, GAME);
// The same file (same hash in the listing): nothing downloaded but the listing, asked for afresh when the person presses the button.
const gh = fakeGitHub();
const same = await checkGamePatches(src, GAME, { fetchText: gh.fetchText, fresh: true });
assert.equal(same.same, true);
assert.equal(same.fresh, null);
assert.deepEqual(gh.files(), []);
assert.equal(gh.asked.length, 1, 'only the listing');
assert.deepEqual(same.result.refused.map((r) => r.name), ['Bigger draw distance', 'Photo mode in the launcher', 'Too big'], 'what cannot be used is still said');
// The file changed online: a new hash, and one patch writes something else.
const tree = JSON.parse(fixture('tree.json'));
tree.tree.find((t) => t.path.endsWith('SpecterRun.xml')).sha = 'e'.repeat(40);
const changedXml = fixture('SpecterRun.xml').replace('Value="909090"', 'Value="909091"');
const gh2 = fakeGitHub({
'https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main?recursive=1': async () => JSON.stringify(tree),
[rawUrl('patches/xml_prospero/SpecterRun.xml')]: async () => changedXml,
});
const changed = await checkGamePatches(src, GAME, { fetchText: gh2.fetchText, fresh: true });
assert.equal(changed.same, false);
assert.equal(changed.fresh.sha, 'e'.repeat(40));
assert.deepEqual(changed.changes, { added: [], removed: [], changed: ['Infinite ammo'] });
// GitHub out of reach: the check fails with the reason, rather than answer from the copy kept in this browser.
const saved = JSON.parse(store.get('cheatspecter.gamepatch.tree'));
store.set('cheatspecter.gamepatch.tree', JSON.stringify({ ...saved, at: 0 }));
await assert.rejects(
checkGamePatches(src, GAME, {
fetchText: async () => {
throw new Error('the download timed out');
},
}),
/could not be reached|timed out/,
);
});
// ---- kept on by the PS5 ------------------------------------------------------------
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
let skipPayload = 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 {
skipPayload = 'needs a C compiler and a built web app (npm run build)';
}
test('converted patches marked "Keep on" are switched on by the PS5 itself', { skip: skipPayload }, async () => {
store.clear();
const res = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { fetchText: fakeGitHub().fetchText });
const kept = res.entries.filter((e) => ['Infinite ammo', 'Free shopping'].includes(e.name)).map((e) => ({ ...e, keep: true }));
const fake = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-gamepatch-'));
fs.mkdirSync(path.join(fake, 'data/cheatspecter/cheats'), { recursive: true });
fs.writeFileSync(path.join(fake, `data/cheatspecter/cheats/${GAME.titleId}-${GAME.version}.cspector`), saveFileText({ ...emptyTable(GAME.titleId, GAME.version, 'Specter Run'), entries: kept }));
const PORT = 19171 + PORT_OFFSET;
const proc = spawn(BIN, [], {
env: { ...process.env, CS_PORT: String(PORT), CS_DEBUGGER_PORT: String(TCP), CS_EVENT_PORT: String(17991 + PORT_OFFSET), CS_ROOT: fake, CS_SHADOWMOUNT_PORT: '1', CS_ENGINE_SPEED: '10' },
stdio: 'ignore',
});
try {
let st;
for (let i = 0; i < 300; i++) {
st = await fetch(`http://127.0.0.1:${PORT}/api/engine`).then((r) => r.json()).catch(() => null);
if (st?.cheats?.length === 2 && st.cheats.every((c) => c.state === 'on')) break;
await new Promise((r) => setTimeout(r, 30));
}
assert.deepEqual(st.cheats.map((c) => [c.name, c.state]), [['Infinite ammo', 'on'], ['Free shopping', 'on']]);
assert.equal(imageHex(0x2040, 3), '909090');
assert.equal(imageHex(0x30a0, 4), '90909090');
} finally {
proc.kill();
await new Promise((r) => setTimeout(r, 100));
GAME.code.ammoSpend.bytes.copy(mock.game.image, 0x2040);
GAME.code.goldSpend.bytes.copy(mock.game.image, 0x30a0);
fs.rmSync(fake, { recursive: true, force: true });
}
});