Checks / web (push) Canceled after 0s
The pack's TM/HM pocket lists TMs and HMs with their moves, gives TMs and teaches either through the ABLE/NOT ABLE party menu, the compatibility and "already knows" checks and LearnMove; a TM is used up and makes the Pokémon happier. Eggs show no level or HP in the party menus, get the Egg stats page with its hatching hint, and are skipped by HealParty. Bug Catcher Wade's number registers (VAR_CALLERID and RandomPhoneMon). The playthrough now runs through Falkner's ZEPHYRBADGE, the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. With MAKE_TEST_SAVES it writes a Violet City backup for the manual checks. Validated with 610 unit tests, 157 browser tests (including all 256 badge bytes against the original BadgeStatBoosts), native/reference builds and reference/vendor/release checks. Egg hatching (#29) and trainer calls (#30) remain on their cards. Closes #31 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
372 lines
15 KiB
JavaScript
372 lines
15 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",
|
|
stringEgg: "String_Egg",
|
|
tmhmMoves: "TMHMMoves",
|
|
eggPic: "EggPic",
|
|
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
|
|
rivalNameString: "NamingScreenJumptable.RivalNameString",
|
|
rivalDefaultName: "NameRival.DefaultName",
|
|
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",
|
|
randomPhoneMon: "RandomPhoneMon",
|
|
checkPokerus: "CheckPokerus",
|
|
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
|
|
warpToSpawnPoint: "WarpToSpawnPoint",
|
|
battleBGMap: "BattleBGMap",
|
|
halveMoney: "HalveMoney",
|
|
getWhiteoutSpawn: "GetWhiteoutSpawn",
|
|
tryReceiveItem: "FindItemInBallScript.TryReceiveItem",
|
|
giveItemDummy: "GiveItemScript_DummyFunction",
|
|
restartMapMusic: "RestartMapMusic",
|
|
fadeOutMusic: "FadeOutMusic",
|
|
fadeOutToBlack: "FadeOutToBlack",
|
|
fadeInFromBlack: "FadeInFromBlack",
|
|
reloadSpritesNoPalettes: "ReloadSpritesNoPalettes",
|
|
receiveSpecialPhoneCall: "CheckSpecialPhoneCall.script",
|
|
specialPhoneCallList: "SpecialPhoneCallList",
|
|
specialCallOnlyWhenOutside: "SpecialCallOnlyWhenOutside",
|
|
specialCallWhereverYouAre: "SpecialCallWhereverYouAre",
|
|
phoneClickText: "PhoneClickText",
|
|
ringTwiceStartCall: "RingTwice_StartCall",
|
|
hangUp: "HangUp",
|
|
initCallReceiveDelay: "InitCallReceiveDelay",
|
|
nameRival: "NameRival",
|
|
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 = {
|
|
playerDamageTaken: "wPlayerDamageTaken",
|
|
enemyDamageTaken: "wEnemyDamageTaken",
|
|
payDayMoney: "wPayDayMoney",
|
|
textDecimalByte: "wTextDecimalByte",
|
|
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",
|
|
callerContact: "wCallerContact",
|
|
surfingPlayerState: "wSurfingPlayerState",
|
|
strengthSpecies: "wStrengthSpecies",
|
|
curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
|
|
visitedSpawns: "wVisitedSpawns",
|
|
tempWildMonSpecies: "wTempWildMonSpecies",
|
|
parkBallsRemaining: "wParkBallsRemaining",
|
|
coins: "wCoins",
|
|
bgMapMode: "hBGMapMode",
|
|
blueCardBalance: "wBlueCardBalance",
|
|
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, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD and OPTIONS entries are keyed by their PRET labels.
|
|
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "OPTIONS"]) {
|
|
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
|
if (!block) 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;
|
|
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",
|
|
};
|
|
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"]
|
|
.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}`);
|
|
}
|
|
}
|
|
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(`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.",
|
|
);
|