Map the French, German, Italian and Spanish releases' addresses (#84)
PRET builds none of these releases, so scripts/map-revision.mjs derives where each keeps the profile's labels from the 1.1 build: a bank-by-bank alignment, then the exact references RGBDS recorded while linking 1.1, read at the matching place in the release, each result checked against its bytes. All but 1-3 labels per release are found; the rest, in code the release changed, are -1 so reading them refuses. check:reference verifies the generated file when the ROMs are present. CrystalAssets now points the profile at the cartridge's addresses, and the releases' characters (from their own fonts) and two European text codes decode. The introduction and Mom's scene play in each language and every map sweeps as in English, but the releases stay refused until the logic they localize (units, word order, layouts) is ported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c480a28f0
commit
627f87d8b5
17 files changed
+1202
-190
No files matched your search
@@ -0,0 +1,707 @@
|
||||
// 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_<FR|DE|IT|ES>_ROM_PATH).
|
||||
// MAP_REVISION_DUMP=dir also writes every 1.1 label's place in each release
|
||||
// (<dir>/<language>.json), for comparing routines.
|
||||
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]);
|
||||
const pattern = [];
|
||||
for (let d = -3; d <= 3; d++) pattern.push(cover.has(p + d) ? -1 : en[p + d]);
|
||||
let hit;
|
||||
for (let q = from; q < from + 128 && q + 3 < fr.length; q++)
|
||||
if (pattern.every((b, k) => b < 0 || fr[q - 3 + k] === b)) {
|
||||
if (hit !== undefined) return undefined;
|
||||
hit = q;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
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)));
|
||||
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 };
|
||||
Reference in new issue
Block a user