UNPKG

roomie

Version:

ROM metadata helper

380 lines (379 loc) 13.9 kB
import { EventEmitter } from "node:events"; import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import { regions } from "./tables/regions.js"; import { specs } from "./tables/specs.js"; import { isHiRomBuffer } from "./systems/snes.js"; export class Roomie extends EventEmitter { constructor(path) { super(); this.load(path); } detectSystemFromPath(p) { const ext = p.toLowerCase().split(".").pop(); if (ext === "nds") return "nds"; if (ext === "gba") return "gba"; if (ext === "gb" || ext === "gbc") return "gb"; if (ext === "sfc" || ext === "smc") return "sfc"; if (ext === "z64" || ext === "n64") return "n64"; // Default to sfc to keep compatibility with original intent; could be improved. return "sfc"; } readGameCode(system) { const b = this._rom; try { if (system === "nds" && b.length >= 0x10) { return b.subarray(0x0C, 0x10).toString("ascii"); } if (system === "gba" && b.length >= 0xB0) { return b.subarray(0xAC, 0xB0).toString("ascii"); } if (system === "gb" && b.length >= 0x0143) { return b.subarray(0x013F, 0x0143).toString("ascii"); } if (system === "n64" && b.length >= 0x2F) { return b.subarray(0x20, 0x2F).toString("ascii").trim(); } } catch { } return undefined; } computeRegion(system, gameCode) { switch (system) { case "nds": if (gameCode && gameCode.length >= 4) { const key = gameCode[3]; return regions.nds[key]; } return undefined; case "gba": if (gameCode && gameCode.length >= 4) { const key = gameCode[3]; return regions.gba[key]; } return undefined; case "gb": if (this._rom.length > 0x14A) { const v = this._rom[0x14A]; return regions.gb[v]; } return undefined; case "sfc": const hi = isHiRomBuffer(this._rom); const off = hi ? 0xFFD9 : 0x7FD9; if (this._rom.length > off) { const key = this._rom[off]; return regions.snes[key]; } return undefined; case "n64": // N64 region code at offset 0x3E in some ROMs (common practice) if (this._rom.length > 0x3E) { const regionByte = this._rom[0x3E]; // Map region byte to region string (basic example) const regionMap = { 0x44: "USA", 0x45: "Europe", 0x46: "France", 0x4A: "Japan", 0x50: "PAL", 0x55: "Australia", 0x58: "Germany", 0x59: "Europe", 0x5A: "Europe", }; return regionMap[regionByte] || "Unknown"; } return undefined; } } computeSfcInfo() { if (this._system !== "sfc") return undefined; const hi = isHiRomBuffer(this._rom); const base = hi ? 0xFFD0 : 0x7FD0; const offD5 = base + 0x05; // map mode (for specs mapping) const offD6 = base + 0x06; // hardware type const offD7 = base + 0x07; // ROM size exponent const offD8 = base + 0x08; // RAM size exponent const offD9 = base + 0x09; // raw romSpeed byte const out = {}; // romSpeed: raw D9 byte as string, like original JS if (this._rom.length > offD9) { out.romSpeed = this._rom[offD9].toString().trim(); } // specs: map D5 (2-digit hex) to specs.sfc.romspeed if (this._rom.length > offD5) { const key = this._rom[offD5].toString(16).padStart(2, "0"); const spec = specs.sfc?.romspeed?.[key]; if (spec) out.rom = { ...(out.rom || {}), type: spec.type, speed: spec.speed }; } // rom size from D7 using original expression if (this._rom.length > offD7) { const exp = this._rom[offD7]; const size = 2 ** (2 ^ exp) * 1000; out.rom = { ...(out.rom || {}), size }; } // ram size from D8 using original expression if (this._rom.length > offD8) { const exp = this._rom[offD8]; out.ram = 2 ** (2 ^ exp) * 1000; } // hardware from D6 (2-digit hex) if (this._rom.length > offD6) { const hwKey = this._rom[offD6].toString(16).padStart(2, "0"); const hw = specs.sfc?.hardware?.[hwKey]; if (hw) out.hardware = hw; } return out; } _name() { const b = this._rom; try { switch (this._system) { case "nds": if (b.length >= 0x20) { return b.subarray(0x0, 0x20).toString("ascii").replace(/\0/g, "").trim(); } break; case "gba": if (b.length >= 0xAC) { return b.subarray(0xA0, 0xAC).toString("ascii").replace(/\0/g, "").trim(); } break; case "gb": if (b.length >= 0x134) { return b.subarray(0x134, 0x144).toString("ascii").replace(/\0/g, "").trim(); } break; case "sfc": // SNES title at 0x7FC0 or 0xFFC0 depending on LoROM/HiROM const hi = isHiRomBuffer(b); const base = hi ? 0xFFC0 : 0x7FC0; if (b.length > base + 21) { return b.subarray(base, base + 21).toString("ascii").replace(/\0/g, "").trim(); } break; case "n64": if (b.length >= 0x20) { return b.subarray(0x20, 0x34).toString("ascii").replace(/\0/g, "").trim(); } break; } } catch { } return undefined; } _gameid() { const code = this._gamecode(); if (!code) return undefined; switch (this._system) { case "nds": return "NTR-" + code; case "gba": return "AGB-" + code; default: return undefined; } } _gamecode() { const b = this._rom; try { switch (this._system) { case "nds": if (b.length >= 0x10) { return b.subarray(0x0C, 0x10).toString("ascii"); } break; case "gba": if (b.length >= 0xB0) { return b.subarray(0xAC, 0xB0).toString("ascii"); } break; case "gb": if (b.length >= 0x0143) { return b.subarray(0x013F, 0x0143).toString("ascii"); } break; case "n64": if (b.length >= 0x2F) { return b.subarray(0x20, 0x2F).toString("ascii").trim(); } break; } } catch { } return undefined; } _cartridge() { // Build cartridge metadata depending on system const b = this._rom; switch (this._system) { case "nds": { const code = this._gamecode(); const region = this._region(); return { system: "nds", gameCode: code, region, size: b.length, }; } case "gba": { const code = this._gamecode(); const region = this._region(); return { system: "gba", gameCode: code, region, size: b.length, }; } case "gb": { const region = this._region(); return { system: "gb", region, size: b.length, }; } case "sfc": { const sfcInfo = this.computeSfcInfo(); return { system: "sfc", ...sfcInfo, size: b.length, }; } case "n64": { // N64 cartridge info from header bytes const countryByte = b.length > 0x3E ? b[0x3E] : undefined; const versionByte = b.length > 0x3F ? b[0x3F] : undefined; const countryMap = { 0x00: "Japan", 0x01: "USA", 0x02: "Europe", 0x03: "Germany", 0x04: "France", 0x05: "Spain", 0x06: "Italy", 0x07: "China", 0x08: "Australia", 0x09: "Unknown", 0x0A: "Unknown", 0x0B: "Unknown", 0x0C: "Unknown", 0x0D: "Unknown", 0x0E: "Unknown", 0x0F: "Unknown", }; return { system: "n64", name: this._name(), country: countryByte !== undefined ? countryMap[countryByte] || "Unknown" : undefined, version: versionByte !== undefined ? versionByte.toString() : undefined, size: b.length, }; } default: return undefined; } } _region() { return this.computeRegion(this._system, this._gamecode()); } async load(pathOrBuffer) { if (typeof pathOrBuffer === "string") { this._path = pathOrBuffer; this._rom = await fs.readFile(pathOrBuffer); this._system = this.detectSystemFromPath(pathOrBuffer); if (!this._system) { throw new Error("unknown_file"); } } else { this._rom = pathOrBuffer; this._path = "in-memory"; const b = this._rom; let detected = undefined; // Check NDS: game code at 0x0C-0x10 ASCII uppercase letters/digits if (b.length >= 0x10) { const code = b.subarray(0x0C, 0x10).toString("ascii"); if (/^[A-Z0-9]{4}$/.test(code)) { detected = "nds"; } } // Check GBA: game code at 0xAC-0xB0 ASCII uppercase letters/digits if (!detected && b.length >= 0xB0) { const code = b.subarray(0xAC, 0xB0).toString("ascii"); if (/^[A-Z0-9]{4}$/.test(code)) { detected = "gba"; } } // Check GB: game code at 0x0134-0x0143 ASCII valid characters if (!detected && b.length >= 0x0143) { const code = b.subarray(0x0134, 0x0143).toString("ascii"); if (/^[A-Z0-9]{4,9}$/.test(code)) { detected = "gb"; } } // Check N64: ASCII text at 0x20-0x2E if (!detected && b.length >= 0x2F) { const code = b.subarray(0x20, 0x2F).toString("ascii"); if (/^[\x20-\x7E]+$/.test(code) && code.trim().length > 0) { detected = "n64"; } } // Check SFC: use isHiRomBuffer heuristic if (!detected) { if (b.length > 0x8000 && (isHiRomBuffer(b) || !isHiRomBuffer(b))) { detected = "sfc"; } } if (!detected) { throw new Error("unknown_bytes"); } this._system = detected; } const sha1 = createHash("sha1").update(this._rom).digest("hex"); const gameCode = this.readGameCode(this._system); const info = { path: this._path, system: this._system, size: this._rom.length, hash: { sha1 }, gameCode, region: this.computeRegion(this._system, gameCode), }; if (this._system === "sfc") { info.sfc = this.computeSfcInfo(); } if (this._system === "n64") { info.n64 = { name: this._name(), country: this._region(), version: this._rom.length > 0x3F ? this._rom[0x3F].toString() : undefined, }; } this._info = info; this.name = this._name(); this.gameid = this._gameid(); this.region = this._region(); this.gamecode = this._gamecode(); this.cartridge = this._cartridge(); this.emit("loaded", info); } get info() { return this._info; } get system() { return this._system; } get path() { return this._path; } get rom() { return this._rom; } } export default Roomie;