mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-18 07:15:16 +02:00
572 lines
31 KiB
JavaScript
572 lines
31 KiB
JavaScript
// 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 & 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 });
|
||
}
|
||
});
|