UNPKG

z3r-patch

Version:

A JS module for patching ALTTPR seeds

272 lines (234 loc) 9.18 kB
import * as fs from "node:fs"; import * as bps from "bps"; import center from "center-align"; import * as z3pr from "@jaxxy/z3pr"; import shuffleSfx from "./sfx.js"; import { charBytes, heartColorBytes as hcBytes, heartSpeedBytes as hsBytes, menuSpeedBytes as msBytes, } from "./records.js"; const URL = "https://alttpr.com"; /** * Applies a randomizer seed patch to a JP 1.0 LTTP ROM. * @param {string} base The file path to the JP 1.0 LTTP ROM. * @param {VTSeed} seed The seed object generated by alttpr.com. * @param {PatchOptions} [options] Post-generation options. * @returns {Promise<Uint8Array>} The patched ROM. */ export default async function(base, seed, options = {}) { const { buffer } = fs.readFileSync(base); let rom = new Uint8Array(buffer); // Apply base patch first if (!("current_rom_hash" in seed)) { const res = await fetch(`${URL}/api/h/${seed.hash}`); const json = await res.json(); seed.current_rom_hash = json.md5; } const bpsFile = await fetch(`${URL}/bps/${seed.current_rom_hash}.bps`) .then(res => res.arrayBuffer()); const patch = new Uint8Array(bpsFile); const { instructions } = bps.parse(patch); rom = bps.apply(instructions, rom); // Expand ROM size if necessary if (seed.size > 2) { const newSize = seed.size * (1024 ** 2); const resizeSize = Math.min(newSize, rom.buffer.byteLength); const replacement = new Uint8Array(resizeSize); replacement.set(rom); rom = replacement; } // Seed-specific patches for (const rec of seed.patch) { const [[key, values]] = Object.entries(rec); write(parseInt(key), values); } // Custom sprite if (options?.sprite instanceof ArrayBuffer) { const sprite = new Uint8Array(options.sprite); if (isZSPR(sprite)) { parseZSPR(sprite); } else { // Legacy handler for (let i = 0; i < 0x7000; ++i) { write(0x80000 + i, sprite[i]); } for (let i = 0; i < 120; ++i) { write(0xdd308 + i, sprite[0x7000 + i]); } write(0xdedf5, sprite[0x7036]); write(0xdedf6, sprite[0x7037]); write(0xdedf7, sprite[0x7054]); write(0xdedf8, sprite[0x7055]); } } // Background music (default: true) write(0x18021a, (options?.backgroundMusic ?? true) ? 0x00 : 0x01); // Heart color (default: red) if (rom[0x7FE2] <= 0x03 || rom[0x7FE2] == 0xFF) { // Legacy handler const legacyHcBytes = { [hcBytes.red]: [0x24, 0x05], [hcBytes.blue]: [0x2c, 0x0d], [hcBytes.green]: [0x3c, 0x19], [hcBytes.yellow]: [0x28, 0x09], }; const col = hcBytes[options?.heartColor] ?? hcBytes.red; const [byte, fByte] = legacyHcBytes[col]; [0x6fa1e, 0x6fa20, 0x6fa22, 0x6fa24, 0x6fa26, 0x6fa28, 0x6fa2a, 0x6fa2c, 0x6fa2e, 0x6fa30] .forEach(adr => write(adr, byte)); write(0x65561, fByte); } else { write(0x187020, hcBytes[options?.heartColor] ?? hcBytes.red); } // Heart speed (default: normal) write(0x180033, hsBytes[options?.heartSpeed] ?? hsBytes.normal); // Menu speed (default: normal) const isInstant = msBytes[options?.menuSpeed] === msBytes.instant; write(0x180048, msBytes[options?.menuSpeed] ?? msBytes.normal); write(0x6dd9a, isInstant ? 0x20 : 0x11); write(0x6df2a, isInstant ? 0x20 : 0x12); write(0x6e0e9, isInstant ? 0x20 : 0x12); // MSU-1 resume (default: true) if (options?.msu1Resume === false) { write(0x18021D, 0x00); write(0x18021E, 0x00); } // Quickswap (default: true) write(0x18004b, (options?.quickswap ?? true) ? 0x01 : 0x00); // Reduce flashing (default: false) write(0x18017f, (options?.reduceFlash ?? false) ? 0x01 : 0x00); // Palette shuffle (default: false) if (typeof options?.paletteShuffle === "object") { z3pr.randomize(rom, options.paletteShuffle); } else if (typeof options?.paletteShuffle === "string") { z3pr.randomize(rom, { mode: options.paletteShuffle, randomize_overworld: true, randomize_dungeon: true, }); } else if (options?.paletteShuffle === true) { z3pr.randomize(rom, { mode: "maseya", randomize_overworld: true, randomize_dungeon: true, }); } // SFX shuffle (default: false) if (options?.sfxShuffle) { const int16Bytes = v => [v & 0xff, (v >> 8) & 0xff]; const snes2pc = v => ((v & 0x7F0000) >> 1) | (v & 0x7FFF); const sfxTable = { 2: 0x1A8BD0, 3: 0x1A8CCC }; const sfxAccompTable = { 2: 0x1A8C4E, 3: 0x1A8D4A }; const sfxMap = shuffleSfx(); const sfxSets = [sfxMap[2], sfxMap[3]]; for (const set of sfxSets) { for (const id in set) { const sfx = set[id]; const baseAddr = snes2pc(sfxTable[sfx.target.set]); write(baseAddr + sfx.target.id * 2 - 2, int16Bytes(sfx.address)); const accompBase = snes2pc(sfxAccompTable[sfx.target.set]); let last = sfx.target.id; if (sfx.target.chain) { sfx.target.chain.forEach(chained => { write(accompBase + last - 1, chained); last = chained; }); } write(accompBase + last - 1, 0); } } } // Checksum fix is done last const total = rom.reduce((p, c, i) => i >= 0x7fdc && i < 0x7fe0 ? p : p + c); const checksum = (total + 0x1fe) & 0xffff; const inverse = checksum ^ 0xffff; write(0x7fdc, [ inverse & 0xff, inverse >> 8, checksum & 0xff, checksum >> 8, ]); return rom; function write(offset, bytes) { if (typeof bytes === "number") { rom[offset] = bytes; } else for (let i = 0; i < bytes.length; ++i) { rom[offset + i] = bytes[i]; } } /** @param {Uint8Array} data */ function isZSPR(data) { const { fromCharCode } = String; return data.subarray(0, 4).reduce((p, c) => p + fromCharCode(c), "") === "ZSPR"; } /** @param {Uint8Array} data */ function parseZSPR(data) { const canWriteAuthor = rom[0x118000] === 0x02 && rom[0x118001] === 0x37 && rom[0x11801E] === 0x02 && rom[0x11801F] === 0x37 const gfxOffset = data[12] << 24 | data[11] << 16 | data[10] << 8 | data[9]; const palOffset = data[18] << 24 | data[17] << 16 | data[16] << 8 | data[15]; let metaIndex = 0x1D; let junk = 2; while (metaIndex < gfxOffset && junk > 0) { if (!data[metaIndex + 1] && !data[metaIndex]) { --junk; } metaIndex += 2; } let shortAuth = ""; while (metaIndex < gfxOffset && data[metaIndex] !== 0x00) { shortAuth += String.fromCharCode(data[metaIndex]); ++metaIndex; } if (canWriteAuthor) { shortAuth = center(shortAuth.substring(0, 28), 28).toUpperCase(); for (let i = 0; i < shortAuth.length; ++i) { const char = shortAuth.charAt(i); const [up, lo] = charBytes[char in charBytes ? char : " "]; write(0x118002 + i, up); write(0x118020 + i, lo); } } // GFX if (gfxOffset !== 0xFFFFFFFF) { for (let i = 0; i < 0x7000; ++i) { write(0x80000 + i, data[gfxOffset + i]); } } // Palettes for (let i = 0; i < 120; ++i) { write(0xdd308 + i, data[palOffset + i]); } // Gloves for (let i = 0; i < 4; ++i) { write(0xdedf5 + i, data[palOffset + 120 + i]); } } } /** * @typedef VTSeed * @prop {string} generated * @prop {string} hash * @prop {string} logic * @prop {Record<number,number[]>[]} patch * @prop {number} size * @prop {object} spoiler * @prop {string} [current_rom_hash] */ /** * @typedef PatchOptions * @prop {HeartSpeed} [heartSpeed] * @prop {HeartColor} [heartColor] * @prop {MenuSpeed} [menuSpeed] * @prop {boolean} [quickswap] * @prop {boolean} [backgroundMusic] * @prop {boolean} [msu1Resume] * @prop {boolean|z3pr.PaletteMode|z3pr.PaletteRandomizerOptions<z3pr.SeedValue>} [paletteShuffle] * @prop {boolean} [reduceFlash] * @prop {boolean} [sfxShuffle] * @prop {ArrayBuffer} [sprite] */ /** @typedef {"off"|"quarter"|"half"|"normal"|"double"} HeartSpeed */ /** @typedef {"red"|"blue"|"green"|"yellow"} HeartColor */ /** @typedef {"slow"|"normal"|"fast"|"instant"} MenuSpeed */