// 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 .cspecter 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 \( is never closed\)/); assert.throws(() => parseGamePatchFile('PPSA99999'), /not a game patch file/); assert.throws(() => parseGamePatchFile(''), /not valid XML/); assert.throws(() => parseGamePatchFile('