/** * 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 { CreateLogger } from "npm:mei-logger"; import { bytesToHex, parsePatcherHtml } from "./_parser.ts"; import type { BemaniPatch, SpiceBasePatch, SpiceMemoryPatch, SpiceMetaPatch, SpiceNumberPatch, SpicePatch, SpiceUnionPatch, } from "./_types.ts"; const logger = CreateLogger("b2spatch"); 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. logger.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 -A 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();