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.", );