/** * 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();