z3r-patch
Version:
A JS module for patching ALTTPR seeds
272 lines (234 loc) • 9.18 kB
JavaScript
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 */