From 20c41426c6ae61342898497a9ba8611e61253dc0 Mon Sep 17 00:00:00 2001 From: beerpsi Date: Thu, 11 Jul 2024 15:21:00 +0700 Subject: [PATCH] bemanipatcher -> sp2x --- README.md | 6 ++ patchers/b2spatch.mjs | 165 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 patchers/b2spatch.mjs diff --git a/README.md b/README.md index 3c5eebe..d1a96ba 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Miscellaneous scripts that don't need their own repo. +## Patchers +- `patchers/b2spatch.mjs`: Convert from BemaniPatcher to Spice2x JSON + - Requires `cheerio`: `npm i cheerio` + - You will need to rename the resulting JSON manually so that they match the + `{gameCode}-{timestamp}_{entrypoint}` format expected by Spice2x. + ## Arcaea - `acaca/arcpack.py`: Script to extract Arcaea Nintendo Switch `arc.pack` files diff --git a/patchers/b2spatch.mjs b/patchers/b2spatch.mjs new file mode 100644 index 0000000..0be374f --- /dev/null +++ b/patchers/b2spatch.mjs @@ -0,0 +1,165 @@ +/** + * 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(); +