beerpsi-x/patchers/b2spatch.mjs
2024-07-11 15:21:00 +07:00

166 lines
4.6 KiB
JavaScript

/**
* Script to convert a BemaniPatcher HTML to a spice2x patch JSON.
*
* Usage:
* - node b2spatch.js <patcher URL | local patcher HTML> <gameCode>
*/
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 <patcher URL | local patcher HTML> <gameCode> [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();