// Where the French, German, Italian and Spanish releases keep the labels the // profile pins (src/game/rom/profile.ts). PRET builds none of them, so the // addresses are derived from its English 1.1 build: the 1.1 ROM is aligned // with each release bank by bank, and the rest is found through the exact // references RGBDS recorded while linking 1.1 (every patch in the object // files), read at the matching place in the release. Every result is then // checked against the release's bytes. // // node scripts/map-revision.mjs writes src/game/rom/revision-addresses.ts // node scripts/map-revision.mjs --check fails if that file is out of date // // Needs the pinned PRET checkout built for 1.1 (its .o, .map and .sym) and // the releases' ROMs at the repository root (or CRYSTAL__ROM_PATH). // MAP_REVISION_DUMP=dir also writes every 1.1 label's place in each release // (/.json), for comparing routines. --symbols writes // .reference/pokecrystal/pokecrystal_.sym: 1.1's symbol file with // each release's places (labels not found left out), so the reference specs // run against a release with CRYSTAL_ROM_PATH and CRYSTAL_SYMBOLS_PATH. import { createHash } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { PROFILE_RAM, PROFILE_SYMBOLS } from "./profile-symbols.mjs"; const root = ".reference/pokecrystal"; const output = "src/game/rom/revision-addresses.ts"; export const RELEASES = { fr: { rom: "Pokemon - Version Cristal (France).gbc", sha1: "c055992b16b7399c687647725cdd1f4f13a2f75c" }, de: { rom: "Pokemon - Kristall-Edition (Germany).gbc", sha1: "accb584293ba056152f1fd908439b019017ff2fe" }, it: { rom: "Pokemon - Versione Cristallo (Italy).gbc", sha1: "6cee05e5b95beeae74b8365ad18ec4a07a8c4af8" }, es: { rom: "Pokemon - Edicion Cristal (Spain).gbc", sha1: "889a06fc0bb863666865aa69def0adf97945ac2a" }, }; const flat = (bank, address) => (address < 0x4000 ? address : bank * 0x4000 + address - 0x4000); /** name -> [bank, address] from a .sym file. */ function loadSymbols(file) { const symbols = new Map(); for (const line of readFileSync(file, "utf8").split("\n")) { const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line.trim()); if (match) symbols.set(match[3], [parseInt(match[1], 16), parseInt(match[2], 16)]); } return symbols; } // ------------------------------------------------------------ RGBDS objects /** An RGB9 object file (rgbds(5)): its symbols and its ROM sections' patches. */ function readObject(file) { const b = readFileSync(file); let i = 0; const long = () => ((i += 4), b.readInt32LE(i - 4)); const byte = () => b[i++]; const string = () => { const end = b.indexOf(0, i); const value = b.toString("latin1", i, end); i = end + 1; return value; }; if (b.toString("latin1", 0, 4) !== "RGB9") throw new Error(`${file} is not an RGB9 object.`); i = 8; const symbolCount = long(), sectionCount = long(), nodeCount = long(); for (let n = 0; n < nodeCount; n++) { long(); long(); if (byte() & 0x7f) string(); else for (let depth = long(); depth > 0; depth--) long(); } const symbols = []; for (let n = 0; n < symbolCount; n++) { const name = string(); if (byte() !== 1) (long(), long(), long(), long()); symbols.push(name); } const sections = []; for (let n = 0; n < sectionCount; n++) { const name = string(); long(); long(); const size = long(), type = byte(); long(); long(); byte(); long(); const patches = []; if ((type & 7) === 2 || (type & 7) === 3) { i += size; for (let count = long(); count > 0; count--) { long(); long(); const offset = long(); long(); long(); const kind = byte(), length = long(); patches.push({ offset, kind, rpn: parseRpn(b.subarray(i, i + length), symbols) }); i += length; } } sections.push({ name, size, type, patches }); } return sections; } /** An RPN expression as ["sym", name], ["bank", name], ["lit", n] or ["op", code]. */ function parseRpn(rpn, symbols) { const ops = []; for (let i = 0; i < rpn.length; ) { const code = rpn[i++]; if (code === 0x50 || code === 0x81) { ops.push([code === 0x50 ? "bank" : "sym", symbols[rpn.readInt32LE(i)]]); i += 4; } else if (code === 0x80) { ops.push(["lit", rpn.readInt32LE(i)]); i += 4; } else if (code === 0x51 || code === 0x53 || code === 0x54) { const end = rpn.indexOf(0, i); ops.push(["section", code]); i = end + 1; } else if (code === 0x55 || code === 0x56 || code === 0x62) { ops.push(["op", code, rpn[i++]]); } else ops.push(["op", code]); } return ops; } /** Every ROM patch of the 1.1 link: { site, kind, rpn }, sites as ROM offsets. */ function references() { const placed = new Map(); let bank = 0; for (const line of readFileSync(`${root}/pokecrystal11.map`, "utf8").split("\n")) { const header = /^(ROM0|ROMX|WRAM0|WRAMX|SRAM|HRAM|VRAM|OAM) bank #(\d+):/.exec(line); if (header) bank = Number(header[2]); const section = /^\s+SECTION: \$([0-9a-f]{4})(?:-\$[0-9a-f]{4})? \(\$[0-9a-f]{4} bytes?\) \["(.*)"\]/.exec(line); if (section && !placed.has(section[2])) placed.set(section[2], flat(bank, parseInt(section[1], 16))); } const fragments = new Map(), out = []; for (const object of ["audio11.o", "home11.o", "main11.o", "ram11.o"]) for (const section of readObject(`${root}/${object}`)) { if (!placed.has(section.name) || ((section.type & 7) !== 2 && (section.type & 7) !== 3)) continue; let base = placed.get(section.name); if (section.type & 0x40) { const before = fragments.get(section.name) ?? 0; fragments.set(section.name, before + section.size); base += before; } for (const patch of section.patches) out.push({ site: base + patch.offset, kind: patch.kind, rpn: patch.rpn }); } return out; } /** ["sym", name, addend] for symbol + constant, or ["bank", name]. */ function singleSymbol(rpn) { const named = rpn.filter((op) => op[0] === "sym" || op[0] === "bank"); if (named.length !== 1) return undefined; if (named[0][0] === "bank") return rpn.length === 1 ? ["bank", named[0][1]] : undefined; const evaluate = (value) => { const stack = []; for (const op of rpn) { if (op[0] === "sym") stack.push(value); else if (op[0] === "lit") stack.push(op[1]); else if (op[0] === "op" && (op[1] === 0 || op[1] === 1) && stack.length >= 2) { const right = stack.pop(), left = stack.pop(); stack.push(op[1] === 0 ? left + right : left - right); } else return undefined; } return stack.length === 1 ? stack[0] : undefined; }; const zero = evaluate(0), shifted = evaluate(0x1000); if (zero === undefined || shifted === undefined || shifted - zero !== 0x1000) return undefined; return ["sym", named[0][1], zero]; } const PATCH_SIZE = [1, 2, 4, 1]; function indexReferences(refs) { const words = new Map(), banks = new Map(), halves = new Map(), cover = new Set(); const push = (map, key, value) => (map.get(key) ?? map.set(key, []).get(key)).push(value); for (const { site, kind, rpn } of refs) { for (let k = 0; k < PATCH_SIZE[kind]; k++) cover.add(site + k); if (kind === 0 && rpn.length === 2 && rpn[0][0] === "sym" && rpn[1][0] === "op" && (rpn[1][1] === 0x70 || rpn[1][1] === 0x71)) push(halves, rpn[0][1], [site, rpn[1][1] === 0x70 ? "hi" : "lo"]); const single = singleSymbol(rpn); if (!single) continue; if (single[0] === "bank" && kind === 0) push(banks, single[1], site); else if (single[0] === "sym" && kind === 1) push(words, single[1], [site, single[2]]); } return { words, banks: new Map([...banks].map(([k, v]) => [k, new Set(v)])), halves, cover }; } // ------------------------------------------------------------ GB-Z80 code const LENGTH = new Array(256).fill(1); for (const op of [0x06, 0x0e, 0x16, 0x1e, 0x26, 0x2e, 0x36, 0x3e, 0xc6, 0xce, 0xd6, 0xde, 0xe6, 0xee, 0xf6, 0xfe, 0x18, 0x20, 0x28, 0x30, 0x38, 0xe0, 0xf0, 0xe8, 0xf8, 0xcb, 0x10]) LENGTH[op] = 2; for (const op of [0x01, 0x11, 0x21, 0x31, 0x08, 0xc2, 0xc3, 0xc4, 0xca, 0xcc, 0xcd, 0xd2, 0xd4, 0xda, 0xdc, 0xea, 0xfa]) LENGTH[op] = 3; const INVALID = new Set([0xd3, 0xdb, 0xdd, 0xe3, 0xe4, 0xeb, 0xec, 0xed, 0xf4, 0xfc, 0xfd]); const END = new Set([0xc9, 0xd9, 0xc3, 0xe9, 0x18]); const LOADS = new Set([0x3e, 0x06, 0x0e, 0x16, 0x1e, 0x26, 0x2e]); const JUMPS = new Set([0x18, 0x20, 0x28, 0x30, 0x38]); /** The same instructions for up to n steps, 16-bit operands masked. */ function sameCode(en, fr, e, f, n) { for (let k = 0; k < n; k++) { const op = en[e]; if (INVALID.has(op) || fr[f] !== op) return false; const length = LENGTH[op]; if (length === 2 && !JUMPS.has(op) && en[e + 1] !== fr[f + 1] && !LOADS.has(op)) return false; e += length; f += length; if (END.has(op)) break; } return true; } // ------------------------------------------------------------ alignment const BANK = 0x4000; /** Anchors that occur once in each bank, longest increasing chain. */ function chain(pairs) { const tails = [], tailIndex = [], previous = new Array(pairs.length).fill(-1); pairs.forEach(([, f], i) => { let lo = 0, hi = tails.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (tails[mid] < f) lo = mid + 1; else hi = mid; } tails[lo] = f; tailIndex[lo] = i; previous[i] = lo > 0 ? tailIndex[lo - 1] : -1; }); const out = []; for (let k = tailIndex.at(-1) ?? -1; k >= 0; k = previous[k]) out.push(pairs[k]); return out.reverse(); } function uniqueGrams(text, k, lo, hi) { const count = new Map(); for (let i = lo; i + k <= hi; i++) { const gram = text.slice(i, i + k); count.set(gram, (count.get(gram) ?? 0) + 1); } const at = new Map(); for (let i = lo; i + k <= hi; i++) { const gram = text.slice(i, i + k); if (count.get(gram) === 1 && new Set(gram).size > Math.min(3, k - 2)) at.set(gram, i); } return at; } function matchGrams(E, F, k, [e0, e1], [f0, f1]) { const ge = uniqueGrams(E, k, e0, e1), gf = uniqueGrams(F, k, f0, f1); const pairs = []; for (const [gram, e] of ge) if (gf.has(gram)) pairs.push([e, gf.get(gram)]); return chain(pairs.sort((a, b) => a[0] - b[0] || a[1] - b[1])); } /** Runs [e0, e1, delta] of one bank where 1.1 offset e sits at e + delta. */ function bankRuns(E, F) { let pairs = []; for (const k of [16, 10, 7]) { if (!pairs.length) pairs = matchGrams(E, F, k, [0, E.length], [0, F.length]); else { const points = [[-k, -k], ...pairs, [E.length, F.length]]; const found = [...pairs]; for (let i = 0; i + 1 < points.length; i++) { const [e0, f0] = points[i], [e1, f1] = points[i + 1]; const a = Math.max(e0 + k, 0), b = Math.max(f0 + k, 0); if (e1 - a < k || f1 - b < k || e1 - e0 === f1 - f0) continue; found.push(...matchGrams(E, F, k, [a, e1], [b, f1])); } const unique = [...new Map(found.map((p) => [p.join(), p])).values()]; pairs = chain(unique.sort((a, b) => a[0] - b[0] || a[1] - b[1])); } } const runs = []; for (const [e, f] of pairs) { const delta = f - e, last = runs.at(-1); if (last && last[2] === delta && e <= last[1] + 64) { last[1] = Math.max(last[1], e + 7); last[3]++; } else runs.push([e, e + 7, delta, 1]); } const kept = runs.filter((run) => run[3] >= 2); // Equal-length gaps between runs keep the delta (only operands changed). const full = []; kept.forEach((run, i) => { full.push(run.slice(0, 3)); if (kept[i + 1]?.[2] === run[2]) full.push([run[1], kept[i + 1][0], run[2]]); }); return full; } function align(en, fr) { const text = (bytes, bank) => bytes.toString("latin1", bank * BANK, (bank + 1) * BANK); return Array.from({ length: en.length / BANK }, (_, bank) => bankRuns(text(en, bank), text(fr, bank))); } function locate(runs, p) { const bank = Math.floor(p / BANK), offset = p % BANK, list = runs[bank]; let lo = 0, hi = list.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (list[mid][0] <= offset) lo = mid + 1; else hi = mid; } const run = list[lo - 1]; return run && run[0] <= offset && offset < run[1] ? p + run[2] : undefined; } // ------------------------------------------------------------ resolution function strictMatch(en, fr, e, f) { if (f === undefined || f + 16 > fr.length) return undefined; let same = 0; for (let i = 0; i < 16; i++) if (en[e + i] === fr[f + i]) same++; if (same >= 14) return "data"; if (![0x00, 0x16, 0x50, 0x57, 0x58].includes(en[e]) && sameCode(en, fr, e, f, 12)) return "code"; return undefined; } /** Every 1.1 label's place in the release: label -> ROM offset (or RAM * address), with how it was found. */ function resolve(en, fr, symbols, refs) { const runs = align(en, fr); const { words, banks, halves, cover } = indexReferences(refs); const rom = new Map(), sameAddress = new Map(); for (const [name, [bank, address]] of symbols) if (address < 0x8000) { const e = flat(bank, address); rom.set(name, e); (sameAddress.get(e) ?? sameAddress.set(e, []).get(e)).push(name); } const scan = new Map(); for (let p = 0; p + 1 < en.length; p++) { const w = en[p] | (en[p + 1] << 8); (scan.get(w) ?? scan.set(w, []).get(w)).push(p); } const known = new Map(), how = new Map(); for (const [name, e] of rom) { const f = locate(runs, e), kind = strictMatch(en, fr, e, f); if (kind) (known.set(name, f), how.set(name, `align-${kind}`)); } let starts = [], startAt = new Map(); /** From e (1.1) and f, bytes equal except patched ones, up to p. */ const masked = (e, f, p) => { for (let d = 0; d <= p - e; d++) { if (f + d >= fr.length) return undefined; if (e + d === p) return f + d; if (!cover.has(e + d) && en[e + d] !== fr[f + d]) return undefined; } return undefined; }; const site = (p) => { let lo = 0, hi = starts.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (starts[mid] <= p) lo = mid + 1; else hi = mid; } const i = lo - 1; for (let j = i; j > i - 8 && j >= 0; j--) { if (Math.floor(starts[j] / BANK) !== Math.floor(p / BANK) || p - starts[j] > 600) break; const q = masked(starts[j], startAt.get(starts[j]), p); if (q !== undefined) return q; } // Then the bank alignment: a repeating pattern of small texts can align // at another offset, which a walk from a resolved label rules out. const aligned = locate(runs, p); if (aligned !== undefined) return aligned; // A few bytes into a resolved label (a menu header's own fields may // differ before its pointer). if (i >= 0 && p - starts[i] <= 8 && Math.floor(starts[i] / BANK) === Math.floor(p / BANK)) return startAt.get(starts[i]) + (p - starts[i]); // A routine the release changed (an inserted instruction): the // instruction around the pointer, patched bytes as wildcards, found once // near the resolved routine. if (i >= 0 && p - starts[i] <= 64 && Math.floor(starts[i] / BANK) === Math.floor(p / BANK)) { const from = startAt.get(starts[i]); // Widest first; narrower when a changed byte sits next to it. for (const [before, after] of [[3, 3], [1, 3]]) { const pattern = []; // The pointer itself is what the release changes. for (let d = -before; d <= after; d++) pattern.push(d === 0 || d === 1 || cover.has(p + d) ? -1 : en[p + d]); if (pattern.filter((b) => b >= 0).length < (before === 3 ? 3 : 2)) continue; // The hit nearest where 1.1's distance from the routine puts it, // within 16 bytes and nearer than any other. const expected = from + (p - starts[i]); const hits = []; for (let q = from; q < from + 128 && q + after < fr.length; q++) if (pattern.every((b, k) => b < 0 || fr[q - before + k] === b)) hits.push(q); hits.sort((x, y) => Math.abs(x - expected) - Math.abs(y - expected)); if (hits.length === 1) return hits[0]; if (hits.length && Math.abs(hits[0] - expected) <= 16 && Math.abs(hits[1] - expected) > Math.abs(hits[0] - expected)) return hits[0]; } return undefined; } return undefined; }; const refresh = () => { // Labels at one address share it. for (const names of sameAddress.values()) { const found = names.find((name) => known.has(name)); if (found) for (const name of names) if (!known.has(name)) (known.set(name, known.get(found)), how.set(name, "alias")); } startAt = new Map(); for (const [name, f] of known) if (rom.has(name) && !startAt.has(rom.get(name))) startAt.set(rom.get(name), f); starts = [...startAt.keys()].sort((a, b) => a - b); }; /** What the release's bytes at 1.1's references to a label say, ranked. */ const votesFor = (name) => { const [bank, address] = symbols.get(name); const inRom = address < 0x8000, votes = new Map(); const vote = (value, weight) => votes.set(value, (votes.get(value) ?? 0) + weight); if (inRom && halves.has(name)) { const at = (half) => halves.get(name).filter(([, h]) => h === half).map(([s]) => site(s)); for (const lo of at("lo")) for (const hi of at("hi")) if (lo !== undefined && hi !== undefined) { const w = fr[lo] | (fr[hi] << 8); if (w >= 0x4000 && w < 0x8000) vote(bank * BANK + w - 0x4000, 1); } } let sites = words.get(name); const patched = Boolean(sites); if (!sites && inRom) // Resolved while assembling, so no patch: 1.1's pointer bytes in a // pointer context of the label's own bank, or after its bank (dba). sites = (scan.get(address) ?? []) .filter((p) => { const load = [0x21, 0x11, 0x01].includes(en[p - 1]); if (load && (bank === 0 || Math.floor(p / BANK) === bank)) return true; // ld a/b/c, BANK(label) before, or lb bc, BANK(label), n after. if (load && [0x3e, 0x06, 0x0e, 0x16, 0x1e].includes(en[p - 3]) && en[p - 2] === bank) return true; if (load && en[p + 2] === 0x01 && en[p + 4] === bank) return true; return bank && en[p - 1] === bank && Math.floor(p / BANK) !== bank; }) .map((p) => [p, 0]); for (const [s, addend] of sites ?? []) { const q = site(s); if (q === undefined || q + 1 >= fr.length) continue; const w = (fr[q] | (fr[q + 1] << 8)) - addend; if (!inRom) { vote(w, 1); continue; } if (address < 0x4000) { if (w >= 0 && w < 0x4000) vote(w, 2); continue; } if (w < 0x4000 || w >= 0x8000) continue; const siteBank = Math.floor(s / BANK); let releaseBank; if (!patched && siteBank !== bank) { const at = en[s - 1] === bank ? s - 1 : en[s - 2] === bank && [0x3e, 0x06, 0x0e, 0x16, 0x1e].includes(en[s - 3]) ? s - 2 : en[s + 2] === 0x01 && en[s + 4] === bank ? s + 4 : undefined; const qb = at === undefined ? undefined : q + (at - s); if (qb !== undefined) releaseBank = fr[qb]; } if (releaseBank === undefined) for (const d of [2, -1, -2, 3, -3, 1, -4, 4]) if (banks.get(name)?.has(s + d)) { const qb = site(s + d); if (qb !== undefined) { releaseBank = fr[qb]; break; } } let weight = 2; if (releaseBank === undefined) { if (siteBank === bank) releaseBank = Math.floor(q / BANK); else ((releaseBank = bank), (weight = 1)); } if (releaseBank > 0 && releaseBank < 0x80) vote(releaseBank * BANK + w - 0x4000, weight); } return [...votes].sort((a, b) => b[1] - a[1]); }; for (let pass = 0; pass < 6; pass++) { for (;;) { refresh(); let added = 0; for (const name of symbols.keys()) { if (known.has(name)) continue; const ranked = votesFor(name); if (ranked.length && (ranked.length === 1 || ranked[0][1] > ranked[1][1])) { known.set(name, ranked[0][0]); how.set(name, `reference ${ranked[0][1]}/${ranked.reduce((sum, [, n]) => sum + n, 0)}`); added++; } } if (!added) break; } // References outrank byte matches outside code: a short text can look // alike in two languages while the release's pointers name another one. let repaired = 0; for (const name of symbols.keys()) { if (!known.has(name) || how.get(name) === "align-code") continue; const ranked = votesFor(name); if (ranked.length === 1 && ranked[0][1] >= 2 && ranked[0][0] !== known.get(name)) { known.set(name, ranked[0][0]); how.set(name, `repaired, reference ${ranked[0][1]}`); repaired++; } } if (!repaired) break; // Aliases of a repaired label follow it. for (const names of sameAddress.values()) if (names.some((name) => how.get(name)?.startsWith("repaired"))) for (const name of names) if (how.get(name) === "alias") known.delete(name); } // Last, labels nothing points to in a way the release keeps. refresh(); const patchAt = new Map(); for (const ref of refs) (patchAt.get(ref.site) ?? patchAt.set(ref.site, []).get(ref.site)).push(ref); for (const [name, [bank, address]] of symbols) { if (known.has(name) || address >= 0x8000) continue; const e = flat(bank, address); // Equal bytes from a resolved label before it (a table's end). let lo = 0, hi = starts.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (starts[mid] < e) lo = mid + 1; else hi = mid; } const before = starts[lo - 1]; if (before !== undefined && Math.floor(before / BANK) === Math.floor(e / BANK) && e - before <= 600) { const q = masked(before, startAt.get(before), e); if (q !== undefined) { known.set(name, q); how.set(name, "walk"); continue; } } // Its first bytes as the release would hold them: fixed bytes, and the // resolved targets of its pointers; found once. const pattern = []; let fixed = 0; for (let d = 0; d < 16; d++) { const p = e + d; const ref = (patchAt.get(p) ?? [])[0]; if (ref && ref.kind === 1 && ref.rpn.length === 1 && ref.rpn[0][0] === "sym" && known.has(ref.rpn[0][1]) && symbols.get(ref.rpn[0][1])[1] < 0x8000) { const target = known.get(ref.rpn[0][1]); const word = target < 0x4000 ? target : (target % BANK) + 0x4000; pattern.push(word & 0xff, word >> 8); fixed += 2; d++; } else if (cover.has(p)) pattern.push(-1); else (pattern.push(en[p]), fixed++); } if (fixed < 8) continue; const hits = []; for (let q = 0; q + pattern.length <= fr.length && hits.length < 2; q++) if (pattern.every((b, k) => b < 0 || fr[q + k] === b)) hits.push(q); if (hits.length === 1) (known.set(name, hits[0]), how.set(name, "predicted")); } return { known, how, cover, votesFor, site }; } // ------------------------------------------------------------ checks const TERMINATORS = new Set([0x50, 0x57, 0x58]); /** How a resolved label agrees with the release's bytes: "data" (equal), * "pointers" (its patched pointers name the resolved targets), "code" (the * same instructions), "text" (on a text boundary) or "unverified"; or * "mismatch" when a pointer names another place. */ function checker(en, fr, symbols, refs, known, cover) { const patches = new Map(); for (const ref of refs) (patches.get(ref.site) ?? patches.set(ref.site, []).get(ref.site)).push(ref); const pointer = (name) => { if (!known.has(name) || !symbols.has(name)) return undefined; const value = known.get(name); return symbols.get(name)[1] >= 0x8000 || value < 0x4000 ? value : (value % BANK) + 0x4000; }; return (name) => { const e = flat(...symbols.get(name)), f = known.get(name); if (f + 32 > fr.length) return "mismatch"; // Patched bytes up to the first other byte that differs: the structure // both share. let limit = e; while (limit < e + 32 && (cover.has(limit) || en[limit] === fr[f + limit - e])) limit++; let good = 0; for (let s = e; s < limit; s++) for (const { kind, rpn } of patches.get(s) ?? []) { const q = f + s - e; if (kind === 1 && rpn.length === 1 && rpn[0][0] === "sym") { const want = pointer(rpn[0][1]); if (want === undefined) continue; if ((fr[q] | (fr[q + 1] << 8)) !== want) return "mismatch"; good++; } else if (kind === 0 && rpn.length === 1 && rpn[0][0] === "bank") { const target = rpn[0][1]; if (!known.has(target) || symbols.get(target)[1] < 0x4000 || symbols.get(target)[1] >= 0x8000) continue; if (fr[q] !== Math.floor(known.get(target) / BANK)) return "mismatch"; good++; } } let same = 0; for (let i = 0; i < 16; i++) if (en[e + i] === fr[f + i]) same++; if (same >= 14) return "data"; if (good) return "pointers"; if (![0x00, 0x16, 0x50, 0x57, 0x58].includes(en[e]) && sameCode(en, fr, e, f, 12)) return "code"; if (TERMINATORS.has(en[e - 1]) && TERMINATORS.has(fr[f - 1])) return "text"; return "unverified"; }; } // ------------------------------------------------------------ the profile /** The profile's address entries: table -> key -> 1.1 label. */ function profileEntries(symbols) { const profile = readFileSync("src/game/rom/profile.ts", "utf8"); const tables = new Map(); for (const block of profile.matchAll(/export const (\w+)[^=\n]*= \{\n([\s\S]*?)\n\}/g)) { const entries = new Map(); for (const [, quoted, bare, literal] of block[2].matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) { const key = quoted ?? bare; const label = PROFILE_SYMBOLS[key] ?? PROFILE_RAM[key] ?? (symbols.has(key) ? key : undefined); if (!label || !symbols.has(label)) continue; const [bank, address] = symbols.get(label); if ((address >= 0x8000 ? address : flat(bank, address)) === Number(literal)) entries.set(key, label); } if (entries.size) tables.set(block[1], entries); } return tables; } function generate() { const symbols = loadSymbols(`${root}/pokecrystal11.sym`); const en = readFileSync(`${root}/pokecrystal11.gbc`); const refs = references(); const tables = profileEntries(symbols); const keys = [...tables].sort(([a], [b]) => a.localeCompare(b)).flatMap(([table, entries]) => [...entries.keys()].sort().map((key) => [table, key])); const columns = {}; const reports = []; for (const [language, { rom, sha1 }] of Object.entries(RELEASES)) { const path = process.env[`CRYSTAL_${language.toUpperCase()}_ROM_PATH`] ?? rom; const fr = readFileSync(path); if (createHash("sha1").update(fr).digest("hex") !== sha1) throw new Error(`${path} is not the retail ${language} release.`); const { known, how, cover } = resolve(en, fr, symbols, refs); if (process.env.MAP_REVISION_DUMP) writeFileSync(`${process.env.MAP_REVISION_DUMP}/${language}.json`, JSON.stringify(Object.fromEntries(known))); if (process.argv.includes("--symbols")) { const out = []; for (const line of readFileSync(`${root}/pokecrystal11.sym`, "utf8").split("\n")) { const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line.trim()); if (!match) { out.push(line); continue; } const name = match[3], address = parseInt(match[2], 16); const hex = (value, width) => value.toString(16).padStart(width, "0"); if (address >= 0x8000) out.push(`${match[1]}:${hex(known.get(name) ?? address, 4)} ${name}`); else if (known.has(name)) { const f = known.get(name); out.push(`${hex(f < BANK ? 0 : Math.floor(f / BANK), 2)}:${hex(f < BANK ? f : (f % BANK) + 0x4000, 4)} ${name}`); } } writeFileSync(`${root}/pokecrystal_${language}.sym`, out.join("\n")); } const check = checker(en, fr, symbols, refs, known, cover); const counts = {}, unresolved = [], mismatched = [], moved = []; columns[language] = keys.map(([table, key]) => { const label = tables.get(table).get(key); const [bank, address] = symbols.get(label); if (address >= 0x8000) { // Work and save RAM keep 1.1's layout except where the release's // code names other addresses (the Seer's buffers). if (!known.has(label) || known.get(label) === address) return 0; moved.push(label); return known.get(label); } if (!known.has(label)) { unresolved.push(label); return -1; } const result = check(label); counts[result] = (counts[result] ?? 0) + 1; if (result === "mismatch") mismatched.push(`${label} (${how.get(label)})`); return known.get(label) === flat(bank, address) ? 0 : known.get(label); }); reports.push({ language, counts, unresolved, mismatched, moved }); } // Only the entries some release moves. const kept = keys.map((_, i) => i).filter((i) => Object.values(columns).some((values) => values[i] !== 0)); const lines = [ "// Generated by scripts/map-revision.mjs from PRET's English 1.1 build and", "// each release: where it keeps each profile entry (table.key) whose label", "// moved from 1.1; 0 where it did not, -1 where the label was not found", "// there, which the port must not read in that release. Do not edit.", `export const REVISION_KEYS = ${JSON.stringify(kept.map((i) => `${keys[i][0]}.${keys[i][1]}`))} as const;`, "export const REVISION_ADDRESSES = {", ...Object.entries(columns).map(([language, values]) => ` ${language}: [${kept.map((i) => values[i]).join(",")}],`), "} as const;", ]; return { text: lines.join("\n") + "\n", reports }; } if (import.meta.url === `file://${process.argv[1]}`) { const { text, reports } = generate(); for (const { language, counts, unresolved, mismatched, moved } of reports) { console.log(`${language}: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")}; ${unresolved.length} not found`); if (unresolved.length) console.log(` not found: ${unresolved.join(", ")}`); if (mismatched.length) console.log(` mismatched: ${mismatched.join(", ")}`); if (moved.length) console.log(` RAM moved: ${moved.join(", ")}`); } if (process.argv.includes("--check")) { if (readFileSync(output, "utf8") !== text) throw new Error(`${output} is out of date: run node scripts/map-revision.mjs.`); console.log(`${output} is up to date.`); } else writeFileSync(output, text); } export { generate, resolve, references, loadSymbols, checker, flat };