From cadee776ea057c03e569605db868212d2052d537 Mon Sep 17 00:00:00 2001 From: beerpsi Date: Sun, 27 Oct 2024 02:44:51 +0700 Subject: [PATCH] deno is kinda nice --- README.md | 8 +- patchers/_parser.ts | 58 +++++++++++++++ patchers/_types.ts | 137 +++++++++++++++++++++++++++++++++++ patchers/b2mpatch.ts | 138 +++++++++++++++++++++++++++++++++++ patchers/b2spatch.mjs | 165 ------------------------------------------ patchers/b2spatch.ts | 143 ++++++++++++++++++++++++++++++++++++ 6 files changed, 482 insertions(+), 167 deletions(-) create mode 100644 patchers/_parser.ts create mode 100644 patchers/_types.ts create mode 100644 patchers/b2mpatch.ts delete mode 100644 patchers/b2spatch.mjs create mode 100644 patchers/b2spatch.ts diff --git a/README.md b/README.md index cc06a8b..647f397 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,14 @@ disk image. - Dependencies: `pip install construct PyCryptodome` ## Patchers -- `patchers/b2spatch.mjs`: Convert from BemaniPatcher to Spice2x JSON - - Requires `cheerio`: `npm i cheerio` +- `patchers/b2spatch.ts`: Convert from BemaniPatcher to Spice2x JSON + - Use Deno: `deno run -WNE b2spatch.ts ...` - You will need to rename the resulting JSON manually so that they match the `{gameCode}-{timestamp}_{entrypoint}` format expected by Spice2x. +- `patchers/b2mpatch.ts`: Convert from BemaniPatcher to [mempatch-hook](https://github.com/djhackersdev/bemanitools/blob/master/doc/tools/mempatch-hook.md) + - Use Deno: `deno run -A b2mpatch.ts ...` + - You need to setup a folder of executables or DLLs you want to patch. They + can be named the patcher's description, or the patcher's filename. ## Arcaea - `acaca/arcpack.py`: Script to extract Arcaea Nintendo Switch `arc.pack` files diff --git a/patchers/_parser.ts b/patchers/_parser.ts new file mode 100644 index 0000000..4222e7b --- /dev/null +++ b/patchers/_parser.ts @@ -0,0 +1,58 @@ +import vm from "node:vm"; +import * as cheerio from "npm:cheerio"; +import { BemaniPatch, BemaniPatcherArgs } from "./_types.ts"; + +export function bytesToHex(bytes: number[]) { + return bytes.map((b) => b.toString(16).padStart(2, "0").toUpperCase()).join( + "", + ); +} + +export function parsePatcherHtml(filename: string, contents: string) { + const $ = cheerio.load(contents); + + let script = ""; + + for (const element of $("script").get()) { + const code = $(element).html(); + + if (!code?.includes("new Patcher")) { + continue; + } + + script += code; + script += "\n"; + } + + if (script.length === 0) { + console.warn( + `Failed to find any BemaniPatcher patches in ${filename}.`, + ); + return []; + } + + const patchers: BemaniPatcherArgs[] = []; + const context = { + window: { + addEventListener: (_type: string, listener: () => unknown) => + listener(), + }, + Patcher: class { + constructor( + fname: string, + description: string, + patches: BemaniPatch[], + ) { + patchers.push({ fname, description, patches }); + } + }, + PatchContainer: class { + constructor(_patchers: unknown[]) {} + }, + }; + + vm.createContext(context); + vm.runInContext(script, context); + + return patchers; +} diff --git a/patchers/_types.ts b/patchers/_types.ts new file mode 100644 index 0000000..1acaa25 --- /dev/null +++ b/patchers/_types.ts @@ -0,0 +1,137 @@ +type BemaniBasePatch = { + name: string; + tooltip?: string; + danger?: string; +}; + +export type BemaniStandardPatch = BemaniBasePatch & { + type: undefined; + patches: { + offset: number; + on: number[]; + off: number[]; + }[]; +}; + +export type BemaniUnionPatch = BemaniBasePatch & { + type: "union"; + offset: number; + patches: { + name: string; + patch: number[]; + }[]; +}; + +export type BemaniNumberPatch = BemaniBasePatch & { + type: "number"; + offset: number; + size: number; + min?: number; + max?: number; +}; + +export type BemaniDynamicStringPatch = BemaniBasePatch & { + type: "dynamic"; + mode?: "all"; + target: "string"; + patches: { + off: string; + on: string; + }[]; +}; + +export type BemaniDynamicPatch = BemaniBasePatch & { + type: "dynamic"; + mode?: "all"; + target: undefined; + patches: { + off: (number | "XX")[]; + on: (number | "XX")[]; + }[]; +}; + +export type BemaniHexPatch = BemaniBasePatch & { + type: "hex"; + offset: number; + off: number[]; +}; + +export type BemaniPatch = + | BemaniStandardPatch + | BemaniUnionPatch + | BemaniNumberPatch + | BemaniDynamicPatch + | BemaniDynamicStringPatch + | BemaniHexPatch; + +export type BemaniPatcherArgs = { + fname: string; + description: string; + patches: BemaniPatch[]; +}; + +export type SpiceBasePatch = { + type: string; + name: string; + description: string; + gameCode: string; +}; + +export type SpiceMemoryPatch = SpiceBasePatch & { + type: "memory"; + patches: { + offset: number; + dllName: string; + dataDisabled: string; + dataEnabled: string; + }[]; +}; + +export type SpiceNumberPatch = SpiceBasePatch & { + type: "number"; + patch: { + dllName: string; + offset: number; + min: number; + max: number; + size: number; + }; +}; + +export type SpiceUnionPatch = SpiceBasePatch & { + type: "union"; + patches: { + name: string; + patch: { + dllName: string; + data: string; + offset: number; + }; + }[]; +}; + +export type SpiceSignaturePatch = SpiceBasePatch & { + type: "signature"; + signature: string; + replacement: string; + dllName: string; + + /** + * The offset to start searching for the signature. + */ + offset?: number | string; + + /** + * The number of matches to get before returning a result. + */ + usage?: number | string; +}; + +export type SpiceMetaPatch = { + gameCode: string; + version: string; + lastUpdated: string; + source: string; +}; + +export type SpicePatch = SpiceMemoryPatch | SpiceNumberPatch | SpiceUnionPatch | SpiceSignaturePatch; diff --git a/patchers/b2mpatch.ts b/patchers/b2mpatch.ts new file mode 100644 index 0000000..f34bc2e --- /dev/null +++ b/patchers/b2mpatch.ts @@ -0,0 +1,138 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import process from "node:process"; +import path from "node:path"; +import PE from "npm:pe-parser"; +import type { PEFile } from "npm:pe-parser/parser"; +import sanitize from "npm:sanitize-filename"; +import { bytesToHex, parsePatcherHtml } from "./_parser.ts"; + +function fileOffsetToRVA(pe: PEFile, offset: number) { + for (const section of pe.sections) { + if ( + offset >= section.PointerToRawData && + offset < section.PointerToRawData + section.SizeOfRawData + ) { + return offset - section.PointerToRawData + section.VirtualAddress; + } + } +} + +async function main() { + if (process.argv.length < 4) { + console.log( + "Usage: deno run -A b2mpatch.ts [outputDir]", + ); + process.exit(1); + } + + const location = process.argv[2]; + const executableDir = process.argv[3]; + const output = process.argv[4] ?? "."; + const locationIsUrl = location.startsWith("http://") || + location.startsWith("https://"); + let html = ""; + + if (locationIsUrl) { + html = await fetch(location).then((r) => r.text()); + } else { + html = readFileSync(location, { encoding: "utf-8" }); + } + + const patchers = parsePatcherHtml(location, html); + + for (const patcher of patchers) { + let exePath = path.join( + executableDir, + sanitize(patcher.description) + path.extname(patcher.fname), + ); + + if (!existsSync(exePath)) { + console.warn( + `Target file ${exePath} does not exist, falling back to ${patcher.fname}.`, + ); + exePath = path.join(executableDir, patcher.fname); + } + + if (!existsSync(exePath)) { + console.warn(`Target file ${exePath} does not exist, skipping.`); + continue; + } + + const data = readFileSync(exePath); + const pe = await PE.Parse(data); + let mempatch = `# ${patcher.fname} ${patcher.description}\n\n`; + + for (const patch of patcher.patches) { + if (patch.type !== undefined && patch.type !== "union") { + console.warn( + `Unsupported BemaniPatcher patch type ${patch.type}`, + patch, + ); + continue; + } + + mempatch += `# ${patch.name}\n`; + + if (patch.tooltip) { + mempatch += `# ${patch.tooltip}\n`; + } + + if (patch.danger) { + mempatch += `# WARNING: ${patch.danger}\n`; + } + + for (const p of patch.patches) { + let on: string; + let off: string; + let offset: string; + + if (patch.type === "union") { + const up = p as { name: string; patch: number[] }; + + on = bytesToHex(up.patch); + off = data + .subarray( + patch.offset, + patch.offset + up.patch.length, + ) + .toString("hex") + .toUpperCase(); + offset = fileOffsetToRVA(pe, patch.offset) + .toString(16) + .toUpperCase(); + + mempatch += `## ${up.name}\n`; + } else { + const sp = p as { + offset: number; + on: number[]; + off: number[]; + }; + + on = bytesToHex(sp.on); + off = bytesToHex(sp.off); + offset = fileOffsetToRVA(pe, sp.offset).toString(16) + .toUpperCase(); + } + + if (patch.type === "union" && on === off) { + mempatch += `${patcher.fname} ${offset} ${on} ${off}\n`; + } else { + mempatch += `# ${patcher.fname} ${offset} ${on} ${off}\n`; + } + } + + mempatch += "\n"; + } + + writeFileSync( + path.join( + output, + sanitize(`${patcher.fname}-${patcher.description}.mph`), + ), + mempatch, + ); + } +} + +main(); diff --git a/patchers/b2spatch.mjs b/patchers/b2spatch.mjs deleted file mode 100644 index 0be374f..0000000 --- a/patchers/b2spatch.mjs +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Script to convert a BemaniPatcher HTML to a spice2x patch JSON. - * - * Usage: - * - node b2spatch.js - */ - -import vm from "vm"; -import * as cheerio from "cheerio"; -import { readFileSync, writeFileSync } from "fs"; -import path from "path"; - -/** - * - * @param {number[]} bytes - */ -function bytesToHex(bytes) { - return bytes.reduce((acc, c) => acc + c.toString(16).padStart(2, "0").toUpperCase(), ""); -} - -/** - * Parse a BemaniPatcher HTML for patches. - * @param {string} contents - */ -function parsePatcherHtml(filename, contents) { - const $ = cheerio.load(contents); - - let script = ""; - - for (const element of $("script").get()) { - const code = $(element).html(); - - if (!code.includes("new Patcher")) { - continue; - } - - script += code; - script += "\n"; - } - - if (script.length === 0) { - console.warn(`Failed to find any BemaniPatcher patches in ${filename}.`); - return []; - } - - const patchers = []; - const context = { - window: { - addEventListener: (type, cb) => cb(), - }, - Patcher: class { - constructor(fname, description, patches) { - patchers.push({ fname, description, patches }); - } - }, - PatchContainer: class { - constructor(patchers) {} - } - }; - - vm.createContext(context); - vm.runInContext(script, context); - - return patchers; -} - -function convertToSpicePatch(bmPatch, gameCode, dllName) { - let description = ""; - - if (bmPatch.tooltip) { - description += bmPatch.tooltip + "\n"; - } - - if (bmPatch.danger) { - description += `WARNING: ${bmPatch.danger}` - } - - description = description.trim() - - const patch = { - name: bmPatch.name, - description, - gameCode, - } - - if (bmPatch.type) { - if (bmPatch.type === "union") { - patch.type = "union"; - patch.patches = bmPatch.patches.map((p) => ({ - name: p.name, - patch: { - dllName, - data: bytesToHex(p.patch), - offset: bmPatch.offset, - } - })); - } else if (bmPatch.type === "number") { - patch.type = "number"; - patch.patch = { - dllName, - offset: bmPatch.offset, - min: bmPatch.min, - max: bmPatch.max, - size: bmPatch.size, - }; - } else { - console.warn(`Unsupported BemaniPatcher patch type ${bmPatch.type}`, bmPatch); - } - } else { - patch.type = "memory"; - patch.patches = bmPatch.patches.map((p) => ({ - offset: p.offset, - dllName, - dataDisabled: bytesToHex(p.off), - dataEnabled: bytesToHex(p.on), - })); - } - - return patch; -} - -async function main() { - if (process.argv.length < 4) { - console.log("Usage: node b2spatch.js [output dir]") - process.exit(1); - } - - const location = process.argv[2]; - const gameCode = process.argv[3]; - const output = process.argv[4] ?? "."; - const locationIsUrl = location.startsWith("http://") || location.startsWith("https://"); - let html = ""; - - if (locationIsUrl) { - html = await fetch(location).then((r) => r.text()); - } else { - html = readFileSync(location, { encoding: "utf-8" }); - } - - const patchers = parsePatcherHtml(location, html); - - for (const patcher of patchers) { - const lastUpdated = new Date() - const spice = [ - { - gameCode, - version: patcher.description, - lastUpdated: `${lastUpdated.getFullYear()}-${(lastUpdated.getMonth() + 1).toString().padStart(2, "0")}-${lastUpdated.getDate().toString().padStart(2, "0")} ${lastUpdated.getHours().toString().padStart(2, "0")}:${lastUpdated.getMinutes().toString().padStart(2, "0")}:${lastUpdated.getSeconds().toString().padStart(2, "0")}`, - source: locationIsUrl ? location : "https://sp2x.two-torial.xyz/", - } - ]; - - for (const patch of patcher.patches) { - spice.push(convertToSpicePatch(patch, gameCode, patcher.fname)); - } - - writeFileSync( - path.join(output, `${gameCode}-${patcher.description}.json`), - JSON.stringify(spice, null, 4) - ); - } -} - -main(); - diff --git a/patchers/b2spatch.ts b/patchers/b2spatch.ts new file mode 100644 index 0000000..74ef5ae --- /dev/null +++ b/patchers/b2spatch.ts @@ -0,0 +1,143 @@ +/** + * Script to convert a BemaniPatcher HTML to a spice2x patch JSON. + * + * Usage: + * - deno run -WNE b2spatch.ts [outputDir] + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { bytesToHex, parsePatcherHtml } from "./_parser.ts"; +import type { + BemaniPatch, + SpiceBasePatch, + SpiceMemoryPatch, + SpiceMetaPatch, + SpiceNumberPatch, + SpicePatch, + SpiceUnionPatch, +} from "./_types.ts"; + +function convertToSpicePatch( + bmPatch: BemaniPatch, + gameCode: string, + dllName: string, +) { + let description = ""; + + if (bmPatch.tooltip) { + description += bmPatch.tooltip + "\n"; + } + + if (bmPatch.danger) { + description += `WARNING: ${bmPatch.danger}`; + } + + description = description.trim(); + + const patch: SpiceBasePatch = { + type: "memory", + name: bmPatch.name, + description, + gameCode, + }; + + if (bmPatch.type) { + if (bmPatch.type === "union") { + patch.type = "union"; + (patch as SpiceUnionPatch).patches = bmPatch.patches.map((p) => ({ + name: p.name, + patch: { + dllName, + data: bytesToHex(p.patch), + offset: bmPatch.offset, + }, + })); + } else if (bmPatch.type === "number") { + const numWidth = bmPatch.size * 8; + + patch.type = "number"; + (patch as SpiceNumberPatch).patch = { + dllName, + offset: bmPatch.offset, + min: bmPatch.min ?? -(2 ** (numWidth - 1)), + max: bmPatch.max ?? 2 ** (numWidth - 1) - 1, + size: bmPatch.size, + }; + } else { + // Technically you can match BemaniPatcher's DynamicPatch type to + // Spice's signature patch, but one DynamicPatch can have multiple signatures, + // while Spice only supports one signature per patch. + console.warn( + `Unsupported BemaniPatcher patch type ${bmPatch.type}`, + bmPatch, + ); + } + } else { + patch.type = "memory"; + (patch as SpiceMemoryPatch).patches = bmPatch.patches.map((p) => ({ + offset: p.offset, + dllName, + dataDisabled: bytesToHex(p.off), + dataEnabled: bytesToHex(p.on), + })); + } + + return patch as SpicePatch; +} + +async function main() { + if (process.argv.length < 4) { + console.log( + "Usage: deno run -WNE b2spatch.ts [output dir]", + ); + process.exit(1); + } + + const location = process.argv[2]; + const gameCode = process.argv[3]; + const output = process.argv[4] ?? "."; + const locationIsUrl = location.startsWith("http://") || + location.startsWith("https://"); + let html = ""; + + if (locationIsUrl) { + html = await fetch(location).then((r) => r.text()); + } else { + html = readFileSync(location, { encoding: "utf-8" }); + } + + const patchers = parsePatcherHtml(location, html); + + for (const patcher of patchers) { + const lastUpdated = new Date(); + const spice: (SpiceMetaPatch | SpicePatch)[] = [ + { + gameCode, + version: patcher.description, + lastUpdated: `${lastUpdated.getFullYear()}-${ + (lastUpdated.getMonth() + 1).toString().padStart(2, "0") + }-${lastUpdated.getDate().toString().padStart(2, "0")} ${ + lastUpdated.getHours().toString().padStart(2, "0") + }:${lastUpdated.getMinutes().toString().padStart(2, "0")}:${ + lastUpdated.getSeconds().toString().padStart(2, "0") + }`, + source: locationIsUrl + ? location + : "https://sp2x.two-torial.xyz/", + }, + ]; + + for (const patch of patcher.patches) { + spice.push(convertToSpicePatch(patch, gameCode, patcher.fname)); + } + + writeFileSync( + path.join(output, `${gameCode}-${patcher.description}.json`), + JSON.stringify(spice, null, 4), + ); + } +} + +main();