Files
Crystal/scripts/check-reference.mjs
kawaandClaude Opus 5.5 627f87d8b5
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m51s
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>
2026-10-17 17:45:34 +02:00

335 lines
16 KiB
JavaScript

import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { PROFILE_RAM, PROFILE_SYMBOLS } from "./profile-symbols.mjs";
import { RELEASES, generate } from "./map-revision.mjs";
const root = ".reference/pokecrystal";
const expected = "f2f52230b536214ef7c9924f483392993e226cfb";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const supplied = readFileSync(romPath),
rebuilt = readFileSync(`${root}/pokecrystal11.gbc`);
if (
createHash("sha1").update(supplied).digest("hex") !== expected ||
!supplied.equals(rebuilt)
)
throw new Error(
"The supplied ROM and reference rebuild must exactly match revision 1.1.",
);
const revision = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (revision !== "7a7881d0d62e0ddbd82dcf10e7116807487ac651")
throw new Error("Unexpected PRET revision.");
const symbols = new Map(
readFileSync(`${root}/pokecrystal11.sym`, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (!match) return [];
const bank = parseInt(match[1], 16),
address = parseInt(match[2], 16);
return [
[
match[3],
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000,
],
];
}),
);
const names = PROFILE_SYMBOLS;
// Work and hardware RAM symbols are compared by their CPU address, not a
// ROM offset. Hardware RAM (hMoneyTemp and similar) lives at $FF80-$FFFE.
const ram = new Map(
readFileSync(`${root}/pokecrystal11.sym`, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^0[01]:((?:[c-d][0-9a-f]{3}|ff[0-9a-f]{2})) (\S+)$/.exec(
line,
);
return match ? [[match[2], parseInt(match[1], 16)]] : [];
}),
);
const ramNames = PROFILE_RAM;
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
// Every label the profile pins, for the other revisions' check below.
const used = new Set(["Trainers", "TrainerGroups"]);
for (const [key, symbol] of Object.entries(names)) {
const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile);
used.add(symbol);
if (!match || Number(match[1]) !== symbols.get(symbol))
throw new Error(`Profile address mismatch: ${key} / ${symbol}`);
}
for (const [key, symbol] of Object.entries(ramNames)) {
const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile);
used.add(symbol);
if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
}
// SAVED_WRAM, WRAM and SAVE_BLOCK entries are work RAM labels.
for (const table of ["SAVED_WRAM", "WRAM", "SAVE_BLOCK"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (block === undefined) throw new Error(`${table} symbol table is missing.`);
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
const label = quoted ?? bare;
used.add(label);
if (ram.get(label) !== Number(literal))
throw new Error(`${table} RAM mismatch: ${label}`);
}
}
// SRAM entries are offsets into a 32 KiB .sav file: bank * $2000 plus the
// address within $a000-$bfff.
{
const sram = new Map(
readFileSync(`${root}/pokecrystal11.sym`, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^0([0-3]):([ab][0-9a-f]{3}) (\S+)$/.exec(line);
return match
? [[match[3], Number(match[1]) * 0x2000 + parseInt(match[2], 16) - 0xa000]]
: [];
}),
);
const block = /export const SRAM = \{([\s\S]*?)\} as const;/.exec(profile)?.[1];
if (block === undefined) throw new Error("SRAM symbol table is missing.");
for (const [, label, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+),/gm)) {
used.add(label);
if (sram.get(label) !== Number(literal))
throw new Error(`SRAM mismatch: ${label}`);
}
}
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO, KANTO and AUDIO entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS", "KANTO"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (block === undefined) throw new Error(`${table} symbol table is missing.`);
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
const label = quoted ?? bare;
used.add(label);
if (symbols.get(label) !== Number(literal))
throw new Error(`${table} symbol mismatch: ${label}`);
}
}
if (!profile.includes(`NUM_TRAINER_CLASSES = ${(symbols.get("Trainers") - symbols.get("TrainerGroups")) / 2};`))
throw new Error("Trainer class count does not match the reference.");
const eventIds = new Map();
{
let next = 0;
for (const raw of readFileSync(
`${root}/constants/event_flags.asm`,
"utf8",
).split("\n")) {
const line = raw.split(";")[0].trim();
let match;
if (line.startsWith("const_def")) next = 0;
else if ((match = /^const_next\s+(\d+)$/.exec(line)))
next = Number(match[1]);
else if ((match = /^const_skip(?:\s+(\d+))?$/.exec(line)))
next += Number(match[1] ?? 1);
else if ((match = /^const (\w+)$/.exec(line)))
eventIds.set(match[1], next++);
}
}
const eventNames = {
playersRoomPoster: "EVENT_PLAYERS_ROOM_POSTER",
playersHouseConsole: "EVENT_PLAYERS_HOUSE_2F_CONSOLE",
playersHouseDoll1: "EVENT_PLAYERS_HOUSE_2F_DOLL_1",
playersHouseDoll2: "EVENT_PLAYERS_HOUSE_2F_DOLL_2",
playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL",
wallOpenedInAerodactylChamber: "EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER",
wallOpenedInKabutoChamber: "EVENT_WALL_OPENED_IN_KABUTO_CHAMBER",
wallOpenedInHoOhChamber: "EVENT_WALL_OPENED_IN_HO_OH_CHAMBER",
wallOpenedInOmanyteChamber: "EVENT_WALL_OPENED_IN_OMANYTE_CHAMBER",
bugCatchingContestant1A: "EVENT_BUG_CATCHING_CONTESTANT_1A",
bugCatchingContestant2A: "EVENT_BUG_CATCHING_CONTESTANT_2A",
bugCatchingContestant3A: "EVENT_BUG_CATCHING_CONTESTANT_3A",
bugCatchingContestant4A: "EVENT_BUG_CATCHING_CONTESTANT_4A",
bugCatchingContestant5A: "EVENT_BUG_CATCHING_CONTESTANT_5A",
bugCatchingContestant6A: "EVENT_BUG_CATCHING_CONTESTANT_6A",
bugCatchingContestant7A: "EVENT_BUG_CATCHING_CONTESTANT_7A",
bugCatchingContestant8A: "EVENT_BUG_CATCHING_CONTESTANT_8A",
bugCatchingContestant9A: "EVENT_BUG_CATCHING_CONTESTANT_9A",
bugCatchingContestant10A: "EVENT_BUG_CATCHING_CONTESTANT_10A",
togepiHatched: "EVENT_TOGEPI_HATCHED",
};
for (const [key, name] of Object.entries(eventNames)) {
const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile);
if (!match || Number(match[1]) !== eventIds.get(name))
throw new Error(`Profile event mismatch: ${key} / ${name}`);
}
// Count constants up to a DEF, the way const_def/const_value do.
const constantCount = (file, name) => {
let value = 0;
for (const raw of readFileSync(`${root}/${file}`, "utf8").split("\n")) {
const line = raw.split(";")[0].trim();
if (line.startsWith("const_def"))
value = Number(/const_def\s+(\d+)/.exec(line)?.[1] ?? 0);
else if (/^const \w+$/.test(line)) value++;
else if (line.startsWith(`DEF ${name} EQU const_value - 1`))
return value - 1;
}
throw new Error(`${name} not found`);
};
for (const [name, file] of [
["NUM_POKEMON", "constants/pokemon_constants.asm"],
["NUM_ITEMS", "constants/item_constants.asm"],
["NUM_FRUIT_TREES", "constants/script_constants.asm"],
])
if (!profile.includes(`${name} = ${constantCount(file, name)};`))
throw new Error(`${name} does not match the reference.`);
// PRET constants: const_def/const/const_skip/const_next/shift_const,
// trainerclass and numeric or aliased DEF ... EQU, plus the battle command
// macro list.
const pretConstants = new Map();
{
const number = (text) =>
text.startsWith("$")
? parseInt(text.slice(1), 16)
: text.startsWith("%")
? parseInt(text.slice(1), 2)
: Number(text);
const files = [
...execFileSync("ls", [`${root}/constants`], { encoding: "utf8" })
.split("\n")
.filter((name) => name.endsWith(".asm"))
.map((name) => `${root}/constants/${name}`),
`${root}/macros/scripts/battle_commands.asm`,
];
for (const file of files) {
let value = 0,
step = 1,
trainerClass = 0;
for (const raw of readFileSync(file, "utf8").split("\n")) {
const line = raw.split(";")[0].trim();
let match;
if ((match = /^const_def(?:\s+(-?[$%]?[0-9a-fA-F]+))?(?:\s*,\s*(-?\d+))?$/.exec(line))) {
value = match[1] ? number(match[1].replace(/^-/, "")) * (match[1].startsWith("-") ? -1 : 1) : 0;
step = match[2] ? Number(match[2]) : 1;
} else if ((match = /^const_next\s+([$%]?[0-9a-fA-F]+)$/.exec(line))) value = number(match[1]);
else if ((match = /^const_skip(?:\s+(\d+))?$/.exec(line))) value += step * Number(match[1] ?? 1);
else if ((match = /^DEF __trainer_class__ = (\d+)$/.exec(line))) trainerClass = Number(match[1]);
else if ((match = /^trainerclass\s+(\w+)$/.exec(line))) {
// DEF \1 EQU __trainer_class__, then const_def 1 for its trainers.
pretConstants.set(match[1], trainerClass++);
value = 1;
step = 1;
} else if ((match = /^(const|shift_const|command)\s+(\w+)$/.exec(line))) {
const stored = match[1] === "shift_const" ? 1 << value : value;
pretConstants.set(match[2], stored);
if (match[1] === "shift_const") pretConstants.set(`${match[2]}_F`, value);
value += step;
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+const_value\s*-\s*(\w+)$/.exec(line))) {
const offset = /^\d+$/.test(match[2]) ? Number(match[2]) : pretConstants.get(match[2]);
if (offset !== undefined) pretConstants.set(match[1], value - offset);
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+([$%]?[0-9a-fA-F]+|\w+)$/.exec(line))) {
const target = match[2];
const resolved =
target === "const_value"
? value
: /^[$%]?[0-9]/.test(target) || /^[$%]/.test(target)
? number(target)
: pretConstants.get(target);
if (resolved !== undefined) pretConstants.set(match[1], resolved);
}
}
}
}
const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts", "src/game/audio/constants.ts", "src/game/audio/battle-music.ts", "src/game/scripts/radio.ts"]
.map((file) => readFileSync(file, "utf8"))
.join("\n");
for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) {
for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim)) {
const expected = pretConstants.get(name);
if (expected === undefined || ((expected + 256) % 256 !== Number(literal) % 256 && expected !== Number(literal)))
throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`);
}
}
// The cartridge save converter's PRET constants (its LAYOUT holds struct
// offsets, compared with the symbols by tests/cartridge-save.test.ts).
{
const block = /export const SAV = \{([\s\S]*?)\} as const;/.exec(
readFileSync("src/game/world/cartridge-save.ts", "utf8"),
)?.[1];
if (block === undefined) throw new Error("SAV constant table is missing.");
for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim))
if (pretConstants.get(name) !== Number(literal))
throw new Error(`Cartridge save constant mismatch: ${name} is ${literal}, PRET has ${pretConstants.get(name)}`);
}
if (!profile.includes(`pokegear: ${pretConstants.get("ENGINE_POKEGEAR")},`))
throw new Error("ENGINE_POKEGEAR does not match the reference.");
if (!profile.includes(`pokedex: ${pretConstants.get("ENGINE_POKEDEX")},`))
throw new Error("ENGINE_POKEDEX does not match the reference.");
if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`))
throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference.");
if (!profile.includes(`bugContestTimer: ${pretConstants.get("ENGINE_BUG_CONTEST_TIMER")},`))
throw new Error("ENGINE_BUG_CONTEST_TIMER does not match the reference.");
for (const [key, name] of [
["bikeShopCall", "ENGINE_BIKE_SHOP_CALL_ENABLED"],
["rocketsInRadioTower", "ENGINE_ROCKETS_IN_RADIO_TOWER"],
["rocketsInMahogany", "ENGINE_ROCKETS_IN_MAHOGANY"],
["expnCard", "ENGINE_EXPN_CARD"],
["dayCareManHasEgg", "ENGINE_DAY_CARE_MAN_HAS_EGG"],
["dayCareManHasMon", "ENGINE_DAY_CARE_MAN_HAS_MON"],
["dayCareLadyHasMon", "ENGINE_DAY_CARE_LADY_HAS_MON"],
["rocketSignal", "ENGINE_ROCKET_SIGNAL_ON_CH20"],
["fishSwarm", "ENGINE_QWILFISH_SWARM"],
["buenasPassword", "ENGINE_BUENAS_PASSWORD"],
["dunsparceSwarm", "ENGINE_DUNSPARCE_SWARM"],
["yanmaSwarm", "ENGINE_YANMA_SWARM"],
])
if (!profile.includes(`${key}: ${pretConstants.get(name)},`))
throw new Error(`${name} does not match the reference.`);
if (!profile.includes(`NUM_SPRITEMOVEDATA = ${pretConstants.get("NUM_SPRITEMOVEDATA")};`))
throw new Error("NUM_SPRITEMOVEDATA does not match the reference.");
if (!profile.includes(`NUM_ENGINE_FLAGS = ${pretConstants.get("NUM_ENGINE_FLAGS")};`))
throw new Error("NUM_ENGINE_FLAGS does not match the reference.");
const groups = readFileSync(`${root}/data/maps/maps.asm`, "utf8")
.split(/^MapGroup_/m)
.slice(1)
.map((group) => (group.match(/^\tmap /gm) ?? []).length);
if (
!profile.includes(`MAP_GROUP_COUNT = ${groups.length};`) ||
!profile.includes(`LAST_MAP_GROUP_SIZE = ${groups.at(-1)};`)
)
throw new Error("Map group bounds do not match the reference.");
// The other English cartridges (src/rom/validate.ts) read through the same
// profile: when their PRET rebuilds are present (`make crystal crystal_au`),
// they must match the retail hashes and keep every pinned label in place.
const banked = (file) =>
new Map(
readFileSync(file, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}:[0-9a-f]{4}) (\S+)$/.exec(line);
return match ? [[match[2], match[1]]] : [];
}),
);
const pinned = banked(`${root}/pokecrystal11.sym`);
const revisions = [];
for (const [name, rom, sha1] of [
["US/Europe 1.0", "pokecrystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"],
["Australia", "pokecrystal_au", "a0fc810f1d4e124434f7be2c989ab5b5892ddf36"],
]) {
if (!existsSync(`${root}/${rom}.gbc`) || !existsSync(`${root}/${rom}.sym`)) continue;
if (createHash("sha1").update(readFileSync(`${root}/${rom}.gbc`)).digest("hex") !== sha1)
throw new Error(`The ${name} rebuild does not match the retail cartridge.`);
const other = banked(`${root}/${rom}.sym`);
const moved = [...used].filter((label) => other.get(label) !== pinned.get(label));
if (moved.length)
throw new Error(`Profile labels at other addresses in ${name}: ${moved.slice(0, 10).join(", ")}`);
revisions.push(name);
}
// The European releases' addresses (src/game/rom/revision-addresses.ts),
// regenerated from 1.1 and compared when their ROMs are present.
const releases = Object.values(RELEASES).filter(({ rom }) => existsSync(rom));
let european = false;
if (releases.length === Object.keys(RELEASES).length) {
if (generate().text !== readFileSync("src/game/rom/revision-addresses.ts", "utf8"))
throw new Error("src/game/rom/revision-addresses.ts is out of date: run node scripts/map-revision.mjs.");
european = true;
} else if (releases.length) throw new Error("Checking the European releases' addresses needs all four ROMs.");
console.log(
`User ROM, pinned PRET rebuild and all native profile addresses match${revisions.length ? `, also in ${revisions.join(" and ")}` : ""}${european ? "; the European releases' addresses are up to date" : ""}.`,
);