Files
Crystal/scripts/check-reference.mjs
T
Sébastien QUEROLandClaude Opus 5.5 e00dfab42e Open Route 30 with map trainers and multi-Pokémon trainer battles
Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript.
_CheckTrainerBattle runs before tile events at the end of each step and
on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and
writeobjectxy keeps the trainer where it stopped. The interpreter gains
the trainer header commands, wRunningTrainerBattleScript and hidden
items (HiddenItemScript).

Trainers with several Pokémon send out the next one through
EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including
its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and
AI_Switch are translated with their original bugs, and wPlayerUsedMoves
is recorded. The Shift-mode switch offer, trainer items, Pursuit on a
switch, Perish Song and Spikes remain refused.

Route 30 and its Berry house are enabled; Mr. Pokémon's house and
Route 31 stay closed. A refused trainer sighting does not restart until
the player moves, so a rollback cannot trap the player in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:36:54 +02:00

320 lines
13 KiB
JavaScript

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
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 = {
trainerGroups: "TrainerGroups",
trainerClassNames: "TrainerClassNames",
trainerClassAttributes: "TrainerClassAttributes",
trainerClassDVs: "TrainerClassDVs",
statusOnlyEffects: "StatusOnlyEffects",
stallMoves: "StallMoves",
recklessMoves: "RecklessMoves",
residualMoves: "ResidualMoves",
riskyEffects: "RiskyEffects",
mapGroups: "MapGroupPointers",
tilesets: "Tilesets",
collisions: "CollisionPermissionTable",
environmentColors: "EnvironmentColorsPointers",
backgroundPalettes: "TilesetBGPalette",
objectPalettes: "MapObjectPals",
roofPalettes: "RoofPals",
roofTypes: "MapGroupRoofs",
roofs: "Roofs",
housePalettes: "HousePalette",
sprites: "OverworldSprites",
facings: "Facings",
standardScripts: "StdScripts",
mapScenes: "MapScenes",
specials: "SpecialsPointers",
emotes: "Emotes",
landmarks: "Landmarks",
weekdayNames: "SetDayOfWeek.WeekdayStrings",
whatDayText: "SetDayOfWeek.OakTimeWhatDayIsItText",
isItDayText: "SetDayOfWeek.OakTimeIsItText",
dstConfirmText: "InitialSetDSTFlag.DSTIsThatOKText",
timeConfirmText: "InitialClearDSTFlag.TimeAskOkayText",
baseData: "BaseData",
pokemonNames: "PokemonNames",
pokemonPics: "PokemonPicPointers",
picsBanks: "FixPicBank.PicsBanks",
pokemonPalettes: "PokemonPalettes",
growthRates: "GrowthRates",
evosAttacks: "EvosAttacksPointers",
moves: "Moves",
moveNames: "MoveNames",
itemNames: "ItemNames",
itemAttributes: "ItemAttributes",
itemPocketNames: "ItemPocketNames",
giveItemScript: "GiveItemScript",
putItemInPocketText: "PutItemInPocketText",
pocketIsFullText: "PocketIsFullText",
findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText",
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
hiddenItemScript: "HiddenItemScript",
talkToTrainerScript: "TalkToTrainerScript",
seenByTrainerScript: "SeenByTrainerScript",
wildBattleScript: "WildBattleScript",
battleWhiteoutScript: "Script_BattleWhiteout",
spawnPoints: "SpawnPoints",
johtoGrassWildMons: "JohtoGrassWildMons",
grassMonProbTable: "GrassMonProbTable",
grassCollisions: "CheckGrassCollision.blocks",
fruitTreeScript: "FruitTreeScript",
fruitTreeItems: "FruitTreeItems",
engineFlags: "EngineFlags",
spriteMovementData: "SpriteMovementData",
getCurTreeFruit: "GetCurTreeFruit",
tryResetFruitTrees: "TryResetFruitTrees",
checkFruitTree: "CheckFruitTree",
pickedFruitTree: "PickedFruitTree",
trainerWalkToPlayer: "TrainerWalkToPlayer",
setMemEvent: "SetMemEvent",
marts: "Marts",
martWelcomeText: "MartWelcomeText",
martAskMoreText: "MartAskMoreText",
martComeAgainText: "MartComeAgainText",
martHowManyText: "MartHowManyText",
martFinalPriceText: "MartFinalPriceText",
martNoMoneyText: "MartNoMoneyText",
martPackFullText: "MartPackFullText",
martThanksText: "MartThanksText",
martCantBuyText: "MartCantBuyText",
martSellHowManyText: "MartSellHowManyText",
martSellPriceText: "MartSellPriceText",
martBoughtText: "MartBoughtText",
fadeOutToWhite: "FadeOutToWhite",
healParty: "HealParty",
healMachineAnim: "HealMachineAnim",
checkPokerus: "CheckPokerus",
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
warpToSpawnPoint: "WarpToSpawnPoint",
battleBGMap: "BattleBGMap",
halveMoney: "HalveMoney",
getWhiteoutSpawn: "GetWhiteoutSpawn",
tryReceiveItem: "FindItemInBallScript.TryReceiveItem",
giveItemDummy: "GiveItemScript_DummyFunction",
restartMapMusic: "RestartMapMusic",
setDayOfWeek: "SetDayOfWeek",
initialSetDst: "InitialSetDSTFlag",
initialClearDst: "InitialClearDSTFlag",
toggleDecorationsVisibility: "ToggleDecorationsVisibility",
toggleMaptileDecorations: "ToggleMaptileDecorations",
};
// 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 = {
battleReward: "wBattleReward",
stringBuffer1: "wStringBuffer1",
stringBuffer2: "wStringBuffer2",
stringBuffer3: "wStringBuffer3",
stringBuffer4: "wStringBuffer4",
stringBuffer5: "wStringBuffer5",
playerName: "wPlayerName",
momsName: "wMomsName",
rivalName: "wRivalName",
battleMonNickname: "wBattleMonNickname",
enemyMonNickname: "wEnemyMonNickname",
curPartyLevel: "wCurPartyLevel",
monOrItemNameBuffer: "wMonOrItemNameBuffer",
curFruit: "wCurFruit",
dailyFlags1: "wDailyFlags1",
unusedDailyFlag: "wUnusedDailyFlag",
itemQuantityChange: "wItemQuantityChange",
movementBuffer: "wMovementBuffer",
hiddenItemID: "wHiddenItemID",
runningTrainerBattleScript: "wRunningTrainerBattleScript",
moneyTemp: "hMoneyTemp",
};
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
for (const [key, symbol] of Object.entries(names)) {
const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile);
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);
if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
}
// BATTLE entries are keyed by their PRET labels.
{
const block = /export const BATTLE = \{([\s\S]*?)\} as const;/.exec(profile)?.[1];
if (!block) throw new Error("Battle symbol table is missing.");
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
const label = quoted ?? bare;
if (symbols.get(label) !== Number(literal))
throw new Error(`Battle 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",
};
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 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;
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 = /^(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+([$%]?[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 = readFileSync("src/game/battle/constants.ts", "utf8");
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}`);
}
}
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(`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.");
console.log(
"User ROM, pinned PRET rebuild and all native profile addresses match.",
);