deno is kinda nice

This commit is contained in:
beerpsi 2024-10-27 02:44:51 +07:00
parent 421c2dc09b
commit cadee776ea
6 changed files with 482 additions and 167 deletions

View File

@ -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

58
patchers/_parser.ts Normal file
View File

@ -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;
}

137
patchers/_types.ts Normal file
View File

@ -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;

138
patchers/b2mpatch.ts Normal file
View File

@ -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 <patcher URL | local patcher HTML> <executableDir> [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();

View File

@ -1,165 +0,0 @@
/**
* 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();

143
patchers/b2spatch.ts Normal file
View File

@ -0,0 +1,143 @@
/**
* Script to convert a BemaniPatcher HTML to a spice2x patch JSON.
*
* Usage:
* - deno run -WNE b2spatch.ts <patcher URL | local patcher HTML> <gameCode> [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 <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: (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();