Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)

- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 08:52:30 +02:00
1 parent 7d03f5c5ca
commit e49bc6de9a
49 files changed
+6028 -88

No files matched your search

+14 -5
View File
@@ -47,6 +47,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. | | `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. | | `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
| `game/party/eggs.ts`, `game/scripts/hatching.ts`, `game/scripts/hatch-animation.ts`, `game/world/world.ts` | `CountStep`, `StepHappiness`, `DoEggStep`, `HatchEggs`, `SetEggMonCaughtData`, `DoBikeStep`, `EggHatch_AnimationSequence`, `SpriteAnimFunc_RevealNewMon` | Walking happiness every 512 counted steps; Egg cycles at byte 128, stopping at the first zero; zero wraps to 255. Hatches commit atomically with ownership, caught data, Pokédex, Togepi flag and naming. Saves during hatching replay the source step. Byte counters and Bicycle count are saved, migrated by absence. Map transitions and battle reloads clear the poison clock; native save restoration preserves its phase. Poison damage remains unported; `DayCareStep` runs after the Egg check, so a hatching step skips it. ROM tile screens render wobbling, cracks and shell fragments: reference checks compare 344 wobble frames plus shell OAM states, tilemaps and palettes for normal Togepi and shiny Pichu. Hardware loading waits and the final species-specific front-picture animation remain absent; Unown uses A instead of stale battle DVs. Sounds are wired but not checked by ear. |
| `game/scripts/screen.ts`, `game/rendering/tile-screen.ts`, `game/rom/font.ts` | `home/tilemap.asm`, `engine/gfx/load_font.asm` (`_LoadStandardFont`, `LoadFrame`) | Tile screens: a 20x18 tilemap with background palettes and sprites over it, built by a minigame or special from ROM graphics and answered with buttons. Blinking sprites follow bit 4 of the frame counter. Overlay screens (a menu box) leave the map visible around them. The standard font and the option's textbox frame load as 2bpp tiles. | | `game/scripts/screen.ts`, `game/rendering/tile-screen.ts`, `game/rom/font.ts` | `home/tilemap.asm`, `engine/gfx/load_font.asm` (`_LoadStandardFont`, `LoadFrame`) | Tile screens: a 20x18 tilemap with background palettes and sprites over it, built by a minigame or special from ROM graphics and answered with buttons. Blinking sprites follow bit 4 of the frame counter. Overlay screens (a menu box) leave the map visible around them. The standard font and the option's textbox frame load as 2bpp tiles. |
| `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. | | `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. |
| `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. | | `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. |
@@ -58,7 +59,9 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/scripts/cianwood.ts`, Routes 38–41, Olivine and Cianwood | `engine/events/shuckle.asm` (`GiveShuckle`, `ReturnShuckie`), `engine/pokemon/bills_pc_top.asm` (`CheckCurPartyMonFainted`), `engine/pokemon/caught_data.asm` (`SetGiftPartyMonCaughtData`), `engine/events/poke_seer.asm`, `engine/events/print_photo.asm`, `engine/printer/printer.asm` (`PrintPartymon`, `CheckPrinterStatus`), `engine/printer/printer_serial.asm`, `engine/events/specials.asm` (`PlaySlowCry`) | Shuckie is a level 15 party-only gift with MANIA's OT, ID 518, nickname, a Berry and `LANDMARK_GIFT` caught data; it sets `ENGINE_GOT_SHUCKIE_TODAY`. `ReturnShuckie` returns all five codes; `CheckCurPartyMonFainted` looks at the rest of the party, and Mania answers `SHUCKIE_FAINTED` with his happiness speech, as the original does. `PokeSeer` follows `ReadCaughtData`, including the missing `cp [hl]` that compares only the OT ID's high byte, `GetCaughtLocation`'s event and gift landmarks, `GetCaughtLevel`'s ??? that leaves `wSeerCaughtLevel` unchanged, and `SeerAdvice`'s inclusive thresholds. `wSeerCaughtLevel` lives in the "Miscellaneous WRAM 1" union: the port keeps the Seer's own last value until a load, while in the original other users of those bytes may change it. `PhotoStudio` has no printer to talk to: the handshake reads $ff, so `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and `SendScreenToPrinter` retries until B sets `hPrinter`. The port shows "Printer Error 2" with "Press B to Cancel" on one page, then the no-photo text; the print page behind the box and its alternation with "CHECKING LINK" are not drawn. `PlaySlowCry` is audio. The story maps run on ported script commands; `tests/olivine-cianwood.test.ts` and six playthrough stages cover them. | | `game/scripts/cianwood.ts`, Routes 38–41, Olivine and Cianwood | `engine/events/shuckle.asm` (`GiveShuckle`, `ReturnShuckie`), `engine/pokemon/bills_pc_top.asm` (`CheckCurPartyMonFainted`), `engine/pokemon/caught_data.asm` (`SetGiftPartyMonCaughtData`), `engine/events/poke_seer.asm`, `engine/events/print_photo.asm`, `engine/printer/printer.asm` (`PrintPartymon`, `CheckPrinterStatus`), `engine/printer/printer_serial.asm`, `engine/events/specials.asm` (`PlaySlowCry`) | Shuckie is a level 15 party-only gift with MANIA's OT, ID 518, nickname, a Berry and `LANDMARK_GIFT` caught data; it sets `ENGINE_GOT_SHUCKIE_TODAY`. `ReturnShuckie` returns all five codes; `CheckCurPartyMonFainted` looks at the rest of the party, and Mania answers `SHUCKIE_FAINTED` with his happiness speech, as the original does. `PokeSeer` follows `ReadCaughtData`, including the missing `cp [hl]` that compares only the OT ID's high byte, `GetCaughtLocation`'s event and gift landmarks, `GetCaughtLevel`'s ??? that leaves `wSeerCaughtLevel` unchanged, and `SeerAdvice`'s inclusive thresholds. `wSeerCaughtLevel` lives in the "Miscellaneous WRAM 1" union: the port keeps the Seer's own last value until a load, while in the original other users of those bytes may change it. `PhotoStudio` has no printer to talk to: the handshake reads $ff, so `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and `SendScreenToPrinter` retries until B sets `hPrinter`. The port shows "Printer Error 2" with "Press B to Cancel" on one page, then the no-photo text; the print page behind the box and its alternation with "CHECKING LINK" are not drawn. `PlaySlowCry` is audio. The story maps run on ported script commands; `tests/olivine-cianwood.test.ts` and six playthrough stages cover them. |
| `game/scripts/lake-of-rage.ts`, Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | `engine/events/magikarp.asm` (`CheckMagikarpLength`, `CalcMagikarpLength`, `PrintMagikarpLength`, `MagikarpHouseSign`), `engine/menus/intro_menu.asm` (`InitializeMagikarpHouse`), `engine/pokemon/search_party.asm` (`FindThatSpecies`), `engine/overworld/scripting.asm` (`DoScene`), `engine/events/money.asm` (`TakeMoney`) | The Magikarp is measured from its own DVs and OT ID with the battle code's `CalcMagikarpLength` (whose `.BCLessThanDE` compares only the high byte). A longer one, compared feet then inches, becomes the record with the Magikarp's OT name as holder; the record starts at 3′6″ by RALPH and is saved as `magikarpRecord`. `FindThatSpecies` reads `wPartySpecies`, where an Egg is EGG, so an Egg is never found. `setmapscene`/`setscene` on a map without a scene variable (Route 43, the Lake of Rage): `GetMapSceneID` leaves `de` alone and `DoScene` still writes the byte through it when it is nonzero. At the Route 43 gate `de` is `EventFlagAction`'s byte offset of `EVENT_CLEARED_ROCKET_HIDEOUT` (4), the MBC3 RAM-enable register, so nothing changes. After the Red Gyarados, `de` is whatever `closetext` left; the port consumes the byte and changes nothing, and that write was not checked in an emulator. `TakeMoney` leaves a short account at zero, so the Route 43 Rockets take all of a smaller balance. The Rocket Hideout's cameras, traps, passwords, executives and Electrodes and Pryce's ice floor run on ported primitives; `PlayCurMonCry` is audio. `tests/mahogany.test.ts` and five playthrough stages cover them. | | `game/scripts/lake-of-rage.ts`, Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | `engine/events/magikarp.asm` (`CheckMagikarpLength`, `CalcMagikarpLength`, `PrintMagikarpLength`, `MagikarpHouseSign`), `engine/menus/intro_menu.asm` (`InitializeMagikarpHouse`), `engine/pokemon/search_party.asm` (`FindThatSpecies`), `engine/overworld/scripting.asm` (`DoScene`), `engine/events/money.asm` (`TakeMoney`) | The Magikarp is measured from its own DVs and OT ID with the battle code's `CalcMagikarpLength` (whose `.BCLessThanDE` compares only the high byte). A longer one, compared feet then inches, becomes the record with the Magikarp's OT name as holder; the record starts at 3′6″ by RALPH and is saved as `magikarpRecord`. `FindThatSpecies` reads `wPartySpecies`, where an Egg is EGG, so an Egg is never found. `setmapscene`/`setscene` on a map without a scene variable (Route 43, the Lake of Rage): `GetMapSceneID` leaves `de` alone and `DoScene` still writes the byte through it when it is nonzero. At the Route 43 gate `de` is `EventFlagAction`'s byte offset of `EVENT_CLEARED_ROCKET_HIDEOUT` (4), the MBC3 RAM-enable register, so nothing changes. After the Red Gyarados, `de` is whatever `closetext` left; the port consumes the byte and changes nothing, and that write was not checked in an emulator. `TakeMoney` leaves a short account at zero, so the Route 43 Rockets take all of a smaller balance. The Rocket Hideout's cameras, traps, passwords, executives and Electrodes and Pryce's ice floor run on ported primitives; `PlayCurMonCry` is audio. `tests/mahogany.test.ts` and five playthrough stages cover them. |
| `game/scripts/blackthorn.ts`, the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den | `engine/events/dratini.asm` (`GiveDratini`), `engine/events/move_deleter.asm` (`MoveDeletion`), `engine/pokemon/mon_menu.asm` (`ChooseMoveToDelete`), `engine/events/whiteout.asm` (`Script_Whiteout`), `data/maps/setup_scripts.asm` (`MapSetupScript_Continue`) | `GiveDratini` searches the party backwards by species and writes `.Movesets` 0 (WRAP, THUNDER WAVE, TWISTER, EXTREMESPEED) or 1 with each move's base PP; a value of 2 or more changes nothing. `MoveDeletion` follows the routine's order of questions; `ChooseMoveToDelete` is shown as a list of the moves, and `.DeleteMove` shifts the later moves and PP bytes, PP Ups included. A save records objects whose visibility no longer matches their flags: `MapSetupScript_Continue` skips `LoadMapObjects`, so a flagless object a script made disappear (the Radio Tower director) stays gone after Continue and one that appeared with its flag set stays visible; a later visit rebuilds them from the map. A battle lost inside an `scall` jumps to `Script_Whiteout` without unwinding; its `newloadmap` is followed by `endall`, which drops the caller. The switch room's doors, the warehouse's reset, the Ice Path and Blackthorn Gym stone tables, whirlpools and the Dragon Shrine quiz run on ported primitives. `tests/blackthorn.test.ts`, `tests/radio-tower.test.ts` and six playthrough stages cover them. | | `game/scripts/blackthorn.ts`, the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den | `engine/events/dratini.asm` (`GiveDratini`), `engine/events/move_deleter.asm` (`MoveDeletion`), `engine/pokemon/mon_menu.asm` (`ChooseMoveToDelete`), `engine/events/whiteout.asm` (`Script_Whiteout`), `data/maps/setup_scripts.asm` (`MapSetupScript_Continue`) | `GiveDratini` searches the party backwards by species and writes `.Movesets` 0 (WRAP, THUNDER WAVE, TWISTER, EXTREMESPEED) or 1 with each move's base PP; a value of 2 or more changes nothing. `MoveDeletion` follows the routine's order of questions; `ChooseMoveToDelete` is shown as a list of the moves, and `.DeleteMove` shifts the later moves and PP bytes, PP Ups included. A save records objects whose visibility no longer matches their flags: `MapSetupScript_Continue` skips `LoadMapObjects`, so a flagless object a script made disappear (the Radio Tower director) stays gone after Continue and one that appeared with its flag set stays visible; a later visit rebuilds them from the map. A battle lost inside an `scall` jumps to `Script_Whiteout` without unwinding; its `newloadmap` is followed by `endall`, which drops the caller. The switch room's doors, the warehouse's reset, the Ice Path and Blackthorn Gym stone tables, whirlpools and the Dragon Shrine quiz run on ported primitives. `tests/blackthorn.test.ts`, `tests/radio-tower.test.ts` and six playthrough stages cover them. |
| `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the fourteen `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched. The original's `sBox` is the current box, and its own `sBoxN` slot may still hold an older copy from before the last box change; the port keeps the boxes without stale copies, so a Pokémon only in such a copy is not found. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. | | `game/party/day-care.ts`, `game/scripts/day-care.ts`, Day-Care and Route 34 | `engine/events/daycare.asm`, `engine/pokemon/breeding.asm`, `engine/pokemon/move_mon.asm`, `engine/pokemon/breedmon_level_growth.asm`, `DayCareStep`, `_GiveOddEgg` | Deposit refusals, level growth/price, move learning on retrieval, compatibility and egg moves/DVs/species, the egg countdown/chance, collection and Odd Egg tables run from the user's ROM. Retrieval keeps the original experience truncation. The save holds both parents and the prepared egg, active/compatible bits, countdown and mother index, with flag consistency validation and absence migration. Day-Care map objects resolve their deposited species' ROM icons. Six controlled-RNG cartridge comparisons cover routine state and byte consumption; native tests cover map scripts and save/reload. Keeper UI/audio timing and fence OAM/palettes are not reference-validated. Hatching is the row above. |
| `game/party/hall-of-fame.ts`, `game/scripts/hall-of-fame.ts`, `game/scripts/pc.ts` | `engine/events/halloffame.asm` (`GetHallOfFameParty`, `_HallOfFamePC`, `LoadHOFTeam`, `DisplayHOFMon`), `engine/menus/save.asm` (`AddHallOfFameEntry`), `engine/events/pokecenter_pc.asm` (`.ChooseWhichPCListToUse`, `HallOfFamePC`) | Up to 30 recorded teams, newest first, with the capped win count; saved and migrated by absence, separate from the League flag. Nothing records a team until the League is ported. The PC lists HALL OF FAME from the count; pages browse with A, Start and B as the original does, and keep its unreachable "HOF Master!" title. A cartridge comparison of 255 teams checks the tilemap, attributes, graphics and palette; the frontpic animation (`HOF_AnimateFrontpic`) is not presented. |
| `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the thirteen other `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched, preserving the original's explicitly documented bug even now that deposits run. The original searches the current `sBox` and skips its numbered `sBoxN` copy; the port searches each logical box once. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. |
| `game/scripts/battle-tower.ts`, the Battle Tower | `engine/events/battle_tower/battle_tower.asm` (`BattleTowerAction`, `RunBattleTowerTrainer`, `ReadBTTrainerParty`, `CopyBTTrainer_FromBT_OT_TowBT_OTTemp`, `BattleTower_GiveReward`, `BattleTower_RandomlyChooseReward`, `LoadOpponentTrainerAndPokemonWithOTSprite`, `CheckForBattleTowerRules`), `engine/events/battle_tower/load_trainer.asm`, `engine/events/battle_tower/rules.asm`, `engine/events/battle_tower/trainer_text.asm`, `mobile/mobile_46.asm` (`_BattleTowerRoomMenu`, `BattleTower_LevelCheck`, `BattleTower_UbersCheck`), `mobile/mobile_5f.asm` (`Menu_ChallengeExplanationCancel`), `engine/link/link.asm` (`TryQuickSave`), `engine/battle/core.asm`, `engine/battle/effect_commands.asm`, `engine/battle/misc.asm` (`DoBadgeTypeBoosts`) | The Battle Tower's SRAM and the work RAM its scripts share are saved as `battleTower`. The trainer sampler keeps its index as the running byte after a repeated trainer; the Pokémon sampler starts again from `hRandomAdd`; both fold `Random`'s `hRandomAdd` into a byte below a power-of-two mask, which the port draws from its random source. `BattleTowerText` reads the class's gender (1.1 behaviour) and picks the greeting from `hRandomAdd` (0-24 male, 0-14 female), keeping it for the end texts. Each battle start counts the battle and marks the challenge in progress, as `CopyBTTrainer_FromBT_OT_TowBT_OTTemp` does; `LoadPokemonData` then returns the party, Pokédex and Unown Dex to the quick save's. The level menu shows the levels as a list; its CANCEL/B question and the mobile code's YES/NO flow are modelled from their outcomes (YES leaves with $a, NO picks a level again). `TryQuickSave` asks the overwrite question for the player's own save and shows the saving texts; the port keeps the place and Pokémon data `LoadPokemonData` and `Reset` return to. The native game saves itself, so the original's "left without saving" state (a reset during a challenge) cannot arise: a reload replays the room's script. `Reset` returns the player to the quick save's place with the 1F scene set to check the challenge; the original's rollback of money gained by Pay Day is not reproduced. In battle, `wInBattleTowerBattle` turns off obedience, `DoBadgeTypeBoosts`, `BadgeStatBoosts`, experience, the pack (BattleMenu_Pack says items can't be used), AI items and prize money, uses Falkner's AI attributes, keeps Sleep to `%011` and skips the AI's random failure for Sleep, Poison, stat-lowering and Paralyze moves; a loss shows BattleTowerText and does not white out. `DoBadgeTypeBoosts` was added to every battle (it was missing). BATTLETOWERACTION_LEVEL_CHECK and _UBERS_CHECK and the mobile actions are reached only from unreferenced scripts and stay refused. `tests/battle-tower.test.ts`, `tests/battle-tower-engine.test.ts` and `tests/reference-badges.spec.ts` cover them. | | `game/scripts/battle-tower.ts`, the Battle Tower | `engine/events/battle_tower/battle_tower.asm` (`BattleTowerAction`, `RunBattleTowerTrainer`, `ReadBTTrainerParty`, `CopyBTTrainer_FromBT_OT_TowBT_OTTemp`, `BattleTower_GiveReward`, `BattleTower_RandomlyChooseReward`, `LoadOpponentTrainerAndPokemonWithOTSprite`, `CheckForBattleTowerRules`), `engine/events/battle_tower/load_trainer.asm`, `engine/events/battle_tower/rules.asm`, `engine/events/battle_tower/trainer_text.asm`, `mobile/mobile_46.asm` (`_BattleTowerRoomMenu`, `BattleTower_LevelCheck`, `BattleTower_UbersCheck`), `mobile/mobile_5f.asm` (`Menu_ChallengeExplanationCancel`), `engine/link/link.asm` (`TryQuickSave`), `engine/battle/core.asm`, `engine/battle/effect_commands.asm`, `engine/battle/misc.asm` (`DoBadgeTypeBoosts`) | The Battle Tower's SRAM and the work RAM its scripts share are saved as `battleTower`. The trainer sampler keeps its index as the running byte after a repeated trainer; the Pokémon sampler starts again from `hRandomAdd`; both fold `Random`'s `hRandomAdd` into a byte below a power-of-two mask, which the port draws from its random source. `BattleTowerText` reads the class's gender (1.1 behaviour) and picks the greeting from `hRandomAdd` (0-24 male, 0-14 female), keeping it for the end texts. Each battle start counts the battle and marks the challenge in progress, as `CopyBTTrainer_FromBT_OT_TowBT_OTTemp` does; `LoadPokemonData` then returns the party, Pokédex and Unown Dex to the quick save's. The level menu shows the levels as a list; its CANCEL/B question and the mobile code's YES/NO flow are modelled from their outcomes (YES leaves with $a, NO picks a level again). `TryQuickSave` asks the overwrite question for the player's own save and shows the saving texts; the port keeps the place and Pokémon data `LoadPokemonData` and `Reset` return to. The native game saves itself, so the original's "left without saving" state (a reset during a challenge) cannot arise: a reload replays the room's script. `Reset` returns the player to the quick save's place with the 1F scene set to check the challenge; the original's rollback of money gained by Pay Day is not reproduced. In battle, `wInBattleTowerBattle` turns off obedience, `DoBadgeTypeBoosts`, `BadgeStatBoosts`, experience, the pack (BattleMenu_Pack says items can't be used), AI items and prize money, uses Falkner's AI attributes, keeps Sleep to `%011` and skips the AI's random failure for Sleep, Poison, stat-lowering and Paralyze moves; a loss shows BattleTowerText and does not white out. `DoBadgeTypeBoosts` was added to every battle (it was missing). BATTLETOWERACTION_LEVEL_CHECK and _UBERS_CHECK and the mobile actions are reached only from unreferenced scripts and stay refused. `tests/battle-tower.test.ts`, `tests/battle-tower-engine.test.ts` and `tests/reference-badges.spec.ts` cover them. |
| `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. | | `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. |
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. | | Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
@@ -402,8 +405,14 @@ after `MailLoseMessageText`) and QUIT, `MailboxPC`'s READ MAIL, PUT IN PACK
`BillsPC_CheckMail_PreventBlackout`'s `PCString_RemoveMail` use the ROM's `BillsPC_CheckMail_PreventBlackout`'s `PCString_RemoveMail` use the ROM's
strings. The naming screen's `mail` mode is `_ComposeMailMessage`: the strings. The naming screen's `mail` mode is `_ComposeMailMessage`: the
first line gets its `<NEXT>` once full, so a second typed line pads it. first line gets its `<NEXT>` once full, so a second typed line pads it.
`ReadAnyMail` shows the message and author natively; `MailGFXPointers`' `ReadAnyMail` draws all ten ROM stationery types and their palettes, the
stationery and the Portrait MAIL's picture are not drawn. A scripted message/author and mirrored Portrait MAIL picture (Unown A).
`tests/reference-mail.spec.ts` compares those tilemaps, used graphics,
message placement and palettes against the cartridge. Start prints; A/B
close the letter. `scripts/printer.ts` shares the absent-printer error with
Bill's PC, Pokédex PRNT and Photo Studio; B cancels and A leaves it open.
Print previews, printer music and alternating link-check status are omitted;
Unown stamps and the Kanto diploma remain unreachable. A scripted
`givepoke` with MAIL (`GivePokeMail`) is refused. `givepoke` with MAIL (`GivePokeMail`) is refused.
`Pack_InterpretJoypad`'s Select runs `SwitchItemsInBag` on the bag `Pack_InterpretJoypad`'s Select runs `SwitchItemsInBag` on the bag
@@ -414,8 +423,8 @@ pack's BACK. The cursor now stays on the last row chosen while the pack is
open. open.
`DoRepelStep` runs in the world where `CountStep` calls it, after `DoRepelStep` runs in the world where `CountStep` calls it, after
`CheckSpecialPhoneCall`; the poison, happiness, egg, Day-Care and bike `CheckSpecialPhoneCall`; `DayCareStep` now follows it. The poison, happiness,
steps of `CountStep` are not ported yet. `RepelWoreOffScript` starts with egg and bike steps of `CountStep` are separate work. `RepelWoreOffScript` starts with
the step's committed position, like a coordinate event, and its committed the step's committed position, like a coordinate event, and its committed
save keeps the last Repel step; a wild battle's committed save likewise save keeps the last Repel step; a wild battle's committed save likewise
keeps the step's count. `TryWildEncounter`'s `CheckRepelEffect` follows keeps the step's count. `TryWildEncounter`'s `CheckRepelEffect` follows
+37 -10
View File
@@ -133,8 +133,8 @@ The numbered sections below keep their full scope. Each one opens with a checkli
## Implemented: the PC and boxes ## Implemented: the PC and boxes
- `TileCollisionStdScripts` is read from the ROM, so facing a `COLL_PC` tile (in any direction, as in the original) runs `PCScript`. Bookshelves, TVs and similar tiles no longer need the player to face up. - `TileCollisionStdScripts` is read from the ROM, so facing a `COLL_PC` tile (in any direction, as in the original) runs `PCScript`. Bookshelves, TVs and similar tiles no longer need the player to face up.
- `PokemonCenterPC` checks for a Pokémon, then lists the PCs from `PokemonCenterPC.WhichPC`: Bill's PC and the player's PC, plus Oak's PC once the Pokédex is owned. The Hall of Fame PC is refused, since no Hall of Fame entry can exist yet. - `PokemonCenterPC` checks for a Pokémon, then lists the PCs from `PokemonCenterPC.WhichPC`: Bill's PC and the player's PC, plus Oak's PC once the Pokédex is owned. The Hall of Fame PC joins them once a team is recorded (see the Hall of Fame PC increment below).
- Bill's PC: deposit (`BillsPC_CheckMail_PreventBlackout` keeps the last Pokémon and the last one that has not fainted; `RestorePPOfDepositedPokemon`), withdraw (`CalcLevel`, then `CalcMonStats` with stat experience, full HP and no status), release with confirmation ("Bye," with the species name), and change box: switch with the ROM's confirmation, name (a blank name keeps the old one) and print (only the empty-box message). Menus and messages come from the ROM; lists are presented natively with each Pokémon's level or each box's count. - Bill's PC: deposit (`BillsPC_CheckMail_PreventBlackout` keeps the last Pokémon and the last one that has not fainted; `RestorePPOfDepositedPokemon`), withdraw (`CalcLevel`, then `CalcMonStats` with stat experience, full HP and no status), release with confirmation ("Bye," with the species name), and change box: switch with the ROM's confirmation, name (a blank name keeps the old one) and print (the empty-box message, or the absent printer's error since the printer increment below). Menus and messages come from the ROM; lists are presented natively with each Pokémon's level or each box's count.
- The player's PC withdraws, deposits (from every pocket; untossable items move one at a time) and tosses items (up to 50 stacks, `RemoveItemFromPocket` slot rules), and holds MAIL since the remaining field items section. The house PC adds DECORATION, which closes the PC after a change. - The player's PC withdraws, deposits (from every pocket; untossable items move one at a time) and tosses items (up to 50 stacks, `RemoveItemFromPocket` slot rules), and holds MAIL since the remaining field items section. The house PC adds DECORATION, which closes the PC after a change.
- Oak's PC rates the Pokédex from `OakRatings`. - Oak's PC rates the Pokédex from `OakRatings`.
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original. - Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
@@ -234,7 +234,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- `DisplayDexEntry` shows the name, category and number of every seen Pokémon. The height, weight and page text appear only once it is caught; otherwise `.Height` and `.Weight` keep their question marks. The picture takes the species' palette, and the footprint comes from `Footprints`. Left and right move the arrow over PAGE, AREA, CRY and PRNT without wrapping. A on PAGE turns the page. Up and down step to the previous or next seen Pokémon, back on page 1 with the arrow on PAGE. B returns to the list where the entries left the cursor. - `DisplayDexEntry` shows the name, category and number of every seen Pokémon. The height, weight and page text appear only once it is caught; otherwise `.Height` and `.Weight` keep their question marks. The picture takes the species' palette, and the footprint comes from `Footprints`. Left and right move the arrow over PAGE, AREA, CRY and PRNT without wrapping. A on PAGE turns the page. Up and down step to the previous or next seen Pokémon, back on page 1 with the arrow on PAGE. B returns to the list where the entries left the cursor.
- The Pokédex tab reads the loaded game's state. It lists New mode's order up to the last seen Pokémon and shows a selected entry with the same rules: unseen Pokémon stay unnamed, and uncaught ones show no measurements or text. Without a ROM or before the Pokédex is received, it says so. - The Pokédex tab reads the loaded game's state. It lists New mode's order up to the last seen Pokémon and shows a selected entry with the same rules: unseen Pokémon stay unnamed, and uncaught ones show no measurements or text. Without a ROM or before the Pokédex is received, it says so.
- `tests/pokedex.test.ts` compares the order, all 251 entries and every footprint with the pinned PRET source. - `tests/pokedex.test.ts` compares the order, all 251 entries and every footprint with the pinned PRET source.
- Not ported: AREA (`Pokedex_GetArea`), CRY (no audio), PRNT (no printer), the option screen (SELECT), the search screen (START), Old and A to Z modes (`wLastDexMode` stays New), Unown mode and the arrow's blink. Screens are native; the strings, entries and graphics come from the ROM. - Not ported: AREA (`Pokedex_GetArea`), CRY (no audio), PRNT (no printer; it now shows the absent printer's error, see below), the option screen (SELECT), the search screen (START), Old and A to Z modes (`wLastDexMode` stays New), Unown mode and the arrow's blink. Screens are native; the strings, entries and graphics come from the ROM.
## Implemented: the region atlas ## Implemented: the region atlas
@@ -294,14 +294,14 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- The Old, Good and Super Rods fish facing water, never while surfing (Oak objects otherwise; the facing tile may hold an object, as in the original). The map header's fish group decides: none gives "Not even a nibble!" at once; otherwise `Fish` bites below 50% + 1 and picks the rod's `FishGroups` entry, with `TimeFishGroups` for the morning and day or the night. The player casts with the fishing frames (`LoadFishingGFX`'s body and rod tiles), jiggles four times on a bite, shows "!", and after "Oh! A bite!" puts the rod away before a `BATTLETYPE_FISH` battle: "The hooked MAGIKARP attacked!". Qwilfish and Remoraid swarms come from trainer calls, which are not ported. New Bark Town, Route 30 and Cherrygrove City have fish. - The Old, Good and Super Rods fish facing water, never while surfing (Oak objects otherwise; the facing tile may hold an object, as in the original). The map header's fish group decides: none gives "Not even a nibble!" at once; otherwise `Fish` bites below 50% + 1 and picks the rod's `FishGroups` entry, with `TimeFishGroups` for the morning and day or the night. The player casts with the fishing frames (`LoadFishingGFX`'s body and rod tiles), jiggles four times on a bite, shows "!", and after "Oh! A bite!" puts the rod away before a `BATTLETYPE_FISH` battle: "The hooked MAGIKARP attacked!". Qwilfish and Remoraid swarms come from trainer calls, which are not ported. New Bark Town, Route 30 and Cherrygrove City have fish.
- In the start menu's pack, Select marks an item and asks "Where should this be moved to?"; A or Select on another row of the same pocket moves it there (the rows between shift), merging it into a stack of the same item unless either holds 99 (what exceeds 99 stays behind), and B ends the move without leaving the pack. Left and right keep the pocket meanwhile. The packs that GIVE and the PC open ignore Select, as `DepositSellPack` does. - In the start menu's pack, Select marks an item and asks "Where should this be moved to?"; A or Select on another row of the same pocket moves it there (the rows between shift), merging it into a stack of the same item unless either holds 99 (what exceeds 99 stays behind), and B ends the move without leaving the pack. Left and right keep the pocket meanwhile. The packs that GIVE and the PC open ignore Select, as `DepositSellPack` does.
- On the MOVE screen, left and right show the previous or next party member's moves, skipping Eggs (`MoveScreenLoop`'s `.cycle_left` and `.cycle_right`, which come back to the same Pokémon past either end), with the cursor back on the first move; not while a move is being moved. The party menu's cursor follows. - On the MOVE screen, left and right show the previous or next party member's moves, skipping Eggs (`MoveScreenLoop`'s `.cycle_left` and `.cycle_right`, which come back to the same Pokémon past either end), with the cursor back on the first move; not while a move is being moved. The party menu's cursor follows.
- MAIL: giving MAIL (from the pack, or swapped for a held item) opens the mail entry, two lines of up to 16 characters with the second after a full first one (`_ComposeMailMessage`), and keeps the message with the player's name and ID, the holder's species and the MAIL (`ComposeMailMessage`). A Pokémon holding MAIL shows MAIL in its submenu: READ shows the letter (the message and the author; the stationery's graphics are not drawn yet), TAKE asks "Send the removed MAIL to your PC?" (`SendMailToPC`: up to ten in the MAILBOX, else "Your PC's MAILBOX is full."), or on NO "The MAIL will lose its message. OK?" and puts the item in the pack. SWITCH carries the MAIL with its Pokémon; another item still needs "Please remove the MAIL first."; Bill's PC refuses to deposit or release a Pokémon holding MAIL ("Remove MAIL."). The player's PC MAIL BOX lists the MAIL by author with READ MAIL, PUT IN PACK (the message is lost) and ATTACH MAIL (not to an Egg or a Pokémon holding an item). The mail entry offers the original's characters that have one of their own; `<PK>`, `<MN>`, `<PO>`, `<KE>` and the 'd-'v ligatures are left out. Save format 3 gained each Pokémon's `mail` and the `mailbox`, migrated by absence to none. - MAIL: giving MAIL (from the pack, or swapped for a held item) opens the mail entry, two lines of up to 16 characters with the second after a full first one (`_ComposeMailMessage`), and keeps the message with the player's name and ID, the holder's species and the MAIL (`ComposeMailMessage`). A Pokémon holding MAIL shows MAIL in its submenu: READ shows the letter with its ROM stationery and the Portrait MAIL picture, TAKE asks "Send the removed MAIL to your PC?" (`SendMailToPC`: up to ten in the MAILBOX, else "Your PC's MAILBOX is full."), or on NO "The MAIL will lose its message. OK?" and puts the item in the pack. SWITCH carries the MAIL with its Pokémon; another item still needs "Please remove the MAIL first."; Bill's PC refuses to deposit or release a Pokémon holding MAIL ("Remove MAIL."). The player's PC MAIL BOX lists the MAIL by author with READ MAIL, PUT IN PACK (the message is lost) and ATTACH MAIL (not to an Egg or a Pokémon holding an item). The mail entry offers the original's characters that have one of their own; `<PK>`, `<MN>`, `<PO>`, `<KE>` and the 'd-'v ligatures are left out. Save format 3 gained each Pokémon's `mail` and the `mailbox`, migrated by absence to none.
- Wild Magikarp now battle: `LoadEnemyMon` draws their DVs again as the original does, through `CalcMagikarpLength` (compared with the original routine) and its filters, whose millimetre limits only work as a floor of four feet that about 39% of draws skip (39 percent + 1), nowhere in map group 9 or on maps numbered 6 (the Lake of Rage check). Unown are still refused. - Wild Magikarp now battle: `LoadEnemyMon` draws their DVs again as the original does, through `CalcMagikarpLength` (compared with the original routine) and its filters, whose millimetre limits only work as a floor of four feet that about 39% of draws skip (39 percent + 1), nowhere in map group 9 or on maps numbered 6 (the Lake of Rage check). Unown are still refused.
- Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0. - Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0.
## Implemented: multi-hit and special-damage moves ## Implemented: multi-hit and special-damage moves
- Multi-hit moves loop as `BattleCommand_EndLoop` does: back to the script's `critical`, so accuracy is checked once; two to five hits (a second draw above two), two for Double Kick, Bonemerang and Twineedle, one to three kicks for Triple Kick with growing damage (its count lives in `wBattleAnimParam`). "Hit n times!" reads the high byte of the user's `wDamageTaken`, which the loop borrows; effectiveness is said on the first hit only. A target that faints mid-loop ends the move without the count. - Multi-hit moves loop as `BattleCommand_EndLoop` does: back to the script's `critical`, so accuracy is checked once; two to five hits (a second draw above two), two for Double Kick, Bonemerang and Twineedle, one to three kicks for Triple Kick with growing damage (its count lives in `wBattleAnimParam`). "Hit n times!" reads the high byte of the user's `wDamageTaken`, which the loop borrows; effectiveness is said on the first hit only. A target that faints mid-loop ends the move without the count.
- Beat Up hits once per party member that is neither fainted nor statused, with its base Attack and level against the target's base Defense. The original's bugs are kept: the player's active member is recognised by comparing `wCurBattleMon` with the HP's low byte, a lone Pokémon skips King's Rock, and a wild user says "But it failed!" after hitting. A party with an Egg is refused until eggs exist. - Beat Up hits once per party member that is neither fainted nor statused, with its base Attack and level against the target's base Defense. The original's bugs are kept: the player's active member is recognised by comparing `wCurBattleMon` with the HP's low byte, a lone Pokémon skips King's Rock, and a wild user says "But it failed!" after hitting. Eggs follow their stored HP and status too: the routine never reads the EGG marker.
- `ConstantDamage`: Seismic Toss and Night Shade (the level), Sonicboom and Dragon Rage, Psywave (below 1.5 × level; a level-1 user is refused, as the original loops forever), Super Fang and Flail/Reversal (HP bar pixels into `FlailReversalPower`, then the move's power becomes 1). Immune types make them miss (`ResetTypeMatchup`). - `ConstantDamage`: Seismic Toss and Night Shade (the level), Sonicboom and Dragon Rage, Psywave (below 1.5 × level; a level-1 user is refused, as the original loops forever), Super Fang and Flail/Reversal (HP bar pixels into `FlailReversalPower`, then the move's power becomes 1). Immune types make them miss (`ResetTypeMatchup`).
- One-hit KO moves, Counter and Mirror Coat (twice the opponent's last physical or special damage when it moved first, still working after the opponent used an item), False Swipe, Magnitude, Present (a full target refuses the gift, said only with battle animations off), Hidden Power, Return and Frustration (no damage at the extremes of happiness, a kept bug), Selfdestruct and Explosion, Dream Eater, Thief (not MAIL; a trainer's Pokémon keeps the player's item), Pay Day (paid out after a win, doubled by an Amulet Coin, which is now checked at every send-out), freezing, Tri Attack, added confusion, Swagger, Flame Wheel and Sacred Fire thawing their user, fire moves thawing their target, Jump Kick's crash damage, Minimize with Stomp's double damage, Earthquake against a digging target and Thunder's weather accuracy. - One-hit KO moves, Counter and Mirror Coat (twice the opponent's last physical or special damage when it moved first, still working after the opponent used an item), False Swipe, Magnitude, Present (a full target refuses the gift, said only with battle animations off), Hidden Power, Return and Frustration (no damage at the extremes of happiness, a kept bug), Selfdestruct and Explosion, Dream Eater, Thief (not MAIL; a trainer's Pokémon keeps the player's item), Pay Day (paid out after a win, doubled by an Amulet Coin, which is now checked at every send-out), freezing, Tri Attack, added confusion, Swagger, Flame Wheel and Sacred Fire thawing their user, fire moves thawing their target, Jump Kick's crash damage, Minimize with Stomp's double damage, Earthquake against a digging target and Thunder's weather accuracy.
- A Focus Band now hangs on at 1 HP even when the hit would only take that 1 HP, and cancels a one-hit KO's text. - A Focus Band now hangs on at 1 HP even when the hit would only take that 1 HP, and cancels a one-hit KO's text.
@@ -367,7 +367,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Route 31 and its Violet gate, Dark Cave's Violet entrance, Violet City with its Mart, gym, Earl's Pokémon Academy, the nickname speech house, the Pokémon Center and Kyle's house, and Sprout Tower's three floors are open, with their scripts, trainers, items, fruit trees and encounters. Route 32, Route 36, Dark Cave's other entrances and the Pokémon Center's second floor stay closed. - Route 31 and its Violet gate, Dark Cave's Violet entrance, Violet City with its Mart, gym, Earl's Pokémon Academy, the nickname speech house, the Pokémon Center and Kyle's house, and Sprout Tower's three floors are open, with their scripts, trainers, items, fruit trees and encounters. Route 32, Route 36, Dark Cave's other entrances and the Pokémon Center's second floor stay closed.
- New script commands: `gettrainername`, `askforphonenumber` (AddPhoneNumber keeps room for Mom and Elm; `addcellnum` now reports failure as the original does), `checkpokemail`, `loadmenu`/`_2dmenu`/`closewindow` (Earl's blackboard, shown as a list), `giveegg`, `trade` (NPCTrade with saved `wTradeFlags`), `loadvar VAR_CALLERID` and the `RandomPhoneMon` special. The trade animation is not presented. - New script commands: `gettrainername`, `askforphonenumber` (AddPhoneNumber keeps room for Mom and Elm; `addcellnum` now reports failure as the original does), `checkpokemail`, `loadmenu`/`_2dmenu`/`closewindow` (Earl's blackboard, shown as a list), `giveegg`, `trade` (NPCTrade with saved `wTradeFlags`), `loadvar VAR_CALLERID` and the `RandomPhoneMon` special. The trade animation is not presented.
- Eggs keep their species with an `egg` flag, as `wPartySpecies` holds EGG: the party menu shows no level or HP, the stats screen shows the Egg page with its hatching hint, healing skips them, and `checkpoke` does not count them. Eggs do not hatch yet (board #29). - Eggs keep their species with an `egg` flag, as `wPartySpecies` holds EGG: the party menu shows no level or HP, the stats screen shows the Egg page with its hatching hint, healing skips them, and `checkpoke` does not count them. Eggs now hatch through the walking routine described below.
- The pack's TM/HM pocket holds counts by number: TMs and HMs are received, listed with their moves, given (TMs) and taught with ABLE/NOT ABLE, the compatibility and "already knows" checks and LearnMove. A TM is used up and makes the Pokémon happier; an HM stays. - The pack's TM/HM pocket holds counts by number: TMs and HMs are received, listed with their moves, given (TMs) and taught with ABLE/NOT ABLE, the compatibility and "already knows" checks and LearnMove. A TM is used up and makes the Pokémon happier; an HM stays.
- The Johto badges raise the battle Pokémon's stats (BadgeStatBoosts, with the PlainBadge/MineralBadge swap and the Glacier Badge register bug), compared against the unmodified routine for all 256 badge bytes in `tests/reference-badges.spec.ts`. - The Johto badges raise the battle Pokémon's stats (BadgeStatBoosts, with the PlainBadge/MineralBadge swap and the Glacier Badge register bug), compared against the unmodified routine for all 256 badge bytes in `tests/reference-badges.spec.ts`.
- `tests/playthrough.test.ts` now continues to Route 31 (Mom's call), Sprout Tower (Elder Li's Flash), Falkner (the ZEPHYRBADGE, TM31 and the aide's call), the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. `tests/violet.test.ts` covers the other answers. - `tests/playthrough.test.ts` now continues to Route 31 (Mom's call), Sprout Tower (Elder Li's Flash), Falkner (the ZEPHYRBADGE, TM31 and the aide's call), the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. `tests/violet.test.ts` covers the other answers.
@@ -383,7 +383,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- The chamber puzzles (`_UnownPuzzle`) run as a tile screen with the ROM's graphics: the random border layout, the cursor's moves around START/CANCEL, picking up and placing pieces, the solved check, then the chamber's floor opens and the player falls into the inner chamber. The wall patterns (`DisplayUnownWords`) draw their word in Unown letters from the chamber's tileset in a menu box over the map. `HoOhChamber` and `OmanyteChamber` open their walls. - The chamber puzzles (`_UnownPuzzle`) run as a tile screen with the ROM's graphics: the random border layout, the cursor's moves around START/CANCEL, picking up and placing pieces, the solved check, then the chamber's floor opens and the player falls into the inner chamber. The wall patterns (`DisplayUnownWords`) draw their word in Unown letters from the chamber's tileset in a menu box over the map. `HoOhChamber` and `OmanyteChamber` open their walls.
- Tile screens are a general effect for minigames and specials that draw a whole Game Boy screen in tiles (`scripts/screen.ts`, `rendering/tile-screen.ts`): a tilemap with palettes and sprites, answered with buttons. `rom/font.ts` supplies the standard font and the chosen textbox frame for them. - Tile screens are a general effect for minigames and specials that draw a whole Game Boy screen in tiles (`scripts/screen.ts`, `rendering/tile-screen.ts`): a tilemap with palettes and sprites, answered with buttons. `rom/font.ts` supplies the standard font and the chosen textbox frame for them.
- `tests/playthrough.test.ts` continues through Route 32, Union Cave, Azalea Town, the Slowpoke Well, Bugsy, the rival and Ilex Forest to the Route 34 gate; its helpers now live in `tests/playthrough-driver.ts`, and `PLAYTHROUGH_SAVES` keeps each stage's save. `tests/ruins.test.ts` solves the Kabuto puzzle through the world and catches Unown; `tests/azalea.test.ts` covers Kurt's apricorns. - `tests/playthrough.test.ts` continues through Route 32, Union Cave, Azalea Town, the Slowpoke Well, Bugsy, the rival and Ilex Forest to the Route 34 gate; its helpers now live in `tests/playthrough-driver.ts`, and `PLAYTHROUGH_SAVES` keeps each stage's save. `tests/ruins.test.ts` solves the Kabuto puzzle through the world and catches Unown; `tests/azalea.test.ts` covers Kurt's apricorns.
- Still closed or refused here: the Day-Care's deposits and Eggs (board #29), the radios' `MapRadio` and trainers' rematch counts (board #30), the research center's `UnownPrinter` (it needs all 26 letters), the Pokédex's Unown mode, the GS Ball and Celebi shrine (not reachable without the mobile event), and sound. - Still closed or refused here: the radios' `MapRadio` and trainers' rematch counts (board #30), the research center's `UnownPrinter` (it needs all 26 letters), the Pokédex's Unown mode, the GS Ball and Celebi shrine (not reachable without the mobile event), and sound.
## Implemented: Goldenrod City (board #32, increment B) ## Implemented: Goldenrod City (board #32, increment B)
@@ -447,9 +447,9 @@ The numbered sections below keep their full scope. Each one opens with a checkli
## Implemented: Tin Tower and Whirl Islands (board #32, increment G) ## Implemented: Tin Tower and Whirl Islands (board #32, increment G)
- Tin Tower's nine floors and roof and the Whirl Islands' eight maps are open. The Clear Bell lets the player past the entrance's sages and through the Wise Trio's room; in Tin Tower 1F Raikou and Entei flee unless caught, and Suicune battles (`BATTLETYPE_SUICUNE`), followed by Eusine. - Tin Tower's nine floors and roof and the Whirl Islands' eight maps are open. The Clear Bell lets the player past the entrance's sages and through the Wise Trio's room; in Tin Tower 1F Raikou and Entei flee unless caught, and Suicune battles (`BATTLETYPE_SUICUNE`), followed by Eusine.
- New specials (`game/scripts/tin-tower.ts`): `MonCheck` and `BeastsCheck` over `CheckOwnMonAnywhere`: the party and every box, the player's ID and the first five letters of the OT, an empty party finding nothing, the Day-Care left out. After the League with all three beasts, the second Wise Trio appears and gives the Rainbow Wing; its stairs lead up to Ho-Oh on the roof. Lugia waits at the bottom of the Whirl Islands for a player with the Silver Wing. Both battles use `BATTLETYPE_FORCEITEM`. - New specials (`game/scripts/tin-tower.ts`): `MonCheck` and `BeastsCheck` over `CheckOwnMonAnywhere`: the party and every box, the player's ID and the first five letters of the OT, an empty party finding nothing, the Day-Care omitted by the original's documented bug. After the League with all three beasts, the second Wise Trio appears and gives the Rainbow Wing; its stairs lead up to Ho-Oh on the roof. Lugia waits at the bottom of the Whirl Islands for a player with the Silver Wing. Both battles use `BATTLETYPE_FORCEITEM`.
- The playthrough driver plans ledge jumps (for the later stages only, so the earlier seeded stages keep their recorded paths), waterfalls carrying a surfer down, warp panels landing elsewhere on a map, and Surf and Whirlpool between warps in `travel`. Tests: `tests/tin-tower.test.ts` (MonCheck's rules, the Rainbow Wing and the climb to Ho-Oh, the Whirl Islands to Lugia). The playthrough continues for two more stages: Routes 45 and 46 back to Ecruteak, and Tin Tower's Suicune. - The playthrough driver plans ledge jumps (for the later stages only, so the earlier seeded stages keep their recorded paths), waterfalls carrying a surfer down, warp panels landing elsewhere on a map, and Surf and Whirlpool between warps in `travel`. Tests: `tests/tin-tower.test.ts` (MonCheck's rules, the Rainbow Wing and the climb to Ho-Oh, the Whirl Islands to Lugia). The playthrough continues for two more stages: Routes 45 and 46 back to Ecruteak, and Tin Tower's Suicune.
- Remaining: the Battle Tower (increment H below), Olivine's port and the S.S. Aqua (board #33), radio and rematches (#30), breeding (#29), mobile and link features, and audio. `CheckOwnMonAnywhere` in the original also reads the current box's own SRAM copy, which can be stale; the port keeps no stale copy. - Remaining: the Battle Tower (increment H below), Olivine's port and the S.S. Aqua (board #33), radio and rematches (#30), breeding (#29), mobile and link features, and audio. `CheckOwnMonAnywhere` searches the current box and skips its numbered SRAM copy; the port searches each logical box once.
## Implemented: the Battle Tower (board #32, increment H) ## Implemented: the Battle Tower (board #32, increment H)
@@ -492,6 +492,33 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Import preview (`previewImport`) checks the envelope, cartridge, player, base64 and the game state in a scratch world (`NativeRuntime.inspectState`) before anything changes, and shows the trainer and ID, place, play time, badges, caught count, saved time, the game data version (and its upgrade) and the slots of the same trainer, marking one with identical progress. The player adds the backup as a new save or replaces the running save or a conflicting one; a replaced slot keeps its previous record. - Import preview (`previewImport`) checks the envelope, cartridge, player, base64 and the game state in a scratch world (`NativeRuntime.inspectState`) before anything changes, and shows the trainer and ID, place, play time, badges, caught count, saved time, the game data version (and its upgrade) and the slots of the same trainer, marking one with identical progress. The player adds the backup as a new save or replaces the running save or a conflicting one; a replaced slot keeps its previous record.
- Tests: `tests/save-slots.test.ts` (migration once, a newer singleton kept, slot lifecycle, autosave isolation, recovery from a damaged or missing newest record, an interrupted write, rollback when a slot cannot be opened, rejected imports, conflicts and both import targets), `tests/native.test.ts` (`describe` on saves of schema 1, 2 and 3) and `tests/save-slots-browser.spec.ts` (the migration and every Savegames action in Chromium and WebKit). - Tests: `tests/save-slots.test.ts` (migration once, a newer singleton kept, slot lifecycle, autosave isolation, recovery from a damaged or missing newest record, an interrupted write, rollback when a slot cannot be opened, rejected imports, conflicts and both import targets), `tests/native.test.ts` (`describe` on saves of schema 1, 2 and 3) and `tests/save-slots-browser.spec.ts` (the migration and every Savegames action in Chromium and WebKit).
## Implemented: MAIL stationery and printer cancellation
- `ReadAnyMail` now draws all ten stationery types from the ROM, including their color planes, palettes, message/author positions and the mirrored Portrait MAIL picture (Unown A). A/B return; Start opens printing, and B cancels back to the unchanged letter. The accessible message and author remain available with the canvas.
- Nonempty Bill's PC boxes, Pokédex PRNT, MAIL and the Photo Studio share `SendScreenToPrinter`'s absent-printer result (`PRINTER_ERROR_2`). A does not dismiss the error; B cancels. Empty boxes still show their original refusal. The print preview, printer music and alternating link-check status are not presented. Unown stamps and the diploma remain unreachable with the unported Unown mode and Kanto maps.
- Controlled cartridge comparisons cover all ten stationery tilemaps, used graphics, message/author placement and palettes; the font is loaded separately from ROM. Chromium and mobile WebKit checks cover writing, reading, printing and returning to MAIL. Unit checks cover the four reachable print paths and unchanged stored data.
## Implemented: Day-Care and breeding (board #29, egg creation)
- `DayCareMan`, `DayCareLady`, `DayCareAskDepositPokemon`, withdrawal pricing/text, deposits and `RetrieveBreedmon` run through the ROM map scripts. The last Pokémon, last usable Pokémon, Eggs and MAIL holders are refused; retrieval restores HP/PP, learns intervening moves and charges ¥100 plus ¥100 per gained level. The cartridge's loss of experience beyond the retrieved level is preserved.
- `CheckBreedmonCompatibility`, `DayCare_InitBreeding`, `InitEggMoves`, `GetHeritableMoves`, `GetBreedmonMovePointer` and `DayCareStep` preserve Ditto, gender, egg-group, matching-DV and trainer-ID rules, Nidoran species selection, inherited DVs/moves, random-byte order and egg-chance thresholds. The Day-Care gains experience on counted steps. Route 34's callbacks place the man inside/outside and show deposited species using ROM party icons; its compatibility texts and egg collection/full-party/refusal paths run.
- `GiveOddEgg` selects the ROM probability row and copies its species, DVs, moves, stats, name and OT; the Egg Ticket is consumed when held. The stored parents, prepared egg, active/compatible bits, step countdown and mother index live in version-3 saves, migrate by absence and are validated alongside the three engine flags. A refused egg follows the cartridge script's flag-clearing behavior. Tin Tower ownership deliberately continues to omit deposited Pokémon: `engine/pokemon/search_owned.asm` labels this an original bug.
- Validation: 47 Day-Care unit cases plus the Tin Tower ownership regression; the full unit suite passes 916 tests (three skipped), with typecheck and pinned reference-address verification passing; six Chromium reference checks compare compatibility, level growth, egg construction, step experience/chance, retrieval and Odd Egg results, including RNG-byte consumption. These execute the cartridge routines with only Random replaced by controlled bytes. They do not establish frame-perfect keeper UI/audio or fence-sprite OAM/palette parity. Hatching and the full `CountStep` sequence follow below.
## Implemented: walking counters and Egg hatching
- `CountStep` runs after coordinate events and before random encounters. Special calls and an expired Repel consume the step event first. The saved byte counter wraps every 256 steps; `StepHappiness` increases non-Egg happiness on alternate wraps (512 steps), saturating at 255. At byte 128, `DoEggStep` decrements Eggs in order and stops at the first reaching zero; an already-zero byte wraps to 255. `DoBikeStep` counts eligible Bicycle steps and queues the Bike Shop call from 1024 steps. `DayCareStep` follows the Egg check, as in the original, so the step that starts a hatch gives the Day-Care nothing; overworld poison damage/fainting remains unported.
- `HatchEggs` hatches every zero-cycle Egg in party order on a script draft: happiness 120, caught time/place at level 1, full recalculated HP, cleared status, player OT/ID, seen/caught registration, Togepi's event, and the ROM nickname question. Mid-hatch saves rewind the triggering tile and counters; failed presentation restores the step and party. Save format 3 gained `stepCount`, `poisonStepCount`, `happinessStepCount`, `bikeStep`, defaulting to zero in older saves.
- The ROM's Egg and hatchling pictures, palettes, 344 wobble frames, three cracks and ten shell fragments run on a realtime tile screen. Horizontal scrolling extends the existing screen renderer. `tests/reference-hatching.spec.ts` checks all wobble OAM/tilemap/attribute/scroll frames, shell OAM states and normal Togepi/shiny Pichu palettes against the original. Object palettes 0/1 remain the overworld colors, as `_CGB_Evolution` only reloads 2–7. Hardware loading waits and the final species-specific `AnimateFrontpic` are not reproduced; the hatchling holds its base frame and cries. A hypothetical Unown Egg uses letter A rather than the original's stale battle-DV letter bug.
- `tests/egg-hatching.test.ts` covers byte rules, 512-step happiness, walking Togepi cycles, multiple Eggs, caught data, naming, mid-hatch replay, rollback, expired Repel and migration; Beat Up tests include Eggs. The continuous playthrough now waits through realtime screens and hatches Togepi naturally. No physical-device or listening validation.
## Implemented: the Hall of Fame PC (board #29)
- `wHallOfFameCount` and `sHallOfFame` are saved as `hallOfFameCount` and `hallOfFameTeams` (newest first, at most 30 teams of up to six Pokémon with species, ID, DVs, level and nickname), migrated by absence to none and validated against each other. They are separate from `hallOfFame` (`STATUSFLAGS_HALL_OF_FAME_F`), which the Battle Tower, radio, Pokégear and marts read.
- `GetHallOfFameParty` (Eggs left out) and `AddHallOfFameEntry` (the oldest of 30 teams drops off, the count stops at 200) are ported as `addHallOfFameEntry`, but nothing calls it until the League's ceremony is ported.
- `PokemonCenterPC.ChooseWhichPCListToUse` picks `PCPC_POSTGAME` from the count, as the original does, so HALL OF FAME appears once a team is recorded. `_HallOfFamePC`, `LoadHOFTeam` and `DisplayHOFMon` show each Pokémon's page with its cry: A shows the next Pokémon, then the next team; Start skips to the next team; B returns to the PC. The original's unreachable "HOF Master!" title (it needs a count of 201) is kept.
- `tests/reference-hall-of-fame.spec.ts` writes 255 teams (every species, Unown, level 100, the Nidorans, win 200, all eight frames) into the cartridge's SRAM, runs its `_HallOfFamePC`, and compares the tilemap, attributes, graphics and palette with the port's page. `HOF_AnimateFrontpic` is the one routine stubbed: the port shows the picture at rest, without the `ANIM_MON_HOF` animation. `tests/hall-of-fame.test.ts` covers the records, the PC menu with and without them, browsing, and save, reload and migration.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
@@ -585,7 +612,7 @@ Acceptance: finish the opening quest, including encounters and the rival battle,
- [x] Pokémon Center healing, standard Poké Marts and Reflect/Light Screen - [x] Pokémon Center healing, standard Poké Marts and Reflect/Light Screen
- [x] Remaining battle commands, move and item effects, AI layers and special battles (board #28) - [x] Remaining battle commands, move and item effects, AI layers and special battles (board #28)
- [x] Evolution by level, happiness, Tyrogue's stats and stones, with Rare Candy and vitamins (trade evolutions need Link) - [x] Evolution by level, happiness, Tyrogue's stats and stones, with Rare Candy and vitamins (trade evolutions need Link)
- [ ] Breeding, eggs and the rest of PC storage - [x] Breeding, eggs and the rest of PC storage (board #29)
- [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame - [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame
- [x] Johto beyond Route 30: every city, gym, Rocket event, puzzle and minigame to the Rising Badge, Tin Tower, Whirl Islands and the Battle Tower - [x] Johto beyond Route 30: every city, gym, Rocket event, puzzle and minigame to the Rising Badge, Tin Tower, Whirl Islands and the Battle Tower
- [ ] The League, Kanto and the final battle - [ ] The League, Kanto and the final battle
+6 -1
View File
@@ -229,7 +229,7 @@ for (const table of ["SAVED_WRAM", "WRAM"]) {
} }
} }
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO and AUDIO entries are keyed by their PRET labels. // BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO 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"]) { for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1]; 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.`); 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)) { for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
@@ -278,6 +278,7 @@ const eventNames = {
bugCatchingContestant8A: "EVENT_BUG_CATCHING_CONTESTANT_8A", bugCatchingContestant8A: "EVENT_BUG_CATCHING_CONTESTANT_8A",
bugCatchingContestant9A: "EVENT_BUG_CATCHING_CONTESTANT_9A", bugCatchingContestant9A: "EVENT_BUG_CATCHING_CONTESTANT_9A",
bugCatchingContestant10A: "EVENT_BUG_CATCHING_CONTESTANT_10A", bugCatchingContestant10A: "EVENT_BUG_CATCHING_CONTESTANT_10A",
togepiHatched: "EVENT_TOGEPI_HATCHED",
}; };
for (const [key, name] of Object.entries(eventNames)) { for (const [key, name] of Object.entries(eventNames)) {
const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile); const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile);
@@ -380,9 +381,13 @@ if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREE
if (!profile.includes(`bugContestTimer: ${pretConstants.get("ENGINE_BUG_CONTEST_TIMER")},`)) if (!profile.includes(`bugContestTimer: ${pretConstants.get("ENGINE_BUG_CONTEST_TIMER")},`))
throw new Error("ENGINE_BUG_CONTEST_TIMER does not match the reference."); throw new Error("ENGINE_BUG_CONTEST_TIMER does not match the reference.");
for (const [key, name] of [ for (const [key, name] of [
["bikeShopCall", "ENGINE_BIKE_SHOP_CALL_ENABLED"],
["rocketsInRadioTower", "ENGINE_ROCKETS_IN_RADIO_TOWER"], ["rocketsInRadioTower", "ENGINE_ROCKETS_IN_RADIO_TOWER"],
["rocketsInMahogany", "ENGINE_ROCKETS_IN_MAHOGANY"], ["rocketsInMahogany", "ENGINE_ROCKETS_IN_MAHOGANY"],
["expnCard", "ENGINE_EXPN_CARD"], ["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"], ["rocketSignal", "ENGINE_ROCKET_SIGNAL_ON_CH20"],
["fishSwarm", "ENGINE_QWILFISH_SWARM"], ["fishSwarm", "ENGINE_QWILFISH_SWARM"],
["buenasPassword", "ENGINE_BUENAS_PASSWORD"], ["buenasPassword", "ENGINE_BUENAS_PASSWORD"],
+2 -2
View File
@@ -3622,8 +3622,8 @@ export class Battle {
} }
const mon = party[this.beatUpMon]; const mon = party[this.beatUpMon];
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party."); if (!mon) throw new UnsupportedBattle("Beat Up ran past the party.");
if (party.some((member) => member.egg)) // Beat Up reads the party struct, never wPartySpecies: Eggs can hit
throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet."); // if their stored HP and status allow it (an original quirk).
// The player's members go by nickname, a trainer's by species name. // The player's members go by nickname, a trainer's by species name.
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species); this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
const skip = () => this.skipTo(COMMAND.buildopponentrage); const skip = () => this.skipTo(COMMAND.buildopponentrage);
+549
View File
@@ -0,0 +1,549 @@
import { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, JOHTO, SYMBOLS } from "../rom/profile";
import { defaultTextContext, readString } from "../scripts/text";
import {
calcStats,
initialMoves,
levelForExp,
maxPP,
validBoxFields,
type PartyMon,
} from "./pokemon";
import { toBoxMon, type BoxMon } from "./storage";
/** The Day-Care's bytes (wDayCareMan through wEggMon in ram/wram.asm).
* The three engine flags it also holds (ENGINE_DAY_CARE_MAN_HAS_EGG,
* ENGINE_DAY_CARE_MAN_HAS_MON and ENGINE_DAY_CARE_LADY_HAS_MON, bits of
* wDayCareMan and wDayCareLady) live in the script state's flags, where
* checkflag, setflag and clearflag find them; `mon1`/`mon2` keep
* both in step. The Pokémon are kept with their nickname and OT, as
* wBreedMon1Nickname/OT/box_struct are. */
export interface DayCareState {
/** wDayCareMan bit 7 (DAYCAREMAN_ACTIVE_F): the man has been spoken to. */
manActive: boolean;
/** wDayCareLady bit 7 (DAYCARELADY_ACTIVE_F): so has the lady. */
ladyActive: boolean;
/** wDayCareMan bit 5 (DAYCAREMAN_MONS_COMPATIBLE_F): an egg may come. */
compatible: boolean;
/** wStepsToEgg: DayCareStep counts it down while the Pokémon are
* compatible. */
stepsToEgg: number;
/** wBreedMotherOrNonDitto: 0 when wBreedMon1 is the mother (or the non-
* Ditto parent), else 1. Kept after the Pokémon leave, as in WRAM. */
motherOrNonDitto: number;
/** wBreedMon1 (the man's) and wBreedMon2 (the lady's); null while the
* ENGINE_DAY_CARE_*_HAS_MON flag is clear. */
mon1: BoxMon | null;
mon2: BoxMon | null;
/** wEggMon, with its nickname "EGG" and the player's name as OT: the egg
* InitBreeding made, which the man hands over; null before the first. */
egg: BoxMon | null;
}
export const emptyDayCare = (): DayCareState => ({
manActive: false,
ladyActive: false,
compatible: false,
stepsToEgg: 0,
motherOrNonDitto: 0,
mon1: null,
mon2: null,
egg: null,
});
export const copyDayCare = (dayCare: DayCareState): DayCareState =>
structuredClone(dayCare);
/** What the Day-Care routines read of the script state. */
export interface DayCareHost {
flags: Set<number>;
dayCare: DayCareState;
}
const DITTO = 132;
const NIDORAN_F = 29;
const NIDORAN_M = 32;
/** EGG_LEVEL, EGG_NONE * $11 and MAX_LEVEL. */
export const EGG_LEVEL = 5;
const EGG_NONE_GROUPS = 0xff;
const MAX_LEVEL = 100;
/** MAX_DAY_CARE_EXP >> 8, as its top byte is compared. */
const MAX_DAY_CARE_EXP_HIGH = 0x50;
/** The three ENGINE_DAY_CARE_* flags. */
export const DAY_CARE_FLAGS = [
ENGINE_FLAGS.dayCareManHasEgg,
ENGINE_FLAGS.dayCareManHasMon,
ENGINE_FLAGS.dayCareLadyHasMon,
] as const;
/** A stored Pokémon's moves, as the 4-slot tuple the structs hold. */
type Moves = BoxMon["moves"];
/** CheckBreedmonCompatibility's .CheckBreedingGroupCompatibility: either
* Pokémon in the No Eggs group (EGG_NONE) rules breeding out; a Ditto
* suits every other group; otherwise some egg group must be shared. */
function sharesEggGroup(
data: PokemonData,
species1: number,
species2: number,
): boolean {
const groups1 = data.baseData(species1).eggGroups,
groups2 = data.baseData(species2).eggGroups;
if (groups2 === EGG_NONE_GROUPS || groups1 === EGG_NONE_GROUPS) return false;
if (species2 === DITTO) return true;
const b = groups2 & 15,
c = groups2 >> 4;
if (species1 === DITTO) return true;
const d = groups1 & 15,
e = groups1 >> 4;
return d === b || d === c || e === b || e === c;
}
/** CheckBreedmonCompatibility (wBreedingCompatibility): 0 when they will
* not breed, 255 when their DVs are too alike (shown as "brimming with
* energy", and treated as no breeding by DayCare_InitBreeding), else 254
* for the same species and 128 for different ones, less 77 when the OT IDs
* match. Pokémon of the same gender are compared as if genderless. */
export function breedingCompatibility(
data: PokemonData,
mon1: BoxMon,
mon2: BoxMon,
): number {
if (!sharesEggGroup(data, mon1.species, mon2.species)) return 0;
const gender1 = data.gender(mon1.species, mon1.dvs);
const gender2 = data.gender(mon2.species, mon2.dvs);
// A pair of differing genders goes straight to .compute; everything
// else (genderless, or the same gender) needs a Ditto.
if (gender1 === undefined || gender2 === undefined || gender1 === gender2) {
if (mon1.species === DITTO) {
if (mon2.species === DITTO) return 0;
} else if (mon2.species !== DITTO) return 0;
}
// .CheckDVs: equal Defense DVs and equal low 3 bits of the Special DVs.
if (
(mon1.dvs & 0x0f00) === (mon2.dvs & 0x0f00) &&
(mon1.dvs & 7) === (mon2.dvs & 7)
)
return 255;
let compatibility = mon1.species === mon2.species ? 254 : 128;
if (mon1.otId === mon2.otId) compatibility -= 77;
return compatibility;
}
/** GetHeritableMoves: whose moves the egg may learn from (the father's;
* a Ditto's own, if it is the "father"). */
export function heritableMoves(
data: PokemonData,
dayCare: DayCareState,
): Moves {
const mon1 = dayCare.mon1!,
mon2 = dayCare.mon2!;
if (mon1.species === DITTO)
return data.gender(mon2.species, mon2.dvs) === "female"
? mon1.moves
: mon2.moves;
if (mon2.species === DITTO)
return data.gender(mon1.species, mon1.dvs) === "female"
? mon2.moves
: mon1.moves;
return dayCare.motherOrNonDitto === 0 ? mon2.moves : mon1.moves;
}
/** GetBreedmonMovePointer: the Ditto's moves if there is one (although
* the mother is the other parent), else the mother's. */
function motherMoves(dayCare: DayCareState): Moves {
const mon1 = dayCare.mon1!,
mon2 = dayCare.mon2!;
if (mon1.species === DITTO) return mon1.moves;
if (mon2.species === DITTO) return mon2.moves;
return dayCare.motherOrNonDitto === 0 ? mon1.moves : mon2.moves;
}
/** GetEggMove: whether the egg inherits a move of the father's. It must be
* an egg move of the baby, or a level-up move of the baby that the mother
* knows too, or a TM, HM or tutor move the baby can learn. */
function inheritsMove(
data: PokemonData,
dayCare: DayCareState,
species: number,
move: number,
): boolean {
if (data.eggMoves(species).includes(move)) return true;
if (
motherMoves(dayCare).includes(move) &&
data.learnset(species).some((entry) => entry.move === move)
)
return true;
return data.canLearnTMHM(species, move);
}
/** LoadEggMove: into the first empty slot, else the moves shift up and the
* new one takes the last. */
function loadEggMove(moves: number[], move: number) {
const slot = moves.indexOf(0);
if (slot >= 0) moves[slot] = move;
else moves.splice(0, 4, moves[1], moves[2], moves[3], move);
}
/** FillPP: the base PP of each move, 0 for an empty slot. */
const basePP = (data: PokemonData, moves: readonly number[]) =>
moves.map((move) => (move ? data.move(move).pp : 0)) as BoxMon["pp"];
/** InitEggMoves: the father's moves (up to the first empty slot) that the
* egg does not know and GetEggMove accepts. */
function initEggMoves(data: PokemonData, dayCare: DayCareState, egg: BoxMon) {
const moves: number[] = [...egg.moves];
for (const move of heritableMoves(data, dayCare)) {
if (!move) break;
if (moves.includes(move)) continue;
if (inheritsMove(data, dayCare, egg.species, move))
loadEggMove(moves, move);
}
egg.moves = moves as Moves;
egg.pp = basePP(data, moves);
}
/** DayCare_InitBreeding's .UselessJump onwards: the egg for these two
* Pokémon. Draws for the egg species (Nidoran♀ only) and the DVs, in the
* original's order. */
function makeEgg(
data: PokemonData,
dayCare: DayCareState,
player: { id: number; name: string },
random: () => number,
): BoxMon {
const mon1 = dayCare.mon1!,
mon2 = dayCare.mon2!;
// wBreedMotherOrNonDitto: 1 if wBreedMon2 is the mother (or the non-
// Ditto parent), else 0; z (female) in GetGender is the mother.
if (mon1.species === DITTO) dayCare.motherOrNonDitto = 1;
else if (mon2.species === DITTO) dayCare.motherOrNonDitto = 0;
else
dayCare.motherOrNonDitto =
data.gender(mon1.species, mon1.dvs) === "female" ? 0 : 1;
// The mother's species, then GetPreEvolution twice.
let species = dayCare.motherOrNonDitto === 0 ? mon1.species : mon2.species;
species = data.preEvolution(data.preEvolution(species));
// Nidoran♀ can give birth to either gender of Nidoran.
if (species === NIDORAN_F && !(random() < 0x80)) species = NIDORAN_M;
const base = data.baseData(species);
const egg: BoxMon = {
species,
item: 0,
moves: initialMoves(data, species, EGG_LEVEL),
otId: player.id,
ot: player.name,
nickname: readString(
data.reader,
SYMBOLS.stringEgg,
defaultTextContext(""),
),
exp: data.expAtLevel(base.growthRate, EGG_LEVEL),
statExp: [0, 0, 0, 0, 0],
dvs: 0,
pp: [0, 0, 0, 0],
happiness: base.eggCycles,
pokerus: 0,
caughtData: 0,
level: EGG_LEVEL,
egg: true,
};
initEggMoves(data, dayCare, egg);
// Two random bytes of DVs; the Defense DV and the low 3 bits of the
// Special DVs then come from one parent (.GotDVs).
const attackDefense = random(),
speedSpecial = random();
let dvs = (attackDefense << 8) | speedSpecial;
let parent: BoxMon | undefined;
if (mon1.species === DITTO) parent = mon1;
else if (mon2.species === DITTO) parent = mon2;
else {
const gender = data.gender(species, dvs);
// A genderless egg (.SkipDVs) keeps its random DVs; a female egg takes
// them from the father, a male egg from the mother.
if (gender === "female")
parent = dayCare.motherOrNonDitto !== 0 ? mon1 : mon2;
else if (gender === "male")
parent = dayCare.motherOrNonDitto === 0 ? mon1 : mon2;
}
if (parent) dvs = (dvs & 0xf0f8) | (parent.dvs & 0x0f00) | (parent.dvs & 7);
egg.dvs = dvs;
return egg;
}
/** DayCare_InitBreeding: when both are in and will breed (not 0 or 255),
* the steps to the next egg check (a draw of at least 150) and the egg
* are prepared, replacing any earlier. */
export function initBreeding(
data: PokemonData,
host: DayCareHost,
player: { id: number; name: string },
random: () => number,
) {
const dayCare = host.dayCare;
if (!host.flags.has(ENGINE_FLAGS.dayCareLadyHasMon)) return;
if (!host.flags.has(ENGINE_FLAGS.dayCareManHasMon)) return;
const compatibility = breedingCompatibility(
data,
dayCare.mon1!,
dayCare.mon2!,
);
if (compatibility === 0 || compatibility === 255) return;
dayCare.compatible = true;
let steps: number;
do steps = random();
while (steps < 150);
dayCare.stepsToEgg = steps;
dayCare.egg = makeEgg(data, dayCare, player, random);
}
/** The percent chance DayCareStep checks (as a byte to beat) for each
* band of compatibility: 31 percent + 1, 16, 12 and 4 percent. */
function eggChance(compatibility: number) {
if (compatibility >= 230) return Math.floor((31 * 0xff) / 100) + 1;
if (compatibility >= 170) return Math.floor((16 * 0xff) / 100);
if (compatibility >= 110) return Math.floor((12 * 0xff) / 100);
return Math.floor((4 * 0xff) / 100);
}
/** One step's experience for a Day-Care Pokémon: the 24-bit increment of
* DayCareStep, with its (unreachable) cap on the top byte. */
function gainStepExp(mon: BoxMon) {
if (mon.level >= MAX_LEVEL) return;
let low = (mon.exp & 0xff) + 1,
middle = (mon.exp >> 8) & 0xff,
high = mon.exp >> 16;
if (low > 0xff) {
low = 0;
if (++middle > 0xff) {
middle = 0;
high = (high + 1) & 0xff;
if (high >= MAX_DAY_CARE_EXP_HIGH) high = MAX_DAY_CARE_EXP_HIGH;
}
}
mon.exp = (high << 16) | (middle << 8) | low;
}
/** DayCareStep (engine/events/happiness_egg.asm): +1 experience for each
* Pokémon, then, while the pair is compatible, the egg check every
* wStepsToEgg steps. */
export function dayCareStep(
data: PokemonData,
host: DayCareHost,
random: () => number,
) {
const dayCare = host.dayCare;
if (host.flags.has(ENGINE_FLAGS.dayCareManHasMon) && dayCare.mon1)
gainStepExp(dayCare.mon1);
if (host.flags.has(ENGINE_FLAGS.dayCareLadyHasMon) && dayCare.mon2)
gainStepExp(dayCare.mon2);
if (!dayCare.compatible) return;
dayCare.stepsToEgg = (dayCare.stepsToEgg - 1) & 0xff;
if (dayCare.stepsToEgg) return;
dayCare.stepsToEgg = random();
const compatibility = breedingCompatibility(
data,
dayCare.mon1!,
dayCare.mon2!,
);
if (random() >= eggChance(compatibility)) return;
dayCare.compatible = false;
host.flags.add(ENGINE_FLAGS.dayCareManHasEgg);
}
/** GetBreedMon1LevelGrowth: the stored level, the level the experience now
* gives (CalcLevel) and the difference (a byte). */
export function breedLevelGrowth(data: PokemonData, mon: BoxMon) {
const level = levelForExp(data, mon.species, mon.exp);
return { oldLevel: mon.level, level, growth: (level - mon.level) & 0xff };
}
/** GetPriceToRetrieveBreedmon: ¥100, and ¥100 for every level gained. */
export const retrievalPrice = (growth: number) => (growth * 100 + 100) & 0xffff;
/** DepositBreedmon: the box_struct, nickname and OT of a party Pokémon. */
export const depositedMon = (mon: PartyMon): BoxMon => toBoxMon(mon);
/** FillMoves with wSkipMovesBeforeLevelUp set: the level-up moves above
* `previous` up to `level`, each shifting out the first move when all four
* slots are full. wSkipMovesBeforeLevelUp is the byte wEvolutionOldSpecies
* too, which RetrieveBreedmon leaves at TRUE: so FillMoves shifts the PP
* bytes with the moves and gives a learned move its base PP, as it does
* during an evolution. */
function learnMovesBetween(
data: PokemonData,
moves: Moves,
pp: BoxMon["pp"],
species: number,
previous: number,
level: number,
) {
const learned: number[] = [...moves],
points: number[] = [...pp];
for (const entry of data.learnset(species)) {
if (entry.level > level) break;
if (entry.level <= previous || learned.includes(entry.move)) continue;
let slot = learned.indexOf(0);
if (slot < 0) {
learned.shift();
points.shift();
learned.push(0);
points.push(0);
slot = 3;
}
learned[slot] = entry.move;
points[slot] = data.move(entry.move).pp;
}
return { moves: learned as Moves, pp: points as BoxMon["pp"] };
}
/** RetrieveBreedmon: the Pokémon rejoins the party at its new level with
* stats from its stat experience, the moves it learned meanwhile, full HP
* and PP (HealPartyMon), and its experience set to the new level's
* minimum: the original loses the experience beyond it ("Pokémon
* deposited in the Day-Care might lose experience"). */
export function retrievedMon(
data: PokemonData,
stored: BoxMon,
previousLevel: number,
level: number,
): PartyMon {
const mon = structuredClone(stored);
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
const learned = learnMovesBetween(
data,
mon.moves,
mon.pp,
mon.species,
previousLevel,
level,
);
return {
...mon,
moves: learned.moves,
// RestoreAllPP: full PP, keeping each move's PP Up bits.
pp: learned.moves.map((move, slot) =>
move ? maxPP(data, move, learned.pp[slot] >> 6) : learned.pp[slot],
) as BoxMon["pp"],
exp: data.expAtLevel(data.baseData(mon.species).growthRate, level),
level,
status: 0,
stats,
hp: stats[0],
};
}
/** DayCare_GiveEgg: wEggMon joins the party as an Egg: stats without stat
* experience and no HP. */
export function eggAsPartyMon(data: PokemonData, egg: BoxMon): PartyMon {
return {
...structuredClone(egg),
egg: true,
status: 0,
stats: calcStats(data, egg.species, egg.dvs, egg.level),
hp: 0,
};
}
/** NICKNAMED_MON_STRUCT_LENGTH: party_struct and a nickname. */
const NICKNAMED_MON_STRUCT_LENGTH = 48 + 11;
/** A party_struct from the ROM (data/events/odd_eggs.asm): big-endian
* words, as the structs in WRAM. */
function partyMonAt(data: PokemonData, p: number): PartyMon {
const r = data.reader,
word = (at: number) => (r.byte(at) << 8) | r.byte(at + 1);
const moves = [0, 1, 2, 3].map((i) => r.byte(p + 2 + i)) as Moves;
return {
species: r.byte(p),
item: r.byte(p + 1),
moves,
otId: word(p + 6),
ot: "",
nickname: readString(r, p + 48, defaultTextContext("")),
exp: (r.byte(p + 8) << 16) | word(p + 9),
statExp: [0, 1, 2, 3, 4].map((i) =>
word(p + 11 + i * 2),
) as PartyMon["statExp"],
dvs: word(p + 21),
pp: [0, 1, 2, 3].map((i) => r.byte(p + 23 + i)) as BoxMon["pp"],
happiness: r.byte(p + 27),
pokerus: r.byte(p + 28),
caughtData: word(p + 29),
level: r.byte(p + 31),
status: r.byte(p + 32),
hp: word(p + 34),
stats: [0, 1, 2, 3, 4, 5].map((i) =>
word(p + 36 + i * 2),
) as PartyMon["stats"],
};
}
/** _GiveOddEgg: one Random call gives hRandomSub and hRandomAdd; the first
* OddEggProbabilities entry at or above that word picks the OddEggs row.
* The Egg's OT is "ODD" and its name EGG, as AddMobileMonToParty copies
* them; the row is a ready-made party_struct (OT IDs of 1 to 16, the
* `dw` values' bytes swapped by the big-endian reading). */
export function oddEgg(data: PokemonData, random: () => number): PartyMon {
const r = data.reader;
const sub = random(),
add = random();
let index = 0;
for (; ; index++) {
const entry = JOHTO.OddEggProbabilities + index * 2;
const low = r.byte(entry),
high = r.byte(entry + 1);
if (high === 0xff && low === 0xff) break;
if (sub < high) break;
if (sub === high && add <= low) break;
}
const mon = partyMonAt(
data,
JOHTO.OddEggs + index * NICKNAMED_MON_STRUCT_LENGTH,
);
mon.ot = readString(r, JOHTO["_GiveOddEgg.Odd"], defaultTextContext(""));
mon.egg = true;
return mon;
}
/** The structure of the saved Day-Care, checked against the flags that
* mirror it. */
export function validDayCare(
data: PokemonData,
value: unknown,
flags: ReadonlySet<number>,
): value is DayCareState {
const dayCare = value as DayCareState;
const integer = (value: unknown, min: number, max: number) =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
try {
const mon = (stored: unknown, present: boolean, egg: boolean) =>
present
? validBoxFields(stored) &&
Object.keys(stored as object).length === (egg ? 15 : 14) &&
(egg ? (stored as BoxMon).egg === true : !(stored as BoxMon).egg) &&
data.baseData((stored as BoxMon).species) !== undefined
: stored === null;
return (
Boolean(dayCare) &&
typeof dayCare === "object" &&
typeof dayCare.manActive === "boolean" &&
typeof dayCare.ladyActive === "boolean" &&
typeof dayCare.compatible === "boolean" &&
integer(dayCare.stepsToEgg, 0, 255) &&
integer(dayCare.motherOrNonDitto, 0, 1) &&
mon(dayCare.mon1, flags.has(ENGINE_FLAGS.dayCareManHasMon), false) &&
mon(dayCare.mon2, flags.has(ENGINE_FLAGS.dayCareLadyHasMon), false) &&
// An egg waiting needs the egg itself; nothing is compatible without
// both Pokémon.
mon(dayCare.egg, dayCare.egg !== null, true) &&
(dayCare.egg !== null || !flags.has(ENGINE_FLAGS.dayCareManHasEgg)) &&
(!dayCare.compatible ||
(dayCare.mon1 !== null &&
dayCare.mon2 !== null &&
dayCare.egg !== null &&
!flags.has(ENGINE_FLAGS.dayCareManHasEgg)))
);
} catch {
return false;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { PartyMon } from "./pokemon";
/** The happiness a Pokémon has just after hatching (HatchEggs: `ld [hl], $78`),
* which replaces the Egg's remaining cycles. */
export const HATCHED_HAPPINESS = 0x78;
/** The counters CountStep keeps in work RAM (wStepCount,
* wPoisonStepCount) and StepHappiness's toggle (wHappinessStepCount). */
export interface StepCounters {
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
}
/** StepHappiness (engine/events/happiness_egg.asm): called each time
* wStepCount wraps (every 256 steps), it raises the happiness of every
* Pokémon that is not an Egg by one on every other call, so once per 512
* steps, up to 255. It does not go through ChangeHappiness: no bonus and
* no cap other than the byte's, and a Pokémon already at 255 stays there. */
export function stepHappiness(
counters: Pick<StepCounters, "happinessStepCount">,
party: PartyMon[],
) {
counters.happinessStepCount = (counters.happinessStepCount + 1) & 1;
if (counters.happinessStepCount) return;
for (const mon of party)
if (!mon.egg) mon.happiness = Math.min(0xff, mon.happiness + 1);
}
/** DoEggStep (engine/pokemon/breeding.asm): from the first slot, each Egg's
* cycles (kept in its happiness byte) drop by one until one reaches zero,
* which ends the loop and reports that an Egg is ready to hatch; the Eggs
* after it are not counted down on that step. A byte already at zero
* wraps to 255 (`dec [hl]` has no floor) and does not hatch. */
export function doEggStep(party: PartyMon[]): boolean {
for (const mon of party) {
if (!mon.egg) continue;
mon.happiness = (mon.happiness - 1) & 0xff;
if (!mon.happiness) return true;
}
return false;
}
+108
View File
@@ -0,0 +1,108 @@
import { NUM_POKEMON } from "../rom/profile";
import { NICKNAME_PATTERN, PARTY_LENGTH, type PartyMon } from "./pokemon";
/** NUM_HOF_TEAMS: the teams sHallOfFame keeps; HOF_MASTER_COUNT: where
* wHallOfFameCount stops (constants/misc_constants.asm). */
export const NUM_HOF_TEAMS = 30;
export const HOF_MASTER_COUNT = 200;
/** HOF_MON_LENGTH: only the fields GetHallOfFameParty copies. */
export interface HallOfFameMon {
species: number;
otId: number;
dvs: number;
level: number;
nickname: string;
}
/** One sHallOfFame entry (HOF_LENGTH): the win it records and the team
* that won, without its Eggs. */
export interface HallOfFameTeam {
/** wHallOfFameCount when the team entered, capped at HOF_MASTER_COUNT. */
win: number;
mons: HallOfFameMon[];
}
export interface HallOfFameRecords {
/** wHallOfFameCount: the League wins, up to HOF_MASTER_COUNT. */
hallOfFameCount: number;
/** sHallOfFame, newest first. Separate from STATUSFLAGS_HALL_OF_FAME_F. */
hallOfFameTeams: HallOfFameTeam[];
}
/** GetHallOfFameParty: skip Eggs, copy the six recorded fields, and keep
* party order. An all-Egg party produces an empty team. */
export function getHallOfFameParty(
party: readonly PartyMon[],
win: number,
): HallOfFameTeam {
return {
win,
mons: party
.filter((mon) => !mon.egg)
.map(({ species, otId, dvs, level, nickname }) => ({
species,
otId,
dvs,
level,
nickname,
})),
};
}
/** HallOfFame's capped count increment, then AddHallOfFameEntry: the
* newest team goes first and the oldest of a full sHallOfFame drops off.
* The League's ceremony is not ported, so nothing calls this yet; it
* leaves STATUSFLAGS_HALL_OF_FAME_F (`hallOfFame`) to its caller. */
export function addHallOfFameEntry(
records: HallOfFameRecords,
party: readonly PartyMon[],
) {
records.hallOfFameCount = Math.min(
HOF_MASTER_COUNT,
records.hallOfFameCount + 1,
);
records.hallOfFameTeams.unshift(
getHallOfFameParty(party, records.hallOfFameCount),
);
records.hallOfFameTeams.length = Math.min(
NUM_HOF_TEAMS,
records.hallOfFameTeams.length,
);
}
/** Structural records validation; no stats, party ownership or current
* species are required of a historical entry. */
export function validHallOfFame(
count: unknown,
value: unknown,
): value is HallOfFameTeam[] {
const integer = (n: unknown, min: number, max: number): n is number =>
Number.isInteger(n) && Number(n) >= min && Number(n) <= max;
if (
!integer(count, 0, HOF_MASTER_COUNT) ||
!Array.isArray(value) ||
value.length > NUM_HOF_TEAMS
)
return false;
if ((count === 0) !== (value.length === 0) || value.length > count)
return false;
return value.every(
(team, index) =>
team !== null &&
typeof team === "object" &&
integer(team.win, 1, count) &&
(index === 0 ? team.win === count : team.win <= value[index - 1].win) &&
Array.isArray(team.mons) &&
team.mons.length <= PARTY_LENGTH &&
team.mons.every(
(mon: HallOfFameMon) =>
mon !== null &&
typeof mon === "object" &&
integer(mon.species, 1, NUM_POKEMON) &&
integer(mon.otId, 0, 65535) &&
integer(mon.dvs, 0, 65535) &&
integer(mon.level, 1, 100) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname),
),
);
}
+4 -3
View File
@@ -22,11 +22,12 @@ export function drawTileScreen(
// background's own color index to clip against; a screen with none // background's own color index to clip against; a screen with none
// pays nothing beyond the allocation. // pays nothing beyond the allocation.
const indices = new Uint8Array(width * height); const indices = new Uint8Array(width * height);
const scrollX = screen.scroll?.x ?? 0;
const scrollY = screen.scroll?.y ?? 0; const scrollY = screen.scroll?.y ?? 0;
for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++) for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++)
for (let col = 0; col < SCREEN_WIDTH; col++) { for (let col = scrollX < 0 ? -1 : 0; col < SCREEN_WIDTH + (scrollX > 0 ? 1 : 0); col++) {
const cell = row * SCREEN_WIDTH + col; const cell = row * SCREEN_WIDTH + col;
const inside = row >= 0 && row < SCREEN_HEIGHT; const inside = row >= 0 && row < SCREEN_HEIGHT && col >= 0 && col < SCREEN_WIDTH;
const tile = inside ? screen.map[cell] : screen.scroll!.fill; const tile = inside ? screen.map[cell] : screen.scroll!.fill;
if (tile < 0) continue; if (tile < 0) continue;
drawTile( drawTile(
@@ -35,7 +36,7 @@ export function drawTileScreen(
height, height,
screen.tiles, screen.tiles,
tile, tile,
col * 8, col * 8 - scrollX,
row * 8 - scrollY, row * 8 - scrollY,
(inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0], (inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0],
{ indices }, { indices },
+16 -1
View File
@@ -5,6 +5,7 @@ import {
MAP_GROUP_COUNT, MAP_GROUP_COUNT,
NATIVE_MAPS, NATIVE_MAPS,
NUM_ENGINE_FLAGS, NUM_ENGINE_FLAGS,
NUM_POKEMON,
NUM_SPRITEMOVEDATA, NUM_SPRITEMOVEDATA,
OVERWORLD, OVERWORLD,
PHONE, PHONE,
@@ -24,6 +25,10 @@ import type {
FacingTile, FacingTile,
} from "../world/types"; } from "../world/types";
/** Sprite ids above the one-byte range ask for a Pokémon's overworld icon by
* species (MON_SPRITE_ID + species). */
export const MON_SPRITE_ID = 0x100;
/** The only layer that knows where Crystal stores its data. Everything is /** The only layer that knows where Crystal stores its data. Everything is
* decoded from the supplied ROM, on demand, and cached only in memory. */ * decoded from the supplied ROM, on demand, and cached only in memory. */
export class CrystalAssets { export class CrystalAssets {
@@ -566,11 +571,21 @@ export class CrystalAssets {
} }
/** GetSprite: overworld sprites, and Pokémon drawn from their party /** GetSprite: overworld sprites, and Pokémon drawn from their party
* menu icons (GetMonSprite). Variable sprites are resolved by the world * menu icons (GetMonSprite). Variable sprites are resolved by the world
* from wVariableSprites first; day-care Pokémon are not ported. */ * from wVariableSprites first. A Day-Care Pokémon (SPRITE_DAY_CARE_MON_1
* and 2, which GetMonSprite resolves to wBreedMon1/2's species) is
* asked for as MON_SPRITE_ID + its species, as LoadOverworldMonIcon
* draws it. */
sprite(id: number): Sprite | undefined { sprite(id: number): Sprite | undefined {
const cached = this.sprites.get(id); const cached = this.sprites.get(id);
if (cached) return cached; if (cached) return cached;
const r = this.reader; const r = this.reader;
if (id > MON_SPRITE_ID) {
const species = id - MON_SPRITE_ID;
if (species > NUM_POKEMON) return undefined;
const sprite = { graphics: this.monIcon(species), palette: 0, walking: true };
this.sprites.set(id, sprite);
return sprite;
}
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) { if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
// LoadOverworldMonIcon: eight tiles, two frames in the down and up // LoadOverworldMonIcon: eight tiles, two frames in the down and up
// standing slots, with palette 0. // standing slots, with palette 0.
+2
View File
@@ -43,6 +43,8 @@ export function fontTiles(reader: RomReader, frame: number) {
* (constants/charmap.asm). */ * (constants/charmap.asm). */
const CODES: Record<string, number> = { const CODES: Record<string, number> = {
" ": 0x7f, " ": 0x7f,
"“": 0x72,
"”": 0x73,
"(": 0x9a, "(": 0x9a,
")": 0x9b, ")": 0x9b,
":": 0x9c, ":": 0x9c,
+26 -2
View File
@@ -359,6 +359,30 @@ export class PokemonData {
return evolutions; return evolutions;
} }
/** GetPreEvolution: the first species (in dex order) with an evolution
* into this one, or the species itself. */
preEvolution(species: number): number {
this.checkSpecies(species);
for (let from = 1; from <= NUM_POKEMON; from++)
if (this.evolutions(from).some((evolution) => evolution.species === species))
return from;
return species;
}
/** EggMovePointers' list for a species (data/pokemon/egg_moves.asm), up
* to its -1 terminator; the shared NoEggMoves list is empty. */
eggMoves(species: number): number[] {
this.checkSpecies(species);
const r = this.reader,
table = JOHTO.EggMovePointers + (species - 1) * 2;
const moves: number[] = [];
for (let p = r.near(table, r.bank(table)); r.byte(p) !== 0xff; p++) {
if (moves.length > 16) throw new Error("Invalid egg moves.");
moves.push(r.byte(p));
}
return moves;
}
/** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the /** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the
* evolution list is skipped up to its terminating zero byte. */ * evolution list is skipped up to its terminating zero byte. */
learnset(species: number): { level: number; move: number }[] { learnset(species: number): { level: number; move: number }[] {
@@ -439,8 +463,8 @@ export class PokemonData {
padded.set(tiles.slice(column * 80, (column + 1) * 80), ((column + 1) * 7 + 2) * 16); padded.set(tiles.slice(column * 80, (column + 1) * 80), ((column + 1) * 7 + 2) * 16);
return padded; return padded;
} }
eggPalette(): Palette { eggPalette(shiny = false): Palette {
return this.middleColors(SYMBOLS.pokemonPalettes + 0xfd * 8); return this.middleColors(SYMBOLS.pokemonPalettes + 0xfd * 8 + (shiny ? 4 : 0));
} }
/** Front picture tiles, padded into the 7x7 box as PadFrontpic does: /** Front picture tiles, padded into the 7x7 box as PadFrontpic does:
* column-major, bottom-aligned, with a blank left column for 5 and 6. */ * column-major, bottom-aligned, with a blank left column for 5 and 6. */
+88
View File
@@ -202,6 +202,8 @@ export const EVENTS = {
bugCatchingContestant8A: 1821, bugCatchingContestant8A: 1821,
bugCatchingContestant9A: 1822, bugCatchingContestant9A: 1822,
bugCatchingContestant10A: 1823, bugCatchingContestant10A: 1823,
/** HatchEggs sets this when a Togepi hatches. */
togepiHatched: 84,
} as const; } as const;
/** Engine flag indices named directly by translated routines. */ /** Engine flag indices named directly by translated routines. */
@@ -209,10 +211,14 @@ export const ENGINE_FLAGS = {
pokegear: 4, pokegear: 4,
pokedex: 11, pokedex: 11,
allFruitTrees: 84, allFruitTrees: 84,
bikeShopCall: 20,
/** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */ /** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */
bugContestTimer: 17, bugContestTimer: 17,
rocketsInRadioTower: 19, rocketsInRadioTower: 19,
rocketsInMahogany: 23, rocketsInMahogany: 23,
dayCareManHasEgg: 5,
dayCareManHasMon: 6,
dayCareLadyHasMon: 7,
expnCard: 3, expnCard: 3,
rocketSignal: 14, rocketSignal: 14,
fishSwarm: 82, fishSwarm: 82,
@@ -1211,9 +1217,23 @@ export const AUDIO = {
PokemonCries: 0xf2787, PokemonCries: 0xf2787,
} as const; } as const;
/** Egg hatching (engine/pokemon/breeding.asm and the sprite animations it
* uses), keyed by PRET labels for `npm run check:reference`. */
export const EGGS = {
Text_BreedHuh: 0x1c0db0,
_BreedClearboxText: 0x1c0db8,
_BreedEggHatchText: 0x1c0dba,
_BreedAskNicknameText: 0x1c0dd8,
EggHatchGFX: 0x17393,
"Hatch_InitShellFragments.SpriteData": 0x173ef,
BattleObjectPals: 0x979c,
} as const;
/** Johto's scripts, routines and tables from Route 32 on, keyed by their /** Johto's scripts, routines and tables from Route 32 on, keyed by their
* PRET labels for `npm run check:reference`. */ * PRET labels for `npm run check:reference`. */
export const JOHTO = { export const JOHTO = {
"_HallOfFamePC.TimeFamer": 0x8670c,
FontBattleExtra: 0xf8600,
CheckMagikarpLength: 0xfbb32, CheckMagikarpLength: 0xfbb32,
MagikarpHouseSign: 0xfbcd2, MagikarpHouseSign: 0xfbcd2,
FindPartyMonThatSpecies: 0xc276, FindPartyMonThatSpecies: 0xc276,
@@ -1270,6 +1290,30 @@ export const JOHTO = {
"PokeSeer": 0x4f0bc, "PokeSeer": 0x4f0bc,
"PhotoStudio": 0x16dc7, "PhotoStudio": 0x16dc7,
"PlaySlowCry": 0xfb841, "PlaySlowCry": 0xfb841,
DayCareMan: 0x166d6,
DayCareLady: 0x1672a,
DayCareManOutside: 0x16936,
DayCareMon1: 0x17421,
DayCareMon2: 0x17440,
GiveOddEgg: 0x117656,
"PrintDayCareText.TextTable": 0x168aa,
"DayCareManOutside.NotYetText": 0x16944,
"DayCareManOutside.FoundAnEggText": 0x16993,
"DayCareManOutside.ReceivedEggText": 0x16998,
"DayCareManOutside.TakeGoodCareOfEggText": 0x1699d,
"DayCareManOutside.IllKeepItThanksText": 0x169a2,
"DayCareManOutside.NoRoomForEggText": 0x169a7,
LeftWithDayCareLadyText: 0x17462,
LeftWithDayCareManText: 0x17467,
"DayCareMonCompatibilityText.BreedBrimmingWithEnergyText": 0x1749c,
"DayCareMonCompatibilityText.BreedNoInterestText": 0x174a1,
"DayCareMonCompatibilityText.BreedAppearsToCareForText": 0x174a6,
"DayCareMonCompatibilityText.BreedFriendlyText": 0x174ab,
"DayCareMonCompatibilityText.BreedShowsInterestText": 0x174b0,
EggMovePointers: 0x23b11,
OddEggProbabilities: 0x1fb552,
OddEggs: 0x1fb56e,
"_GiveOddEgg.Odd": 0x1fb546,
"SpecialShuckleOT": 0x7370, "SpecialShuckleOT": 0x7370,
"SpecialShuckleNickname": 0x7376, "SpecialShuckleNickname": 0x7376,
"SeerSeeAllText": 0x4f28c, "SeerSeeAllText": 0x4f28c,
@@ -1518,6 +1562,8 @@ export const WRAM = {
wMagikarpRecordHoldersName: 0xdfea, wMagikarpRecordHoldersName: 0xdfea,
wcd49: 0xcd49, wcd49: 0xcd49,
wNrOfBeatenBattleTowerTrainers: 0xcf64, wNrOfBeatenBattleTowerTrainers: 0xcf64,
wBreedMon1Nickname: 0xdef6,
wBreedMon2Nickname: 0xdf2f,
} as const; } as const;
export const MAP_GROUP_COUNT = 26; export const MAP_GROUP_COUNT = 26;
@@ -1743,3 +1789,45 @@ export const NATIVE_MAPS: Record<string, string> = {
"3:16": "National Park (Bug-Catching Contest)", "3:16": "National Park (Bug-Catching Contest)",
}; };
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const; export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
/** ReadAnyMail stationery graphics and palettes. */
export const MAIL = {
"MorphMailDividerGFX": 0xb9926,
"BlueSkyMailGrassGFX": 0xb992e,
"PortraitMailSmallPokeballGFX": 0xb9936,
"MorphMailBorderGFX": 0xb993e,
"MusicMailSmallNoteGFX": 0xb9946,
"SurfMailWaveGFX": 0xb994e,
"PortraitMailUnderlineGFX": 0xb995e,
"LovelyMailUnderlineGFX": 0xb9966,
"LovelyMailSmallHeartGFX": 0xb996e,
"SurfLiteBlueMailSmallShapesGFX": 0xb9976,
"EonMailBorder1GFX": 0xb997e,
"EonMailBorder2GFX": 0xb998e,
"MailNatuGFX": 0xb9996,
"MailDratiniGFX": 0xb99c6,
"MailPoliwagGFX": 0xb99f6,
"MailLaprasGFX": 0xb9a26,
"MailEeveeGFX": 0xb9a56,
"MailDittoGFX": 0xb9a86,
"MailMewGFX": 0xb9ab6,
"MailDragoniteAndSentretGFX": 0xb9b46,
"PortraitMailLargePokeballGFX": 0xb9bfe,
"MailOddishGFX": 0xb9c1e,
"SurfLiteBlueMailLargeShapesGFX": 0xb9c3e,
"LovelyMailLargeHeartGFX": 0xb9c5e,
"MorphMailBorderCornerGFX": 0xb9c7e,
"MailLargeCircleGFX": 0xb9c9e,
"FlowerMailFlowerGFX": 0xb9cbe,
"MusicMailLargeNoteGFX": 0xb9cfe,
"MailCloudGFX": 0xb9d16,
"SurfMailBorderGFX": 0xb9d46,
"FlowerMailBorderGFX": 0xb9d86,
"LiteBlueMailBorderGFX": 0xb9dc6,
"MusicMailBorderGFX": 0xb9e06,
"LovelyMailBorderGFX": 0xb9e26,
"PortraitMailBorderGFX": 0xb9e4e,
"MailGFXPointers": 0xb92f8,
"LoadMailPalettes.MailPals": 0x8d05,
"StandardEnglishFont": 0x1de5e6,
} as const;
+2 -3
View File
@@ -1,3 +1,4 @@
import { printerDisconnected } from "./printer";
import { JOHTO, WRAM } from "../rom/profile"; import { JOHTO, WRAM } from "../rom/profile";
import { generatePartyMon } from "../party/pokemon"; import { generatePartyMon } from "../party/pokemon";
import { readString } from "./text"; import { readString } from "./text";
@@ -93,9 +94,7 @@ export function* photoStudio(c: SpecialContext): Gen {
if (slot >= 0) { if (slot >= 0) {
c.write(JOHTO["PhotoStudio.HoldStillText"]); c.write(JOHTO["PhotoStudio.HoldStillText"]);
yield* c.flush(); yield* c.flush();
const status = readString(c.reader, JOHTO.GBPrinterString_PrinterError2, c.text(), 64); yield* printerDisconnected(c.reader, c.state.options.textboxFrame);
const cancel = readString(c.reader, JOHTO.String_PressBToCancel, c.text());
yield { kind: "text", pages: [`${status}\n\n${cancel}`] };
} }
c.write(JOHTO["PhotoStudio.NoPhotoText"]); c.write(JOHTO["PhotoStudio.NoPhotoText"]);
} }
+272
View File
@@ -0,0 +1,272 @@
import { JOHTO, RAM, WRAM, ENGINE_FLAGS } from "../rom/profile";
import { SOUND } from "../audio/constants";
import { tossItem } from "../party/bag";
import {
breedLevelGrowth,
breedingCompatibility,
depositedMon,
eggAsPartyMon,
initBreeding,
oddEgg,
retrievalPrice,
retrievedMon,
type DayCareState,
} from "../party/day-care";
import { PARTY_LENGTH } from "../party/pokemon";
import type { Gen, SpecialContext } from "./special-context";
/** PrintDayCareText.TextTable's indexes (DAYCARETEXT_*). */
const TEXT = {
manIntro: 0,
ladyIntro: 2,
whichOne: 4,
deposit: 5,
cantBreedEgg: 6,
lastMon: 7,
lastAliveMon: 8,
comeBackLater: 9,
removeMail: 10,
geniuses: 11,
askWithdraw: 12,
withdraw: 13,
gotBack: 14,
tooSoon: 15,
partyFull: 16,
notEnoughMoney: 17,
ohFine: 18,
comeAgain: 19,
} as const;
/** PARTYMENUACTION_GIVE_MON and EGG_TICKET. */
const PARTYMENUACTION_GIVE_MON = 6,
EGG_TICKET = 0x81;
/** PrintDayCareText: the text the table's index names. */
function say(c: SpecialContext, index: number) {
const table = JOHTO["PrintDayCareText.TextTable"];
c.write(c.reader.near(table + index * 2, c.reader.bank(table)));
}
/** The pair of Day-Care keepers differ in which Pokémon, flag and intro
* they use. */
const KEEPERS = {
man: {
hasMon: ENGINE_FLAGS.dayCareManHasMon,
mon: "mon1",
nickname: WRAM.wBreedMon1Nickname,
},
lady: {
hasMon: ENGINE_FLAGS.dayCareLadyHasMon,
mon: "mon2",
nickname: WRAM.wBreedMon2Nickname,
},
} as const;
/** DayCareAskDepositPokemon: the slot to leave, or the text to print. */
function* askDeposit(
c: SpecialContext,
): Gen<{ slot: number } | { text: number }> {
const party = c.state.party;
if (party.length < 2) return { text: TEXT.lastMon };
say(c, TEXT.whichOne);
const slot = yield* c.selectMon(PARTYMENUACTION_GIVE_MON);
if (slot < 0) return { text: TEXT.ohFine };
const mon = party[slot];
if (mon.egg) return { text: TEXT.cantBreedEgg };
// CheckCurPartyMonFainted: any other member with HP will do (an Egg has
// none).
if (!party.some((other, index) => index !== slot && other.hp > 0))
return { text: TEXT.lastAliveMon };
if (c.data.itemIsMail(mon.item)) return { text: TEXT.removeMail };
c.strings[RAM.stringBuffer1] = mon.nickname;
return { slot };
}
/** DayCareMan and DayCareLady: ask to raise a Pokémon, or to give it back
* for ¥100 and ¥100 a level. */
function* keeper(c: SpecialContext, who: keyof typeof KEEPERS): Gen {
const { state, data } = c,
dayCare = state.dayCare,
info = KEEPERS[who];
if (!state.flags.has(info.hasMon)) {
// DayCareManIntroText sets its active bit first; DayCareLadyIntroText
// takes the longer Egg introduction once, and sets hers.
let text: number;
if (who === "man") {
dayCare.manActive = true;
text = TEXT.manIntro;
} else {
text = TEXT.ladyIntro + Number(!dayCare.ladyActive);
dayCare.ladyActive = true;
}
say(c, text);
if (!(yield { kind: "yesno", pages: c.take() }))
return say(c, TEXT.comeAgain);
const choice = yield* askDeposit(c);
if ("text" in choice) {
say(c, choice.text);
return say(c, TEXT.comeAgain);
}
// DepositMonWithDayCareMan/Lady, then the text and cry.
const [mon] = state.party.splice(choice.slot, 1);
dayCare[info.mon] = depositedMon(mon);
state.flags.add(info.hasMon);
say(c, TEXT.deposit);
yield* c.cry(mon.species);
say(c, TEXT.comeBackLater);
initBreeding(data, state, c.player(), c.random);
return;
}
// GetBreedMon1LevelGrowth and GetPriceToRetrieveBreedmon: the price and
// the nickname go to wStringBuffer2 and 1.
const stored = dayCare[info.mon]!;
const { oldLevel, level, growth } = breedLevelGrowth(data, stored);
const price = retrievalPrice(growth);
c.strings[RAM.stringBuffer1] = stored.nickname;
c.numbers[RAM.stringBuffer2 + 1] = growth;
c.numbers[RAM.stringBuffer2 + 2] = price;
// DayCare_AskWithdrawBreedMon.
let refusal: number | undefined;
if (growth) {
say(c, TEXT.geniuses);
if (!(yield { kind: "yesno", pages: c.take() })) refusal = TEXT.ohFine;
else {
say(c, TEXT.askWithdraw);
if (!(yield { kind: "yesno", pages: c.take() })) refusal = TEXT.ohFine;
}
} else {
say(c, TEXT.tooSoon);
if (!(yield { kind: "yesno", pages: c.take() })) refusal = TEXT.ohFine;
}
if (refusal === undefined) {
if (state.money < price) refusal = TEXT.notEnoughMoney;
else if (state.party.length >= PARTY_LENGTH) refusal = TEXT.partyFull;
}
if (refusal !== undefined) {
say(c, refusal);
return say(c, TEXT.comeAgain);
}
// RetrieveMonFromDayCareMan/Lady: SFX_TRANSACTION, then the Pokémon.
c.playSFX(SOUND.SFX_TRANSACTION);
yield* c.waitSFX();
state.party.push(retrievedMon(data, stored, oldLevel, level));
// DayCare_GetBackMonForMoney.
state.money -= price;
say(c, TEXT.withdraw);
yield* c.cry(stored.species);
say(c, TEXT.gotBack);
dayCare[info.mon] = null;
state.flags.delete(info.hasMon);
dayCare.compatible = false;
say(c, TEXT.comeAgain);
}
export const dayCareMan = (c: SpecialContext) => keeper(c, "man");
export const dayCareLady = (c: SpecialContext) => keeper(c, "lady");
/** DayCareManOutside (the man on Route 34): the Egg he found, which the
* player may take if the party has room. Returns TRUE when it was refused
* for want of room; FALSE otherwise, even if the player said no (the
* script then clears ENGINE_DAY_CARE_MAN_HAS_EGG all the same). Without an
* Egg he leaves wScriptVar alone. */
export function* dayCareManOutside(
c: SpecialContext,
value: number,
): Gen<number> {
const { state, data } = c,
dayCare = state.dayCare;
const text = (name: string) =>
c.write(
JOHTO[`DayCareManOutside.${name}` as "DayCareManOutside.NotYetText"],
);
if (!state.flags.has(ENGINE_FLAGS.dayCareManHasEgg)) {
text("NotYetText");
return value;
}
text("FoundAnEggText");
if (!(yield { kind: "yesno", pages: c.take() })) {
text("IllKeepItThanksText");
return 0;
}
if (state.party.length >= PARTY_LENGTH) {
text("NoRoomForEggText");
return 1;
}
// DayCare_GiveEgg, then the next egg is prepared for the pair.
if (!dayCare.egg) throw new Error("The Day-Care has no Egg to give.");
state.party.push(eggAsPartyMon(data, dayCare.egg));
state.flags.delete(ENGINE_FLAGS.dayCareManHasEgg);
initBreeding(data, state, c.player(), c.random);
text("ReceivedEggText");
// SFX_GET_EGG and DelayFrames 120 sound while the text is shown.
c.playSFX(SOUND.SFX_GET_EGG);
text("TakeGoodCareOfEggText");
return 0;
}
/** DayCareMon1 and DayCareMon2 (the Pokémon shown in Route 34's fence):
* whose it is, its cry, and, if the other is in too, how they get on
* (DayCareMonCompatibilityText, from CheckBreedmonCompatibility). */
function* fenceMon(
c: SpecialContext,
which: 1 | 2,
value: number,
): Gen<number> {
const dayCare: DayCareState = c.state.dayCare;
const mon = which === 1 ? dayCare.mon1 : dayCare.mon2,
other = which === 1 ? dayCare.mon2 : dayCare.mon1;
if (!mon) return value;
// The text names both nicknames: its own through text_ram, the other's
// is copied to wStringBuffer1.
c.strings[which === 1 ? WRAM.wBreedMon1Nickname : WRAM.wBreedMon2Nickname] =
mon.nickname;
c.write(
which === 1 ? JOHTO.LeftWithDayCareManText : JOHTO.LeftWithDayCareLadyText,
);
yield* c.cry(mon.species);
const hasOther =
c.state.flags.has(
which === 1
? ENGINE_FLAGS.dayCareLadyHasMon
: ENGINE_FLAGS.dayCareManHasMon,
) && other;
// DayCareMonCursor: with no other Pokémon, a button ends the text.
if (!hasOther) {
yield* c.flush();
return value;
}
c.strings[RAM.stringBuffer1] = other.nickname;
const compatibility = breedingCompatibility(
c.data,
dayCare.mon1!,
dayCare.mon2!,
);
const group = "DayCareMonCompatibilityText.";
c.write(
compatibility === 255
? JOHTO[`${group}BreedBrimmingWithEnergyText`]
: compatibility === 0
? JOHTO[`${group}BreedNoInterestText`]
: compatibility >= 230
? JOHTO[`${group}BreedAppearsToCareForText`]
: compatibility >= 70
? JOHTO[`${group}BreedFriendlyText`]
: JOHTO[`${group}BreedShowsInterestText`],
);
return value;
}
export const dayCareMon1 = (c: SpecialContext, value: number) =>
fenceMon(c, 1, value);
export const dayCareMon2 = (c: SpecialContext, value: number) =>
fenceMon(c, 2, value);
/** GiveOddEgg: the Egg Ticket is used up if the player has one, and one of
* the Odd Eggs joins the party. The script checks for room first. */
export function giveOddEgg(c: SpecialContext) {
const { state, data } = c;
const egg = oddEgg(data, c.random);
tossItem(state.bag, EGG_TICKET, 1);
if (state.party.length >= PARTY_LENGTH)
throw new Error("The party has no room for the Odd Egg.");
state.party.push(egg);
}
+113
View File
@@ -0,0 +1,113 @@
import type { HallOfFameTeam } from "../party/hall-of-fame";
import { PokemonData } from "../rom/pokemon";
import { JOHTO } from "../rom/profile";
import { fontTiles, FRAME_TILE, SPACE_TILE, textTiles } from "../rom/font";
import type { ScriptEffect } from "./interpreter";
import { blankScreen, SCREEN_BUTTONS, type TileScreen } from "./screen";
import { defaultTextContext, readString } from "./text";
import type { GameSound } from "../audio/sound";
/** _HallOfFamePC.DisplayMonAndStrings: DisplayHOFMon's page for one
* Pokémon of a team, over _LoadFontsBattleExtra's tiles, in
* SCGB_PLAYER_OR_MON_FRONTPIC_PALS's single palette. The picture is shown
* at rest: HOF_AnimateFrontpic's ANIM_MON_HOF animation is not ported. */
export function hallOfFameScreen(
data: PokemonData,
team: HallOfFameTeam,
slot: number,
frame: number,
): TileScreen {
const mon = team.mons[slot],
r = data.reader;
const tiles = fontTiles(r, frame);
tiles.set(r.slice(JOHTO.FontBattleExtra, 25 * 16), 0x60 * 16);
tiles.set(data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)));
const title = readString(
r,
JOHTO["_HallOfFamePC.TimeFamer"],
defaultTextContext(""),
);
const screen = blankScreen(
tiles,
`hof-${mon.species}-${mon.dvs}-${frame}`,
[data.palette(mon.species, PokemonData.shiny(mon.dvs))],
`${team.win}${title.trimStart()}: ${mon.nickname}, ${data.speciesName(mon.species)}, level ${mon.level}, ID ${String(mon.otId).padStart(5, "0")}`,
);
screen.map.fill(SPACE_TILE);
const put = (x: number, y: number, values: readonly number[]) =>
screen.map.splice(y * 20 + x, values.length, ...values);
const text = (x: number, y: number, value: string) =>
put(
x,
y,
[...value].flatMap((character) =>
character === '"' ? [0xe1] : textTiles(character),
),
);
// Textbox with 18 interior columns and three/four interior rows.
const box = (y: number, height: number) => {
put(0, y, [FRAME_TILE, ...Array(18).fill(FRAME_TILE + 1), FRAME_TILE + 2]);
for (let row = y + 1; row <= y + height; row++) {
put(0, row, [FRAME_TILE + 3]);
put(19, row, [FRAME_TILE + 3]);
}
put(0, y + height + 1, [
FRAME_TILE + 4,
...Array(18).fill(FRAME_TILE + 1),
FRAME_TILE + 5,
]);
};
box(0, 3);
box(12, 4);
// "HOF Master!" needs a count of HOF_MASTER_COUNT + 1, which the capped
// count never reaches: the original's bug (docs/bugs_and_glitches.md)
// leaves every team "-Time Famer".
text(1, 2, title);
text(2, 2, String(team.win).padStart(3, " "));
for (let x = 0; x < 7; x++)
for (let y = 0; y < 7; y++) put(x + 6, y + 5, [x * 7 + y]);
put(1, 13, [0x74, 0xf2]); // № and <DOT>.
text(3, 13, String(mon.species).padStart(3, "0"));
// GetBasePokemonName drops the Nidorans' gender sign.
text(7, 13, data.speciesName(mon.species).split(/[♂♀]/)[0]);
const gender = data.gender(mon.species, mon.dvs);
text(18, 13, gender === "male" ? "♂" : gender === "female" ? "♀" : " ");
text(8, 14, `/${mon.nickname}`);
// PrintLevel: <LV> and two digits, or three digits over the <LV>.
if (mon.level < 100) {
put(1, 16, [0x6e]);
text(2, 16, String(mon.level));
} else text(1, 16, String(mon.level));
put(7, 16, [0x73, 0x74, 0xf3]); // <ID>, № and /.
text(10, 16, String(mon.otId).padStart(5, "0"));
return screen;
}
/** _HallOfFamePC: LoadHOFTeam takes the teams newest first until an absent
* one (or NUM_HOF_TEAMS); A shows the team's next Pokémon, and after the
* last one the next team, Start skips to the next team and B leaves. Each
* page cries as ANIM_MON_HOF's CryNoWait does. */
export function* hallOfFamePC(
data: PokemonData,
teams: readonly HallOfFameTeam[],
frame: number,
sound?: GameSound,
): Generator<ScriptEffect, void, number | string | undefined> {
for (const team of teams) {
for (let slot = 0; slot < team.mons.length; slot++) {
sound?.playMonCry2(team.mons[slot].species);
const screen = hallOfFameScreen(data, team, slot, frame);
for (;;) {
const button = yield { kind: "screen", screen };
if (typeof button !== "string" || !SCREEN_BUTTONS.includes(button))
throw new Error("Invalid Hall of Fame button.");
if (button === "b") return;
if (button === "start") {
slot = team.mons.length;
break;
}
if (button === "a") break;
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import { PokemonData } from "../rom/pokemon";
import { EGGS, OVERWORLD, SYMBOLS } from "../rom/profile";
import type { Palette } from "../world/types";
import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants";
import {
blankScreen,
romPalette,
type TileScreen,
type ScreenSprite,
} from "./screen";
import type { ScriptEffect } from "./interpreter";
/** The hatching screen's VRAM layout, with sprites in a separate tile bank. */
const OBJ = 0x100;
/** SpriteAnimFunc_RevealNewMon. VAR1 grows by eight each animation frame;
* the angle flips by half a turn each time. DeinitializeSprite still draws
* the last frame once (PlaySpriteAnimations calls UpdateAnimFrame after it). */
export function shellSprites(data: PokemonData, frame: number): ScreenSprite[] {
if (frame > 16) return [];
const r = data.reader;
const sine = (angle: number, amplitude: number) => {
const index = angle & 63;
const value =
(amplitude *
r.word(OVERWORLD["_Sine.sinetable_u10"] + (index & 31) * 2)) >>
8;
return index >= 32 ? -value : value;
};
const sprites: ScreenSprite[] = [];
for (
let p = EGGS["Hatch_InitShellFragments.SpriteData"];
r.byte(p) !== 255;
p += 4
) {
const f = Math.min(frame, 15);
const angle = r.byte(p + 3) ^ (f % 2 === 0 ? 32 : 0);
const flip = r.byte(p + 2);
// _InitSpriteAnimStruct stores E as X and D as Y. The OAM table
// contributes -4, with flipped offsets becoming -8 - offset.
sprites.push({
x: ((r.byte(p) + sine(angle + 16, f * 8) - 4) & 255) - 8,
y: ((r.byte(p + 1) + sine(angle, f * 8) - 4) & 255) - 16,
tile: OBJ + 1,
palette: 0,
flipX: Boolean(flip & 1),
flipY: Boolean(flip & 2),
});
}
return sprites;
}
/** Hatch_UpdateFrontpicBGMapCenter and _CGB_Evolution. The tilemap is
* column-major PlaceGraphic over a cleared space-tile background. */
export function hatchScreen(
data: PokemonData,
species: number,
dvs: number,
hatched: boolean,
overworldPalettes: Palette[] = [0, 1].map((i) =>
romPalette(data.reader, SYMBOLS.objectPalettes + 64 + i * 8),
),
): TileScreen {
const tiles = new Uint8Array(0x102 * 16);
// GetEggFrontpic/GetHatchlingFrontpic incorrectly use stale battle DVs
// for Unown's letter. The port has no persistent battle-DV buffer: A.
tiles.set(data.frontPic(species), 0);
tiles.set(data.eggPic(), 0x31 * 16);
tiles.set(data.reader.slice(EGGS.EggHatchGFX, 32), OBJ * 16);
const screen = blankScreen(
tiles,
`hatch:${species}:${dvs}`,
[
hatched
? data.palette(species, PokemonData.shiny(dvs))
: data.eggPalette(PokemonData.shiny(dvs)),
],
hatched ? data.speciesName(species) : "EGG",
);
// _CGB_Evolution replaces palettes 2-7 only. Cracks/fragments use 0,
// retaining the overworld color instead of the newly loaded gray.
screen.objPalettes = [
...overworldPalettes.slice(0, 2),
...Array.from({ length: 6 }, (_, i) =>
romPalette(data.reader, EGGS.BattleObjectPals + i * 8),
),
];
screen.map.fill(0x7f);
const x = hatched ? 6 : 7,
y = hatched ? 3 : 4,
base = hatched ? 0 : 0x31;
for (let col = 0; col < 7; col++)
for (let row = 0; row < 7; row++)
screen.map[(y + row) * 20 + x + col] = base + col * 7 + row;
return screen;
}
/** EggHatch_AnimationSequence through Hatch_ShellFragmentLoop. Realtime
* screens advance at one video frame; buttons cannot cancel the hatch.
* The final species-specific AnimateFrontpic program is not translated. */
export function* hatchAnimation(
data: PokemonData,
species: number,
dvs: number,
sound?: GameSound,
overworldPalettes?: Palette[],
): Generator<ScriptEffect, void, number | string | undefined> {
const screen = hatchScreen(data, species, dvs, false, overworldPalettes);
const show = function* (
frames: number,
): Generator<ScriptEffect, void, number | string | undefined> {
for (let i = 0; i < frames; i++)
yield { kind: "screen", screen, realtime: true };
};
sound?.playMusic(SOUND.MUSIC_EVOLUTION);
yield* show(80);
const cracks: ScreenSprite[] = [];
for (let cycle = 0; cycle < 8; cycle++) {
for (let wobble = 0; wobble <= cycle; wobble++) {
for (const x of [2, -2]) {
screen.scroll = { x, y: 0, fill: 0x7f };
screen.sprites = cracks.map((sprite) => ({
...sprite,
x: sprite.x - x,
}));
yield* show(3);
}
}
yield* show(16);
if (cycle !== 7 && cycle & 1) {
cracks.push({
x: 88 - 12,
y: 76 + (cycle >> 1) * 8 - 20,
tile: OBJ,
palette: 0,
});
sound?.playSFX(SOUND.SFX_EGG_CRACK);
}
}
sound?.playSFX(SOUND.SFX_EGG_HATCH);
// Hatch_InitShellFragments draws once before changing the BG picture.
screen.scroll = { x: 0, y: 0, fill: 0x7f };
screen.sprites = shellSprites(data, 0);
yield* show(1);
const hatched = hatchScreen(data, species, dvs, true, overworldPalettes);
for (let frame = 1; frame <= 129; frame++) {
hatched.sprites = shellSprites(data, frame);
yield { kind: "screen", screen: hatched, realtime: true };
}
sound?.playMonCry2(species);
}
+190
View File
@@ -0,0 +1,190 @@
import { hatchAnimation } from "./hatch-animation";
import type { Palette } from "../world/types";
import type { GameSound } from "../audio/sound";
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { EGGS, EVENTS, RAM, SYMBOLS } from "../rom/profile";
import { HATCHED_HAPPINESS } from "../party/eggs";
import { NICKNAME_PATTERN, calcStats, caughtData } from "../party/pokemon";
import {
copyScriptState,
type ScriptEffect,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** TOGEPI (constants/pokemon_constants.asm). */
const TOGEPI = 175;
/** CAUGHT_EGG_LEVEL: the level SetEggMonCaughtData stores. */
const CAUGHT_EGG_LEVEL = 1;
/** NICKNAME_LENGTH - 1 */
const NICKNAME_MAX = 10;
/** What the hatching routines need from the world. */
export interface HatchHost {
reader: RomReader;
data: PokemonData;
/** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay(): number;
/** The landmark GetWorldMapLocation finds for the current map. */
landmark(): number;
/** wPlayerID and wPlayerGender (1 for Kris). */
player(): { id: number; gender: 0 | 1 };
/** The text context; `player` is wPlayerName. */
text(): TextContext;
sound?: GameSound;
/** _CGB_Evolution retains overworld object palettes 0 and 1. */
objectPalettes?: Palette[];
}
/** OverworldHatchEgg and HatchEggs (engine/pokemon/breeding.asm): every
* Egg in the party whose cycles have run out (happiness 0) hatches, in
* party order, and each is followed by its nickname question. The party,
* Pokédex and events are changed on a draft of the state, which the world
* commits when the run ends; a save made meanwhile has none of it. */
export class HatchRun {
readonly state: ScriptState;
/** Hatching never defers another script. */
readonly deferred = undefined;
private iterator: Gen;
private done = false;
/** wStringBuffer1: the hatchling's species name. */
private readonly strings: Record<number, string> = {};
constructor(
state: ScriptState,
private host: HatchHost,
) {
this.state = copyScriptState(state);
this.iterator = this.run();
}
/** Advance until the next effect, or undefined when hatching is over. */
resume(input?: Input): ScriptEffect | undefined {
if (this.done) return undefined;
const next = this.iterator.next(input);
if (next.done) {
this.done = true;
return undefined;
}
return next.value;
}
private text(): TextContext {
const base = this.host.text();
return { ...base, strings: { ...base.strings, ...this.strings } };
}
/** A ROM text as pages, with the sounds it plays attached to their pages
* the way a script's text carries them. */
private pages(address: number): string[] {
const sounds: { sfx: number; at: number }[] = [];
const content = readText(this.host.reader, address, {
...this.text(),
onSound: (sfx, at) => sounds.push({ sfx, at }),
});
const pages = content.split("\n\n").filter(Boolean);
const sound = this.host.sound;
if (sound && sounds.length)
Object.defineProperty(pages, "cues", {
value: sounds.map(({ sfx, at }) => ({
page: Math.min(
content.slice(0, at).split("\n\n").length - 1,
pages.length - 1,
),
play: () => sound.playSFX(sfx),
})),
});
return pages;
}
private *print(address: number): Gen {
const pages = this.pages(address);
if (pages.length) yield { kind: "text", pages };
}
private *run(): Gen {
const party = this.state.party;
// HatchEggs walks wPartySpecies, which holds EGG for each Egg; one
// whose cycles are not exactly zero is passed over.
for (let slot = 0; slot < party.length; slot++)
if (party[slot].egg && party[slot].happiness === 0)
yield* this.hatch(slot);
// OverworldHatchEgg: ExitAllMenus, RestartMapMusic, CloseText.
this.host.sound?.restartMapMusic();
}
/** One Egg's turn of HatchEggs. */
private *hatch(slot: number): Gen {
const state = this.state,
data = this.host.data,
mon = state.party[slot];
const player = this.host.player();
mon.happiness = HATCHED_HAPPINESS;
// SetEggMonCaughtData: the time of day and place of the hatching, with
// the level CAUGHT_EGG_LEVEL.
mon.caughtData = caughtData(
this.host.timeOfDay(),
CAUGHT_EGG_LEVEL,
player.gender,
this.host.landmark(),
);
// SetSeenAndCaughtMon, then Togepi's event.
state.pokedex.seen.add(mon.species);
state.pokedex.caught.add(mon.species);
if (mon.species === TOGEPI) state.events.add(EVENTS.togepiHatched);
// The slot's species is no longer EGG; GetPokemonName names it.
delete mon.egg;
const name = data.speciesName(mon.species);
this.strings[RAM.stringBuffer1] = name;
// CalcMonStats (b = FALSE, ignoring stat experience), full HP, no status; the
// player becomes the original trainer.
mon.stats = calcStats(data, mon.species, mon.dvs, mon.level);
mon.hp = mon.stats[0];
mon.status = 0;
mon.otId = player.id;
mon.ot = this.host.text().player;
// Text_HatchEgg: "Huh?", the animation, then the new name.
yield* this.print(EGGS.Text_BreedHuh);
yield* hatchAnimation(
data,
mon.species,
mon.dvs,
this.host.sound,
this.host.objectPalettes,
);
yield* this.print(EGGS._BreedEggHatchText);
// BreedAskNicknameText and YesNoBox; NamingScreen (NAME_MON) if yes.
const wants = yield {
kind: "yesno" as const,
pages: this.pages(EGGS._BreedAskNicknameText),
};
if (!wants) {
mon.nickname = name;
return;
}
const r = this.host.reader,
context = this.text();
let second = SYMBOLS.nicknameStrings;
while (r.byte(second++) !== 0x50);
const suffix = readString(r, SYMBOLS.nicknameStrings, context),
title = readString(r, second, context);
const answer = yield {
kind: "name" as const,
pages: [`${name}${suffix}\n${title}`],
current: name,
max: NICKNAME_MAX,
};
if (
typeof answer !== "string" ||
answer.length > NICKNAME_MAX ||
(answer && !NICKNAME_PATTERN.test(answer))
)
throw new Error("Invalid nickname.");
// InitName: a blank answer keeps the species name.
mon.nickname = /^ *$/.test(answer) ? name : answer;
}
}
+70 -3
View File
@@ -7,6 +7,16 @@ import { RomReader } from "../rom/reader";
import type { GameSound } from "../audio/sound"; import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants"; import { SOUND } from "../audio/constants";
import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood"; import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood";
import {
dayCareLady,
dayCareMan,
dayCareManOutside,
dayCareMon1,
dayCareMon2,
giveOddEgg,
} from "./day-care";
import type { HallOfFameTeam } from "../party/hall-of-fame";
import { copyDayCare, emptyDayCare, type DayCareState } from "../party/day-care";
import { import {
EMPTY_ROUTINES, EMPTY_ROUTINES,
ENGINE_FLAGS, ENGINE_FLAGS,
@@ -193,6 +203,9 @@ export interface ScriptState {
noWildEncounters: boolean; noWildEncounters: boolean;
/** STATUSFLAGS_HALL_OF_FAME_F: the League has been beaten. */ /** STATUSFLAGS_HALL_OF_FAME_F: the League has been beaten. */
hallOfFame: boolean; hallOfFame: boolean;
/** wHallOfFameCount and sHallOfFame (see party/hall-of-fame.ts). */
hallOfFameCount: number;
hallOfFameTeams: HallOfFameTeam[];
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below) /** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */ * and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number }; registeredItem: { which: number; item: number };
@@ -200,6 +213,12 @@ export interface ScriptState {
mailbox: Mail[]; mailbox: Mail[];
/** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */ /** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */
repel: number; repel: number;
/** wStepCount, wPoisonStepCount and wHappinessStepCount: what CountStep
* counts (see ../party/eggs), and DoBikeStep's wBikeStep. */
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
bikeStep: number;
/** wCoins: the Coin Case's coins. */ /** wCoins: the Coin Case's coins. */
coins: number; coins: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */ /** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
@@ -233,6 +252,9 @@ export interface ScriptState {
roamMonsMap: string | null; roamMonsMap: string | null;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */ /** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions; options: GameOptions;
/** The Day-Care: wDayCareMan, wDayCareLady, wBreedMon1/2, wStepsToEgg,
* wBreedMotherOrNonDitto and wEggMon (see ../party/day-care). */
dayCare: DayCareState;
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest /** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
* runs (ENGINE_BUG_CONTEST_TIMER/STATUSFLAGS2_BUG_CONTEST_TIMER_F). */ * runs (ENGINE_BUG_CONTEST_TIMER/STATUSFLAGS2_BUG_CONTEST_TIMER_F). */
bugContest?: BugContest; bugContest?: BugContest;
@@ -278,9 +300,15 @@ export const emptyScriptState = (): ScriptState => ({
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
hallOfFame: false, hallOfFame: false,
hallOfFameCount: 0,
hallOfFameTeams: [],
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
mailbox: [], mailbox: [],
repel: 0, repel: 0,
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
coins: 0, coins: 0,
tradeFlags: 0, tradeFlags: 0,
blueCardBalance: 0, blueCardBalance: 0,
@@ -294,6 +322,7 @@ export const emptyScriptState = (): ScriptState => ({
roamMons: emptyRoamMons(), roamMons: emptyRoamMons(),
roamMonsMap: null, roamMonsMap: null,
options: defaultOptions(), options: defaultOptions(),
dayCare: emptyDayCare(),
}); });
export const copyScriptState = (state: ScriptState): ScriptState => ({ export const copyScriptState = (state: ScriptState): ScriptState => ({
decorations: { ...state.decorations }, decorations: { ...state.decorations },
@@ -326,9 +355,15 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
flash: state.flash, flash: state.flash,
noWildEncounters: state.noWildEncounters, noWildEncounters: state.noWildEncounters,
hallOfFame: state.hallOfFame, hallOfFame: state.hallOfFame,
hallOfFameCount: state.hallOfFameCount,
hallOfFameTeams: structuredClone(state.hallOfFameTeams),
registeredItem: { ...state.registeredItem }, registeredItem: { ...state.registeredItem },
mailbox: state.mailbox.map(copyMail), mailbox: state.mailbox.map(copyMail),
repel: state.repel, repel: state.repel,
stepCount: state.stepCount,
poisonStepCount: state.poisonStepCount,
happinessStepCount: state.happinessStepCount,
bikeStep: state.bikeStep,
coins: state.coins, coins: state.coins,
tradeFlags: state.tradeFlags, tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance, blueCardBalance: state.blueCardBalance,
@@ -344,6 +379,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
roamMons: state.roamMons.map((mon) => ({ ...mon })), roamMons: state.roamMons.map((mon) => ({ ...mon })),
roamMonsMap: state.roamMonsMap, roamMonsMap: state.roamMonsMap,
options: { ...state.options }, options: { ...state.options },
dayCare: copyDayCare(state.dayCare),
...(state.bugContest && { ...(state.bugContest && {
bugContest: { bugContest: {
parkBalls: state.bugContest.parkBalls, parkBalls: state.bugContest.parkBalls,
@@ -572,7 +608,7 @@ export type ScriptEffect =
mail?: boolean; mail?: boolean;
} }
/** ReadAnyMail: a letter, closed with a button. */ /** ReadAnyMail: a letter, closed with a button. */
| { kind: "mail"; mail: Mail } | { kind: "mail"; mail: Mail; screen: TileScreen }
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */ /** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
| { kind: "martmenu"; prompt: string; selected: number } | { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list (buy: full stock; sell: the bag's pockets); /** The mart's item list (buy: full stock; sell: the bag's pockets);
@@ -1661,13 +1697,19 @@ export class ScriptRun {
yield* flush(); yield* flush();
yield synchronous({ kind: "battle", wild, tutorial: true }); yield synchronous({ kind: "battle", wild, tutorial: true });
reloadMapMusic(); reloadMapMusic();
// Script_reloadmap: EnterMap zeroes wPoisonStepCount.
this.state.poisonStepCount = 0;
wild = undefined; wild = undefined;
canLose = false; canLose = false;
break; break;
} }
case 0x60: case 0x60:
// Script_reloadmapafterbattle: a loss continues with the whiteout. // Script_reloadmapafterbattle: a loss continues with the whiteout.
if ((battleResult & 0x3f) !== 1) reloadMapMusic(); if ((battleResult & 0x3f) !== 1) {
reloadMapMusic();
// Script_reloadmap: EnterMap zeroes wPoisonStepCount.
this.state.poisonStepCount = 0;
}
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
// BATTLESCRIPT_WILD_F is set by trainer battles, which run // BATTLESCRIPT_WILD_F is set by trainer battles, which run
// MomTriesToBuySomething. Without a purchase it changes nothing. // MomTriesToBuySomething. Without a purchase it changes nothing.
@@ -1693,6 +1735,8 @@ export class ScriptRun {
// re-running callbacks or rebuilding objects. Native presentation // re-running callbacks or rebuilding objects. Native presentation
// already retains the map; clear wBattleScriptFlags equivalents. // already retains the map; clear wBattleScriptFlags equivalents.
reloadMapMusic(); reloadMapMusic();
// EnterMap zeroes wPoisonStepCount for MAPSETUP_RELOADMAP.
this.state.poisonStepCount = 0;
wild = undefined; wild = undefined;
trainer = undefined; trainer = undefined;
canLose = false; canLose = false;
@@ -3056,9 +3100,17 @@ export class ScriptRun {
flush: function* () { flush: function* () {
if (pages.length) yield { kind: "text", pages: take() }; if (pages.length) yield { kind: "text", pages: take() };
}, },
selectMon: () => this.selectMonFromParty(take()), selectMon: (action) => this.selectMonFromParty(take(), action),
playSFX: (id) => this.cue(() => this.host.sound!.playSFX(id)),
cry: (species) => this.monCry(species),
waitSFX: () => this.waitSFX(),
}; };
} }
/** PlayMonCry: it plays with the pending page, else now, and waits. */
private *monCry(species: number): Generator<ScriptEffect, void, Input> {
this.cue(() => this.host.sound!.playMonCry2(species));
yield* this.waitSFX();
}
/** PlaceMoneyTopRight's box: the money with the ¥ sign. */ /** PlaceMoneyTopRight's box: the money with the ¥ sign. */
private moneyBalance() { private moneyBalance() {
return `${readString(this.reader, JOHTO.MoneyString, this.text())} ¥${this.state.money}`; return `${readString(this.reader, JOHTO.MoneyString, this.text())} ¥${this.state.money}`;
@@ -3729,6 +3781,21 @@ export class ScriptRun {
case JOHTO.NameRater: case JOHTO.NameRater:
yield* nameRater(this.specialContext(pages, take)); yield* nameRater(this.specialContext(pages, take));
return value; return value;
case JOHTO.DayCareMan:
yield* dayCareMan(this.specialContext(pages, take));
return value;
case JOHTO.DayCareLady:
yield* dayCareLady(this.specialContext(pages, take));
return value;
case JOHTO.DayCareManOutside:
return yield* dayCareManOutside(this.specialContext(pages, take), value);
case JOHTO.DayCareMon1:
return yield* dayCareMon1(this.specialContext(pages, take), value);
case JOHTO.DayCareMon2:
return yield* dayCareMon2(this.specialContext(pages, take), value);
case JOHTO.GiveOddEgg:
giveOddEgg(this.specialContext(pages, take));
return value;
case JOHTO.GiveShuckle: case JOHTO.GiveShuckle:
return giveShuckle(this.specialContext(pages, take)); return giveShuckle(this.specialContext(pages, take));
case JOHTO.ReturnShuckie: case JOHTO.ReturnShuckie:
+340
View File
@@ -0,0 +1,340 @@
import type { Mail } from "../party/mail";
import type { PokemonData } from "../rom/pokemon";
import { MAIL, JOHTO } from "../rom/profile";
import { fontTiles, textTiles } from "../rom/font";
import { blankScreen, romPalette, type TileScreen } from "./screen";
import { printerDisconnected } from "./printer";
import type { Gen } from "./special-context";
type Graphic = keyof typeof MAIL;
/** ReadAnyMail.LoadGFX and the ten Load*MailGFX routines. Tiles retain
* the cartridge indices; each 1bpp graphic is expanded into its original
* color plane. Portrait MAIL uses Unown A regardless of the holder's DVs. */
export function mailScreen(data: PokemonData, mail: Mail): TileScreen {
const r = data.reader;
let index = 0;
while (index < 10 && r.byte(MAIL.MailGFXPointers + index * 3) !== mail.type)
index++;
if (index === 10) index = 0;
const tiles = fontTiles(r, 0);
tiles.set(r.slice(JOHTO.FontExtra + 3 * 16, 22 * 16), 0x63 * 16);
for (let i = 0; i < 1024; i++) {
const value = r.byte(MAIL.StandardEnglishFont + i);
tiles[0x800 + i * 2] = value;
tiles[0x801 + i * 2] = value;
}
const screen = blankScreen(
tiles,
`mail:${mail.type}:${mail.species}`,
[romPalette(r, MAIL["LoadMailPalettes.MailPals"] + index * 8)],
`${data.itemName(mail.type)}\n${mail.message}\n${mail.author}`,
);
screen.map.fill(0x7f);
let next = 0x31;
const gfx = (name: Graphic, count: number, color: number, offset = 0) => {
for (let i = 0; i < count * 8; i++) {
const v = r.byte(MAIL[name] + offset * 8 + i);
tiles[next * 16 + i * 2] = color & 1 ? v : 0;
tiles[next * 16 + i * 2 + 1] = color & 2 ? v : 0;
}
next += count;
};
const solid = (count: number, color: number) => {
for (let i = 0; i < count * 8; i++) {
tiles[next * 16 + i * 2] = color & 1 ? 255 : 0;
tiles[next * 16 + i * 2 + 1] = color & 2 ? 255 : 0;
}
next += count;
};
const put = (x: number, y: number, tile: number) => {
screen.map[y * 20 + x] = tile;
};
const row = (x: number, y: number, n: number, tile: number) => {
for (let i = 0; i < n; i++) put(x + i, y, tile);
};
const col = (x: number, y: number, n: number, tile: number) => {
for (let i = 0; i < n; i++) put(x, y + i, tile);
};
const graphic = (x: number, y: number, tile: number, width = 2) => {
for (let j = 0; j < 2; j++)
for (let i = 0; i < width; i++) put(x + i, y + j, tile + j * width + i);
};
const dots = (tile: number, positions: number[][]) =>
positions.forEach(([x, y]) => put(x, y, tile));
const icons = () => {
for (const [x, y] of [
[2, 2],
[16, 2],
[9, 4],
[2, 11],
[6, 12],
[12, 11],
])
graphic(x, y, 0x3d);
dots(0x41, [
[5, 4],
[6, 2],
[12, 4],
[14, 2],
[3, 13],
[9, 11],
[16, 12],
]);
};
const border = (second = false) => {
put(0, 0, 0x31);
row(1, 0, 18, 0x32);
put(19, 0, second ? 0x31 : 0x33);
col(0, 1, 16, second ? 0x33 : 0x34);
col(19, 1, 16, 0x35);
put(0, 17, second ? 0x31 : 0x36);
row(1, 17, 18, second ? 0x34 : 0x37);
put(19, 17, second ? 0x31 : 0x38);
};
// Alternating rows/columns place one extra tile after their loop.
const alternating = (
x: number,
y: number,
n: number,
tile: number,
vertical = false,
) => {
for (let i = 0; i <= n; i++)
put(x + (vertical ? 0 : i), y + (vertical ? i : 0), tile + (i & 1));
};
switch (index) {
case 0:
gfx("FlowerMailBorderGFX", 8, 1);
gfx("MailOddishGFX", 4, 3);
gfx("FlowerMailBorderGFX", 1, 2, 6);
gfx("FlowerMailFlowerGFX", 4, 1);
gfx("FlowerMailFlowerGFX", 4, 2, 4);
border();
row(2, 15, 16, 0x3d);
graphic(16, 13, 0x39);
graphic(2, 13, 0x39);
for (const [x, y] of [
[2, 2],
[5, 3],
[10, 2],
[16, 3],
[5, 11],
[16, 10],
])
graphic(x, y, 0x3e);
for (const [x, y] of [
[3, 4],
[12, 3],
[14, 2],
[2, 10],
[14, 11],
])
graphic(x, y, 0x42);
break;
case 1:
case 2:
gfx(index === 1 ? "SurfMailBorderGFX" : "LiteBlueMailBorderGFX", 8, 2);
gfx(index === 1 ? "MailLaprasGFX" : "MailDratiniGFX", 6, 3);
gfx(index === 1 ? "SurfMailWaveGFX" : "PortraitMailUnderlineGFX", 1, 2);
gfx("SurfLiteBlueMailSmallShapesGFX", 2, 2);
gfx("SurfLiteBlueMailSmallShapesGFX", 2, 1, 2);
gfx("SurfLiteBlueMailLargeShapesGFX", 8, 1);
gfx("SurfLiteBlueMailLargeShapesGFX", 8, 2, 8);
border();
row(2, 15, 16, 0x3f);
graphic(15, 14, 0x39, 3);
graphic(2, 2, 0x44);
graphic(15, 11, 0x44);
graphic(3, 12, 0x4c);
graphic(15, 2, 0x4c);
graphic(6, 3, 0x50);
dots(0x40, [
[13, 2],
[6, 14],
]);
dots(0x41, [
[4, 5],
[17, 5],
[13, 12],
]);
dots(0x42, [
[9, 2],
[14, 5],
[3, 10],
]);
put(6, 11, 0x43);
break;
case 3:
gfx("PortraitMailBorderGFX", 5, 2);
gfx("PortraitMailUnderlineGFX", 1, 2);
next = 0x3d;
gfx("PortraitMailLargePokeballGFX", 4, 1);
gfx("PortraitMailSmallPokeballGFX", 1, 2);
border(true);
row(8, 15, 10, 0x36);
icons();
// PrepMonFrontpic sets wBoxAlignment: reverse each row's bits,
// then PlaceGraphic lays the tile columns out right to left.
const picture = data.frontPic(mail.species, 1);
for (let i = 0; i < picture.length; i++) {
let value = picture[i],
reversed = 0;
for (let bit = 0; bit < 8; bit++) {
reversed = (reversed << 1) | (value & 1);
value >>= 1;
}
tiles[i] = reversed;
}
for (let x = 0; x < 7; x++)
for (let y = 0; y < 7; y++) put(1 + x, 10 + y, (6 - x) * 7 + y);
break;
case 4:
gfx("LovelyMailBorderGFX", 5, 2);
gfx("MailPoliwagGFX", 6, 3);
gfx("LovelyMailUnderlineGFX", 1, 2);
gfx("LovelyMailLargeHeartGFX", 4, 2);
gfx("LovelyMailSmallHeartGFX", 1, 1);
border(true);
row(2, 15, 16, 0x3c);
graphic(15, 14, 0x36, 3);
icons();
break;
case 5:
gfx("EonMailBorder1GFX", 1, 2);
gfx("EonMailBorder2GFX", 1, 1);
gfx("EonMailBorder2GFX", 1, 1);
gfx("EonMailBorder1GFX", 1, 2);
gfx("SurfMailBorderGFX", 1, 2, 6);
gfx("MailEeveeGFX", 6, 3);
next = 0x3d;
gfx("MailLargeCircleGFX", 4, 1);
gfx("EonMailBorder2GFX", 1, 2);
alternating(0, 0, 18, 0x31);
alternating(1, 17, 18, 0x31);
alternating(0, 1, 16, 0x33, true);
alternating(19, 0, 16, 0x33, true);
row(2, 15, 16, 0x35);
graphic(15, 14, 0x36, 3);
icons();
break;
case 6:
// MailGFX_GenerateMonochromeTilesColor2 uses 5 * TILE_1BPP_SIZE
// iterations (40 rows), filling five 2bpp tiles.
solid(5, 2);
gfx("MorphMailBorderCornerGFX", 1, 2, 3);
gfx("MorphMailBorderCornerGFX", 1, 2);
gfx("MorphMailBorderGFX", 1, 2);
gfx("EonMailBorder1GFX", 1, 1);
gfx("MorphMailDividerGFX", 1, 2);
gfx("MailDittoGFX", 6, 3);
border(true);
graphic(1, 1, 0x31);
graphic(17, 15, 0x31);
dots(0x31, [
[1, 3],
[3, 1],
[16, 16],
[18, 14],
]);
dots(0x36, [
[1, 4],
[2, 3],
[3, 2],
[4, 1],
]);
dots(0x37, [
[15, 16],
[16, 15],
[17, 14],
[18, 13],
]);
row(2, 15, 14, 0x38);
row(2, 11, 16, 0x39);
row(2, 5, 16, 0x39);
row(6, 1, 13, 0x3a);
row(1, 16, 13, 0x3a);
graphic(3, 13, 0x3b, 3);
break;
case 7:
gfx("EonMailBorder1GFX", 1, 2);
solid(1, 3);
gfx("BlueSkyMailGrassGFX", 1, 3);
gfx("MailDragoniteAndSentretGFX", 23, 3);
gfx("MailCloudGFX", 6, 1);
gfx("FlowerMailBorderGFX", 1, 1, 6);
gfx("MailCloudGFX", 1, 1);
gfx("MailCloudGFX", 2, 1, 2);
gfx("MailCloudGFX", 1, 1, 5);
row(0, 0, 20, 0x31);
col(0, 1, 16, 0x31);
col(19, 1, 16, 0x31);
row(0, 17, 20, 0x32);
row(0, 16, 20, 0x33);
for (let j = 0; j < 3; j++)
for (let i = 0; i < 6; i++) put(2 + j + i, 2 + j, 0x34 + j * 6 + i);
put(9, 4, 0x7f);
graphic(15, 14, 0x45);
put(15, 16, 0x49);
put(16, 16, 0x4a);
graphic(12, 1, 0x4b, 3);
graphic(15, 4, 0x4b, 3);
row(2, 11, 16, 0x51);
graphic(10, 3, 0x52);
break;
case 8:
gfx("MusicMailBorderGFX", 4, 2);
gfx("MorphMailBorderGFX", 2, 2);
gfx("MailNatuGFX", 6, 3);
solid(1, 0);
gfx("MusicMailLargeNoteGFX", 3, 1);
gfx("MusicMailSmallNoteGFX", 1, 1);
alternating(0, 0, 18, 0x31);
alternating(1, 17, 18, 0x31);
alternating(0, 1, 16, 0x33, true);
alternating(19, 0, 16, 0x33, true);
alternating(2, 15, 14, 0x35);
graphic(15, 14, 0x37, 3);
icons();
break;
case 9:
solid(5, 2);
gfx("BlueSkyMailGrassGFX", 1, 2);
gfx("MailMewGFX", 18, 2);
gfx("LiteBlueMailBorderGFX", 1, 1, 1);
gfx("LiteBlueMailBorderGFX", 1, 1, 6);
border(true);
row(1, 16, 18, 0x36);
graphic(15, 14, 0x37, 3);
put(15, 16, 0x3d);
put(16, 16, 0x3e);
alternating(1, 1, 18, 0x3f);
alternating(0, 2, 14, 0x41, true);
alternating(19, 2, 14, 0x43, true);
put(0, 1, 0x45);
put(19, 1, 0x46);
put(0, 16, 0x47);
put(19, 16, 0x48);
row(2, 5, 16, 0x49);
row(2, 11, 16, 0x4a);
}
// PlaceString's <NEXT> advances two tile rows.
mail.message
.split("\n")
.forEach((line, y) =>
textTiles(line).forEach((tile, x) => put(2 + x, 7 + y * 2, tile)),
);
textTiles(mail.author).forEach((tile, x) =>
put((index === 3 ? 8 : index === 6 ? 6 : 5) + x, 14, tile),
);
return screen;
}
/** ReadAnyMail.loop: A/B return; Start prints and returns to the letter. */
export function* readMail(data: PokemonData, mail: Mail, frame = 0): Gen {
const screen = mailScreen(data, mail);
for (;;) {
const button = yield { kind: "mail", mail: { ...mail }, screen };
if (button === "a" || button === "b") return;
if (button === "start") yield* printerDisconnected(data.reader, frame);
}
}
+20 -10
View File
@@ -3,7 +3,9 @@ import { SOUND } from "../audio/constants";
import type { RomReader } from "../rom/reader"; import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon"; import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile"; import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
import { copyMail } from "../party/mail"; import { hallOfFamePC } from "./hall-of-fame";
import { readMail } from "./mail";
import { printerDisconnected } from "./printer";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag"; import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { import {
BOX_NAME_PATTERN, BOX_NAME_PATTERN,
@@ -47,6 +49,7 @@ export interface PcHost {
const PCPCITEM_PLAYERS_PC = 0, const PCPCITEM_PLAYERS_PC = 0,
PCPCITEM_BILLS_PC = 1, PCPCITEM_BILLS_PC = 1,
PCPCITEM_OAKS_PC = 2, PCPCITEM_OAKS_PC = 2,
PCPCITEM_HALL_OF_FAME = 3,
PCPCITEM_TURN_OFF = 4; PCPCITEM_TURN_OFF = 4;
/** _PlayersPC's menu items (PLAYERSPCITEM_*). */ /** _PlayersPC's menu items (PLAYERSPCITEM_*). */
const PLAYERSPCITEM_WITHDRAW_ITEM = 0, const PLAYERSPCITEM_WITHDRAW_ITEM = 0,
@@ -154,9 +157,11 @@ export class PcSession {
const whose = this.pages(PC.PokecenterPCWhoseText); const whose = this.pages(PC.PokecenterPCWhoseText);
let cursor = 0; let cursor = 0;
for (;;) { for (;;) {
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX or PCPC_BEFORE_HOF. // .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX, PCPC_BEFORE_HOF, or
// No Hall of Fame entry exists before the League is ported. // PCPC_POSTGAME once wHallOfFameCount (not the League flag) is set.
const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? 1 : 0; const set = this.state.flags.has(ENGINE_FLAGS.pokedex)
? this.state.hallOfFameCount ? 2 : 1
: 0;
const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set); const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set);
const choice = yield* this.menu( const choice = yield* this.menu(
whose, whose,
@@ -185,11 +190,19 @@ export class PcSession {
yield* this.print(PC.PokecenterOaksPCText); yield* this.print(PC.PokecenterOaksPCText);
yield* this.oaksPC(); yield* this.oaksPC();
break; break;
case PCPCITEM_HALL_OF_FAME:
yield* hallOfFamePC(
this.host.data,
this.state.hallOfFameTeams,
this.state.options.textboxFrame,
this.host.sound,
);
break;
case PCPCITEM_TURN_OFF: case PCPCITEM_TURN_OFF:
yield* this.print(PC.PokecenterPCOaksClosedText); yield* this.print(PC.PokecenterPCOaksClosedText);
return; return;
default: default:
throw new Error("The Hall of Fame PC is not ported."); throw new Error("Unknown Pokémon Center PC entry.");
} }
} }
} }
@@ -261,7 +274,7 @@ export class PcSession {
if (index < 0 || index >= mailbox.length) return; if (index < 0 || index >= mailbox.length) return;
cursor = index; cursor = index;
const action = yield* this.menu([], actions); const action = yield* this.menu([], actions);
if (action === 0) yield { kind: "mail", mail: copyMail(mailbox[index]) }; if (action === 0) yield* readMail(this.host.data, mailbox[index], this.state.options.textboxFrame);
else if (action === 1) yield* this.putMailInPack(index); else if (action === 1) yield* this.putMailInPack(index);
else if (action === 2) yield* this.attachMail(index); else if (action === 2) yield* this.attachMail(index);
} }
@@ -824,10 +837,7 @@ export class PcSession {
this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]), this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]),
); );
else else
yield { yield* printerDisconnected(this.host.reader, this.state.options.textboxFrame);
kind: "notice",
text: "Printing needs a Game Boy Printer, which is not supported.",
};
} }
} }
} }
+4 -2
View File
@@ -1,3 +1,4 @@
import { printerDisconnected } from "./printer";
import type { GameSound } from "../audio/sound"; import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants"; import { SOUND } from "../audio/constants";
import { NUM_POKEMON } from "../rom/profile"; import { NUM_POKEMON } from "../rom/profile";
@@ -96,7 +97,7 @@ export interface PokedexHost {
* the entry screen. B leaves the list; A opens a seen Pokémon's entry, * the entry screen. B leaves the list; A opens a seen Pokémon's entry,
* where left and right move the arrow cursor, up and down step through * where left and right move the arrow cursor, up and down step through
* seen Pokémon, A runs the action under the cursor and B returns. PAGE * seen Pokémon, A runs the action under the cursor and B returns. PAGE
* and CRY act: AREA and PRNT, the option (SELECT) and search (START) * and CRY act; PRNT reaches the no-printer status. AREA, the option (SELECT) and search (START)
* screens and the Unown mode are not ported. */ * screens and the Unown mode are not ported. */
export class Pokedex { export class Pokedex {
/** wPokedexOrder, in DEXMODE_NEW. */ /** wPokedexOrder, in DEXMODE_NEW. */
@@ -176,11 +177,12 @@ export class Pokedex {
else if (button === "right" && action < ENTRY_ACTIONS - 1) action++; else if (button === "right" && action < ENTRY_ACTIONS - 1) action++;
if (button === "b") return; if (button === "b") return;
if (button === "a") { if (button === "a") {
// Pokedex_Page and .Cry; .Area and .Print are not ported. // Pokedex_Page, .Cry and the absent-printer path of .Print.
if (action === 0) { if (action === 0) {
page = page === 1 ? 2 : 1; page = page === 1 ? 2 : 1;
this.prevEntry = this.selected(); this.prevEntry = this.selected();
} else if (action === 2) this.host.sound?.playCry(this.selected()); } else if (action === 2) this.host.sound?.playCry(this.selected());
else if (action === 3) yield* printerDisconnected(this.host.data.reader);
continue; continue;
} }
if ( if (
+3
View File
@@ -552,5 +552,8 @@ export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost):
take: unused, take: unused,
flush: unused, flush: unused,
selectMon: unused, selectMon: unused,
playSFX: unused,
cry: unused,
waitSFX: unused,
}; };
} }
+45
View File
@@ -0,0 +1,45 @@
import type { RomReader } from "../rom/reader";
import { JOHTO } from "../rom/profile";
import { fontTiles, textTiles } from "../rom/font";
import { blankScreen } from "./screen";
import { defaultTextContext, readString } from "./text";
import type { Gen } from "./special-context";
/** SendScreenToPrinter/CheckPrinterStatus with no peripheral connected:
* PRINTER_ERROR_2 persists until CheckCancelPrint receives B. The native
* status screen omits the print preview and the link retry animation. */
export function* printerDisconnected(reader: RomReader, frame = 0): Gen {
const context = defaultTextContext("");
const status = readString(
reader,
JOHTO.GBPrinterString_PrinterError2,
context,
64,
);
const cancel = readString(reader, JOHTO.String_PressBToCancel, context);
const screen = blankScreen(
fontTiles(reader, frame),
`printer:${frame}`,
[
[
[255, 255, 255],
[170, 170, 170],
[85, 85, 85],
[0, 0, 0],
],
],
`${status}\n${cancel}`,
);
screen.map.fill(0x7f);
for (const [index, line] of [
...status.split("\n"),
"",
...cancel.split("\n"),
].entries())
textTiles(line).forEach((tile, x) => {
screen.map[(5 + index) * 20 + 1 + x] = tile;
});
while ((yield { kind: "screen", screen }) !== "b") {
/* CheckCancelPrint */
}
}
+3 -3
View File
@@ -53,10 +53,10 @@ export interface TileScreen {
label: string; label: string;
/** Drawn over the overworld, like a menu box, rather than replacing it. */ /** Drawn over the overworld, like a menu box, rather than replacing it. */
overlay?: boolean; overlay?: boolean;
/** hSCY as a signed pixel offset: the background (not the sprites) /** hSCX/hSCY as signed pixel offsets: the background (not sprites)
* moves up by `y`, and rows scrolled in from beyond the tilemap show * moves left by `x` and up by `y`, and rows scrolled in from beyond the tilemap show
* `fill` (what the screen left in the rest of the 32-row BG map). */ * `fill` (what the screen left in the rest of the 32-row BG map). */
scroll?: { y: number; fill: number }; scroll?: { x?: number; y: number; fill: number };
} }
/** A GBC color (RGB555, little-endian word) as RGB. */ /** A GBC color (RGB555, little-endian word) as RGB. */
+9 -2
View File
@@ -31,6 +31,13 @@ export interface SpecialContext {
take(): string[]; take(): string[];
/** Show the pending pages and wait for a button, if there are any. */ /** Show the pending pages and wait for a button, if there are any. */
flush(): Gen; flush(): Gen;
/** SelectMonFromParty after the pending pages: a party index, or -1. */ /** SelectMonFromParty after the pending pages: a party index, or -1.
selectMon(): Gen<number>; * With a PARTYMENUACTION_*, SelectTradeOrDayCareMon's prompt. */
selectMon(action?: number): Gen<number>;
/** PlaySFX: now, or with the pending page. */
playSFX(id: number): void;
/** PlayMonCry: with the pending page, else now and waited for. */
cry(species: number): Gen;
/** WaitSFX, when no page is pending. */
waitSFX(): Gen;
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { readMail } from "./mail";
import { SOUND } from "../audio/constants"; import { SOUND } from "../audio/constants";
import type { RomReader } from "../rom/reader"; import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon"; import type { PokemonData } from "../rom/pokemon";
@@ -789,7 +790,7 @@ export class StartMenuRun {
/** ReadPartyMonMail: the letter, until a button. */ /** ReadPartyMonMail: the letter, until a button. */
private *readMail(mail: Mail | undefined): Gen { private *readMail(mail: Mail | undefined): Gen {
if (!mail) throw new Error("The Pokémon holds MAIL without a message."); if (!mail) throw new Error("The Pokémon holds MAIL without a message.");
yield { kind: "mail", mail: { ...mail } }; yield* readMail(this.data, mail, this.state.options.textboxFrame);
} }
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) { private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
if (pocket === TM_HM_POCKET) { if (pocket === TM_HM_POCKET) {
+2 -1
View File
@@ -19,7 +19,8 @@ const sameOT = (ot: string, player: string) => {
/** CheckOwnMonAnywhere (engine/pokemon/search_owned.asm): a Pokémon of /** CheckOwnMonAnywhere (engine/pokemon/search_owned.asm): a Pokémon of
* `species` with the player's ID and OT in the party or a box. An empty * `species` with the player's ID and OT in the party or a box. An empty
* party finds nothing; the Day-Care is not searched. The party struct's * party finds nothing; the original bug omits the Day-Care (see
* engine/pokemon/search_owned.asm), even when its Pokémon belong to us. The party struct's
* species is read, so an Egg counts as its species. */ * species is read, so an Egg counts as its species. */
export function ownsMon(c: SpecialContext, species: number) { export function ownsMon(c: SpecialContext, species: number) {
const { party, storage } = c.state; const { party, storage } = c.state;
+2
View File
@@ -166,6 +166,8 @@ export const OW = {
NUM_OVERWORLD_SPRITES: 102, NUM_OVERWORLD_SPRITES: 102,
SPRITE_POKEMON: 0x80, SPRITE_POKEMON: 0x80,
NUM_POKEMON_SPRITES: 35, NUM_POKEMON_SPRITES: 35,
SPRITE_DAY_CARE_MON_1: 0xe0,
SPRITE_DAY_CARE_MON_2: 0xe1,
SPRITE_VARS: 0xf0, SPRITE_VARS: 0xf0,
// Moves used as field moves (constants/move_constants.asm). // Moves used as field moves (constants/move_constants.asm).
CUT: 0x0f, CUT: 0x0f,
+205 -6
View File
@@ -3,7 +3,8 @@ import {
initialDecorations, initialDecorations,
type Decorations, type Decorations,
} from "./decorations"; } from "./decorations";
import { CrystalAssets } from "../rom/assets"; import { validHallOfFame, type HallOfFameTeam } from "../party/hall-of-fame";
import { CrystalAssets, MON_SPRITE_ID } from "../rom/assets";
import { GameSound } from "../audio/sound"; import { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants"; import { SOUND } from "../audio/constants";
import { battleMusic, hpBarPixels, inKanto, victoryMusic } from "../audio/battle-music"; import { battleMusic, hpBarPixels, inKanto, victoryMusic } from "../audio/battle-music";
@@ -73,6 +74,8 @@ import {
type BattleTowerState, type BattleTowerState,
} from "../scripts/battle-tower"; } from "../scripts/battle-tower";
import { IntroRun } from "../scripts/intro"; import { IntroRun } from "../scripts/intro";
import { HatchRun } from "../scripts/hatching";
import { doEggStep, stepHappiness } from "../party/eggs";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map"; import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen"; import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers"; import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
@@ -101,6 +104,13 @@ import {
} from "../party/mail"; } from "../party/mail";
import type { MovementPose, MovementStep } from "../scripts/movement"; import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag"; import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
copyDayCare,
dayCareStep as stepDayCare,
emptyDayCare,
validDayCare,
type DayCareState,
} from "../party/day-care";
import { NUM_TMS_HMS } from "../rom/pokemon"; import { NUM_TMS_HMS } from "../rom/pokemon";
import { import {
copyStorage, copyStorage,
@@ -206,12 +216,21 @@ export interface NativeSave {
noWildEncounters: boolean; noWildEncounters: boolean;
/** STATUSFLAGS_HALL_OF_FAME_F. */ /** STATUSFLAGS_HALL_OF_FAME_F. */
hallOfFame: boolean; hallOfFame: boolean;
/** wHallOfFameCount and sHallOfFame, newest team first. */
hallOfFameCount: number;
hallOfFameTeams: HallOfFameTeam[];
/** wWhichRegisteredItem and wRegisteredItem, for Select. */ /** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number }; registeredItem: { which: number; item: number };
/** sMailboxes: the PC's MAIL. */ /** sMailboxes: the PC's MAIL. */
mailbox: Mail[]; mailbox: Mail[];
/** wRepelEffect, wCoins and wBlueCardBalance. */ /** wRepelEffect, wCoins and wBlueCardBalance. */
repel: number; repel: number;
/** wStepCount, wPoisonStepCount and wHappinessStepCount (CountStep), and
* DoBikeStep's wBikeStep. */
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
bikeStep: number;
coins: number; coins: number;
blueCardBalance: number; blueCardBalance: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */ /** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
@@ -235,6 +254,9 @@ export interface NativeSave {
roamMonsMap: string | null; roamMonsMap: string | null;
/** The option bytes, saved with the game as sOptions is. */ /** The option bytes, saved with the game as sOptions is. */
options: GameOptions; options: GameOptions;
/** The Day-Care's bytes (wDayCareMan to wEggMon); an empty Day-Care in
* saves from before it was ported. */
dayCare: DayCareState;
/** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline, /** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline,
* while a Bug-Catching Contest runs; absent otherwise. */ * while a Bug-Catching Contest runs; absent otherwise. */
bugContest?: { parkBalls: number; mon: PartyMon | null }; bugContest?: { parkBalls: number; mon: PartyMon | null };
@@ -510,7 +532,7 @@ function evolutionSchedule(from: number, to: number) {
const BATTLE_MESSAGE_TICKS = 30; const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript { interface ActiveScript {
run: ScriptRun | IntroRun | StartMenuRun; run: ScriptRun | IntroRun | StartMenuRun | HatchRun;
effect?: ScriptEffect; effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */ /** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave; committed: NativeSave;
@@ -521,6 +543,8 @@ interface ActiveScript {
state: ScriptState; state: ScriptState;
mapId: string; mapId: string;
blocks: Uint8Array; blocks: Uint8Array;
/** A hatch refusal restores the triggering walking step as well. */
tilesWalked?: number;
}; };
waitTicks: number; waitTicks: number;
moving: Map< moving: Map<
@@ -719,6 +743,8 @@ const OBJECTTYPE_TRAINER = 2;
const TOWN = 1, const TOWN = 1,
ROUTE = 2; ROUTE = 2;
const NUM_SPECIALCALLS = 8; const NUM_SPECIALCALLS = 8;
/** SPECIALCALL_BIKESHOP (constants/phone_constants.asm). */
const SPECIALCALL_BIKESHOP = 6;
/** STEP_SLOW: random walkers take 16 ticks per tile. */ /** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16; const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) => const isTimed = (effect: ScriptEffect | undefined) =>
@@ -992,10 +1018,17 @@ export class GameWorld {
? hour >= object.hour1 && hour < object.hour2 ? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2; : hour >= object.hour1 || hour < object.hour2;
} }
/** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites. */ /** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites, and
* GetMonSprite draws SPRITE_DAY_CARE_MON_1 and 2 as the species of
* wBreedMon1 and 2 (none: no sprite). */
private spriteOf(object: MapObject, state = this.liveState): number { private spriteOf(object: MapObject, state = this.liveState): number {
const override = this.actors?.get(object.id)?.sprite; const override = this.actors?.get(object.id)?.sprite;
if (override !== undefined) return override; if (override !== undefined) return override;
if (object.sprite === OW.SPRITE_DAY_CARE_MON_1 || object.sprite === OW.SPRITE_DAY_CARE_MON_2) {
const mon =
object.sprite === OW.SPRITE_DAY_CARE_MON_1 ? state.dayCare.mon1 : state.dayCare.mon2;
return mon ? MON_SPRITE_ID + mon.species : 0;
}
if (object.sprite < OW.SPRITE_VARS) return object.sprite; if (object.sprite < OW.SPRITE_VARS) return object.sprite;
return state.variables[object.sprite - OW.SPRITE_VARS] ?? 0; return state.variables[object.sprite - OW.SPRITE_VARS] ?? 0;
} }
@@ -1701,8 +1734,132 @@ export class GameWorld {
); );
if (event) if (event)
this.startScript(event.script, PLAYER, { committedPosition: previous }); this.startScript(event.script, PLAYER, { committedPosition: previous });
else if (!this.checkSpecialPhoneCall(previous) && !this.repelStep(previous)) else if (!this.countStep(previous)) this.randomEncounter(previous);
this.randomEncounter(previous); }
/** CountStep (engine/overworld/events.asm), after the coordinate events
* and before RandomEncounter: a queued special call or the end of a
* Repel runs its script and the step is not counted; otherwise the step
* is counted for poison and in total, StepHappiness runs when the total
* wraps (every 256 steps), DoEggStep at $80 (so a hatching Egg's script
* replaces the Day-Care, poison and bike checks), then DayCareStep, the
* poison check every fourth step and DoBikeStep. Returns true when it
* started a script. */
private countStep(previous: { x: number; y: number }): boolean {
if (this.checkSpecialPhoneCall(previous) || this.repelStep(previous))
return true;
const state = this.state;
// A step that starts a script replays after a reload from the tile
// before it, so that script's saved state keeps what the step changed.
const before = {
stepCount: state.stepCount,
poisonStepCount: state.poisonStepCount,
happinessStepCount: state.happinessStepCount,
happiness: state.party.map((mon) => mon.happiness),
};
state.poisonStepCount = (state.poisonStepCount + 1) & 0xff;
state.stepCount = (state.stepCount + 1) & 0xff;
if (!state.stepCount) stepHappiness(state, state.party);
if (state.stepCount === 0x80 && doEggStep(state.party)) {
this.startHatch(previous, before);
return true;
}
this.dayCareStep();
// Every fourth step DoPoisonStep runs. It is not ported: poison does
// not wear down the party on the overworld, nor faint it.
if (state.poisonStepCount >= 4) state.poisonStepCount = 0;
this.bikeStep();
return false;
}
/** CountStep's farcall DayCareStep (engine/events/happiness_egg.asm): a
* counted step is one experience point for each Pokémon in the Day-Care
* and, while they are compatible, brings the egg check nearer. */
private dayCareStep() {
stepDayCare(this.assets.pokemon, this.state, () => this.random() & 0xff);
}
/** DoBikeStep: once the Bike Shop owner has the player's number
* (ENGINE_BIKE_SHOP_CALL_ENABLED), the steps taken on the Bicycle where
* the phone has service are counted, up to 65535; from 1024 the owner
* calls (SPECIALCALL_BIKESHOP) unless another call is already queued,
* and the flag is cleared. */
private bikeStep() {
const state = this.state;
if (
!state.flags.has(ENGINE_FLAGS.bikeShopCall) ||
state.playerState !== OW.PLAYER_BIKE ||
this.map.phoneService
)
return;
if (state.bikeStep !== 0xffff) state.bikeStep++;
// `cp HIGH(1024)` on the high byte, and the call ID's low byte only.
if (state.bikeStep < 1024 || (state.specialPhoneCall & 0xff)) return;
state.specialPhoneCall = SPECIALCALL_BIKESHOP;
state.flags.delete(ENGINE_FLAGS.bikeShopCall);
}
/** HatchEggScript (callasm OverworldHatchEgg) for PLAYEREVENT_HATCH. The
* script's draft has the step's counts and the Eggs' cycles; its saved
* state, like every step event's, is from before the step (see
* `startScript`), so a reload during the hatching walks the step again. */
private startHatch(
previous: { x: number; y: number },
before: {
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
happiness: number[];
},
) {
const committed = this.save();
committed.x = previous.x;
committed.y = previous.y;
committed.tilesWalked--;
committed.stepCount = before.stepCount;
committed.poisonStepCount = before.poisonStepCount;
committed.happinessStepCount = before.happinessStepCount;
committed.party.forEach((mon, index) => (mon.happiness = before.happiness[index]));
// RepelWoreOff did not run; a Repel that is still active lost a step.
if (this.state.repel) committed.repel++;
const rollbackState = copyScriptState(this.state);
rollbackState.stepCount = before.stepCount;
rollbackState.poisonStepCount = before.poisonStepCount;
rollbackState.happinessStepCount = before.happinessStepCount;
rollbackState.party.forEach((mon, index) => (mon.happiness = before.happiness[index]));
rollbackState.repel = committed.repel;
this.script = {
run: new HatchRun(this.state, {
objectPalettes: [0, 1].map((index) => this.assets.objectPalette(index, this.timeOfDay)),
reader: this.assets.reader,
data: this.assets.pokemon,
timeOfDay: () => this.clockTimeOfDay,
landmark: () => this.assets.map(this.mapId).landmark,
player: () => ({
id: this.playerId,
gender: this.avatar === "kris" ? 1 : 0,
}),
text: () => this.host(PLAYER).text(),
sound: this.sound,
}),
committed,
rollback: {
state: rollbackState,
tilesWalked: committed.tilesWalked,
mapId: this.mapId,
player: { ...copyActor(this.player), x: previous.x, y: previous.y },
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.screenInput = [];
this.pump();
} }
/** CountStep's DoRepelStep: a Repel loses a step, and the step that ends /** CountStep's DoRepelStep: a Repel loses a step, and the step that ends
@@ -2246,6 +2403,10 @@ export class GameWorld {
this.currentMap = entry.map; this.currentMap = entry.map;
this.stoneTables = entry.stoneTables; this.stoneTables = entry.stoneTables;
this.state = entry.state; this.state = entry.state;
// EnterMap clears the poison clock on an actual map transition.
// Native save restoration keeps its saved phase for exact replay.
if (setup === "warp" || setup === "door" || setup === "connection")
this.state.poisonStepCount = 0;
this.actors = entry.actors; this.actors = entry.actors;
this.spawned = new Set( this.spawned = new Set(
this.map.objects this.map.objects
@@ -3010,7 +3171,7 @@ export class GameWorld {
return true; return true;
case "mail": case "mail":
this.mailView = { ...effect.mail }; this.mailView = { ...effect.mail };
this.dialogue = { pages: ["MAIL"], index: 0 }; this.dialogue = { pages: ["MAIL"], index: 0, choice: { kind: "screen", screen: effect.screen, selected: 0 } };
return true; return true;
case "mapradio": case "mapradio":
this.dialogue = { this.dialogue = {
@@ -3563,6 +3724,7 @@ export class GameWorld {
this.battle = undefined; this.battle = undefined;
this.battleRun = undefined; this.battleRun = undefined;
this.state = script.rollback.state; this.state = script.rollback.state;
if (script.rollback.tilesWalked !== undefined) this.tilesWalked = script.rollback.tilesWalked;
this.mapId = script.rollback.mapId; this.mapId = script.rollback.mapId;
this.map.blocks.set(script.rollback.blocks); this.map.blocks.set(script.rollback.blocks);
this.blockRevision++; this.blockRevision++;
@@ -4989,9 +5151,15 @@ export class GameWorld {
flash: state.flash, flash: state.flash,
noWildEncounters: state.noWildEncounters, noWildEncounters: state.noWildEncounters,
hallOfFame: state.hallOfFame, hallOfFame: state.hallOfFame,
hallOfFameCount: state.hallOfFameCount,
hallOfFameTeams: structuredClone(state.hallOfFameTeams),
registeredItem: { ...state.registeredItem }, registeredItem: { ...state.registeredItem },
mailbox: state.mailbox.map(copyMail), mailbox: state.mailbox.map(copyMail),
repel: state.repel, repel: state.repel,
stepCount: state.stepCount,
poisonStepCount: state.poisonStepCount,
happinessStepCount: state.happinessStepCount,
bikeStep: state.bikeStep,
coins: state.coins, coins: state.coins,
tradeFlags: state.tradeFlags, tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance, blueCardBalance: state.blueCardBalance,
@@ -5005,6 +5173,7 @@ export class GameWorld {
roamMons: structuredClone(state.roamMons), roamMons: structuredClone(state.roamMons),
roamMonsMap: state.roamMonsMap, roamMonsMap: state.roamMonsMap,
options: { ...state.options }, options: { ...state.options },
dayCare: copyDayCare(state.dayCare),
...(state.bugContest && { ...(state.bugContest && {
bugContest: { bugContest: {
parkBalls: state.bugContest.parkBalls, parkBalls: state.bugContest.parkBalls,
@@ -5132,10 +5301,15 @@ export class GameWorld {
typeof save.flash !== "boolean" || typeof save.flash !== "boolean" ||
typeof save.noWildEncounters !== "boolean" || typeof save.noWildEncounters !== "boolean" ||
typeof save.hallOfFame !== "boolean" || typeof save.hallOfFame !== "boolean" ||
!validHallOfFame(save.hallOfFameCount, save.hallOfFameTeams) ||
!save.registeredItem || !save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) || !integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255) || !integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) || !integer(save.repel, 0, 255) ||
!integer(save.stepCount, 0, 255) ||
!integer(save.poisonStepCount, 0, 255) ||
!integer(save.happinessStepCount, 0, 1) ||
!integer(save.bikeStep, 0, 0xffff) ||
!integer(save.coins, 0, C.MAX_COINS) || !integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.tradeFlags, 0, 0x7f) || !integer(save.tradeFlags, 0, 0x7f) ||
!integer(save.blueCardBalance, 0, 255) || !integer(save.blueCardBalance, 0, 255) ||
@@ -5171,6 +5345,7 @@ export class GameWorld {
(typeof save.roamMonsMap !== "string" || (typeof save.roamMonsMap !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) || !/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) ||
!validOptions(save.options) || !validOptions(save.options) ||
!validDayCare(this.assets.pokemon, save.dayCare, new Set(save.flags)) ||
(save.bugContest === undefined) !== (save.bugContestEnd === undefined) || (save.bugContest === undefined) !== (save.bugContestEnd === undefined) ||
(save.bugContest !== undefined && (save.bugContest !== undefined &&
(typeof save.bugContest !== "object" || (typeof save.bugContest !== "object" ||
@@ -5254,9 +5429,15 @@ export class GameWorld {
flash: save.flash, flash: save.flash,
noWildEncounters: save.noWildEncounters, noWildEncounters: save.noWildEncounters,
hallOfFame: save.hallOfFame, hallOfFame: save.hallOfFame,
hallOfFameCount: save.hallOfFameCount,
hallOfFameTeams: structuredClone(save.hallOfFameTeams),
registeredItem: { ...save.registeredItem }, registeredItem: { ...save.registeredItem },
mailbox: save.mailbox.map(copyMail), mailbox: save.mailbox.map(copyMail),
repel: save.repel, repel: save.repel,
stepCount: save.stepCount,
poisonStepCount: save.poisonStepCount,
happinessStepCount: save.happinessStepCount,
bikeStep: save.bikeStep,
coins: save.coins, coins: save.coins,
tradeFlags: save.tradeFlags, tradeFlags: save.tradeFlags,
blueCardBalance: save.blueCardBalance, blueCardBalance: save.blueCardBalance,
@@ -5270,6 +5451,7 @@ export class GameWorld {
roamMons: structuredClone(save.roamMons), roamMons: structuredClone(save.roamMons),
roamMonsMap: save.roamMonsMap, roamMonsMap: save.roamMonsMap,
options: { ...save.options }, options: { ...save.options },
dayCare: copyDayCare(save.dayCare),
specialPhoneCall: save.specialPhoneCall, specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName, rivalName: save.rivalName,
...(save.bugContest && { ...(save.bugContest && {
@@ -5397,6 +5579,8 @@ export function migrateSave(
...(!Object.hasOwn(save, "noWildEncounters") && { ...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false, noWildEncounters: false,
}), }),
...(!Object.hasOwn(save, "hallOfFameCount") && { hallOfFameCount: 0 }),
...(!Object.hasOwn(save, "hallOfFameTeams") && { hallOfFameTeams: [] }),
...(!Object.hasOwn(save, "hallOfFame") && { hallOfFame: false }), ...(!Object.hasOwn(save, "hallOfFame") && { hallOfFame: false }),
...(!Object.hasOwn(save, "registeredItem") && { ...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
@@ -5407,6 +5591,13 @@ export function migrateSave(
coins: 0, coins: 0,
blueCardBalance: 0, blueCardBalance: 0,
}), }),
// Saves before CountStep was ported start the step counters at 0.
...(!Object.hasOwn(save, "stepCount") && {
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
}),
// Packs saved before the TM/HM pocket had no TMs or HMs. // Packs saved before the TM/HM pocket had no TMs or HMs.
...(typeof save.bag === "object" && ...(typeof save.bag === "object" &&
save.bag !== null && save.bag !== null &&
@@ -5441,6 +5632,7 @@ export function migrateSave(
...(!Object.hasOwn(save, "intro") && { intro: false }), ...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }), ...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }), ...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
...(!Object.hasOwn(save, "dayCare") && { dayCare: emptyDayCare() }),
// Clocks saved before InitClock was ported follow the device time. // Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null && ...(save.clock !== null &&
typeof save.clock === "object" && typeof save.clock === "object" &&
@@ -5486,9 +5678,15 @@ export function migrateSave(
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
hallOfFame: false, hallOfFame: false,
hallOfFameCount: 0,
hallOfFameTeams: [],
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
mailbox: [], mailbox: [],
repel: 0, repel: 0,
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
coins: 0, coins: 0,
blueCardBalance: 0, blueCardBalance: 0,
tradeFlags: 0, tradeFlags: 0,
@@ -5504,5 +5702,6 @@ export function migrateSave(
intro: false, intro: false,
gameTime: emptyGameTime(), gameTime: emptyGameTime(),
options: defaultOptions(), options: defaultOptions(),
dayCare: emptyDayCare(),
} as unknown as NativeSave; } as unknown as NativeSave;
} }
+4 -3
View File
@@ -415,8 +415,7 @@ function mailField(answer: (message: string) => void): HTMLElement[] {
box.append(...lines); box.append(...lines);
return [box, ok]; return [box, ok];
} }
/** ReadAnyMail: the message at its two lines and the author below. The /** ReadAnyMail: cartridge stationery plus accessible message text. */
* stationery's graphics are not drawn. */
function showMail(world: GameWorld) { function showMail(world: GameWorld) {
const mail = world.mailView; const mail = world.mailView;
get("mail-view").hidden = !mail; get("mail-view").hidden = !mail;
@@ -424,6 +423,8 @@ function showMail(world: GameWorld) {
get("mail-type").textContent = world.assets.pokemon.itemName(mail.type); get("mail-type").textContent = world.assets.pokemon.itemName(mail.type);
get("mail-message").textContent = mail.message; get("mail-message").textContent = mail.message;
get("mail-author").textContent = mail.author; get("mail-author").textContent = mail.author;
const choice = world.dialogue?.choice;
if (choice?.kind === "screen") drawTileScreen(get<HTMLCanvasElement>("mail-canvas"), choice.screen, 0);
} }
type MartChoice = Extract< type MartChoice = Extract<
Choice, Choice,
@@ -1349,7 +1350,7 @@ let tileScreenKey = "";
* world's frames. */ * world's frames. */
function showTileScreen(world: GameWorld) { function showTileScreen(world: GameWorld) {
const choice = world.dialogue?.choice; const choice = world.dialogue?.choice;
const screen = choice?.kind === "screen" ? choice.screen : undefined; const screen = choice?.kind === "screen" && !world.mailView ? choice.screen : undefined;
get("tile-screen").hidden = !screen; get("tile-screen").hidden = !screen;
get("tile-screen").classList.toggle("overlay", Boolean(screen?.overlay)); get("tile-screen").classList.toggle("overlay", Boolean(screen?.overlay));
if (!screen) { if (!screen) {
+1 -1
View File
@@ -33,7 +33,7 @@ export function renderShell(root: HTMLElement) {
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div> <div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div> </div>
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div> <div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div> <div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><canvas id="mail-canvas" width="160" height="144"></canvas><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div>
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div> <div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div> <div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div> <div id="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div>
+15 -23
View File
@@ -534,33 +534,25 @@ dialog h2 {
inset: 0; inset: 0;
z-index: 4; z-index: 4;
display: grid; display: grid;
grid-template-rows: auto 1fr auto auto; grid-template-rows: minmax(0, 1fr) auto;
gap: 12px; justify-items: center;
padding: 16px; background: #fff;
color: var(--ink);
background: #f8f8f0;
border: 8px double #a8b8d0;
} }
.mail-type { #mail-canvas {
margin: 0; width: 100%;
font-weight: 700; height: 100%;
object-fit: contain;
image-rendering: pixelated;
} }
.mail-message { .mail-type, .mail-message, .mail-author {
margin: 0; position: absolute;
align-self: center; width: 1px;
font-family: ui-monospace, monospace; height: 1px;
font-size: 1.1rem; overflow: hidden;
line-height: 1.8; clip-path: inset(50%);
white-space: pre; white-space: pre;
overflow-x: auto;
}
.mail-author {
margin: 0;
justify-self: end;
}
.mail-view button {
justify-self: end;
} }
.mail-view button { justify-self: end; }
.stats-screen[data-page="1"] { .stats-screen[data-page="1"] {
--stats-page: #88c888; --stats-page: #88c888;
} }
File diff suppressed because it is too large. Load diff
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { doEggStep, stepHappiness } from "../src/game/party/eggs";
import {
generatePartyMon,
caughtData,
type PartyMon,
} from "../src/game/party/pokemon";
import { HatchRun } from "../src/game/scripts/hatching";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { defaultTextContext } from "../src/game/scripts/text";
import { EVENTS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe("egg step byte rules", () => {
const egg = (happiness: number) => ({ egg: true, happiness }) as PartyMon;
it("stops decrementing at the first ready Egg and wraps a zero byte", () => {
const party = [egg(0), egg(1), egg(5)];
expect(doEggStep(party)).toBe(true);
expect(party.map((m) => m.happiness)).toEqual([255, 0, 5]);
expect(doEggStep(party)).toBe(false);
expect(party.map((m) => m.happiness)).toEqual([254, 255, 4]);
});
it("raises happiness only on alternate wraps, excluding Eggs and capping at 255", () => {
const counters = { happinessStepCount: 0 };
const party = [
egg(20),
{ happiness: 254 } as PartyMon,
{ happiness: 255 } as PartyMon,
];
stepHappiness(counters, party);
expect(party.map((m) => m.happiness)).toEqual([20, 254, 255]);
stepHappiness(counters, party);
expect(party.map((m) => m.happiness)).toEqual([20, 255, 255]);
expect(counters.happinessStepCount).toBe(0);
});
});
describe.skipIf(!existsSync(romPath))("hatching with the local ROM", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const egg = (a: CrystalAssets, species = 175, happiness = 1) =>
Object.assign(
generatePartyMon(a.pokemon, {
species,
level: 5,
item: 0,
otId: 111,
ot: "ELM",
dvs: 0x8888,
caughtData: 0,
}),
{ egg: true as const, nickname: "EGG", happiness, hp: 0 },
);
const world = () => {
const w = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"));
w.restore({ ...w.save(), map: "24:3", x: 12, y: 3, direction: "left" });
settle(w);
w.state.noWildEncounters = true;
return w;
};
const walk = (w: GameWorld, direction: "left" | "right") => {
expect(w.move(direction)).toBe(true);
for (let i = 0; i < 100 && !w.dialogue && w.busy; i++) w.tick();
};
it("hatches every zero-cycle Egg in party order with caught data, ownership and naming", () => {
const a = assets(),
state = emptyScriptState();
state.party = [egg(a, 175, 0), egg(a, 172, 5), egg(a, 173, 0)];
const run = new HatchRun(state, {
reader: a.reader,
data: a.pokemon,
timeOfDay: () => 2,
landmark: () => 12,
player: () => ({ id: 456, gender: 1 }),
text: () => defaultTextContext("KRIS"),
});
const questions: string[] = [];
let effect = run.resume();
while (effect) {
let input: number | string | undefined;
if (effect.kind === "yesno") {
questions.push(effect.pages.join(" "));
input = 1;
}
if (effect.kind === "name")
input = questions.length === 1 ? "SHELLY" : " ";
effect = run.resume(input);
}
expect(state.party.every((mon) => mon.egg)).toBe(true);
expect(run.state.party.map((mon) => mon.nickname)).toEqual([
"SHELLY",
"EGG",
"CLEFFA",
]);
for (const mon of [run.state.party[0], run.state.party[2]]) {
expect(mon.egg).toBeUndefined();
expect(mon).toMatchObject({
happiness: 120,
otId: 456,
ot: "KRIS",
status: 0,
caughtData: caughtData(2, 1, 1, 12),
});
expect(mon.hp).toBe(mon.stats[0]);
expect(run.state.pokedex.caught.has(mon.species)).toBe(true);
}
expect(run.state.events.has(EVENTS.togepiHatched)).toBe(true);
});
it("walks the full Togepi cycle and replays a save made during the hatch", () => {
const w = world();
w.state.party = [egg(w.assets, 175, 2)];
for (let i = 0; i < 383; i++) {
walk(w, i % 2 ? "right" : "left");
settle(w);
}
expect(w.state.party[0].happiness).toBe(1);
expect(w.state.stepCount).toBe(127);
const before = w.save();
walk(w, "right");
expect(w.dialogue).toBeDefined();
const saved = w.save();
expect(saved.party[0]).toEqual(before.party[0]);
expect(saved.stepCount).toBe(127);
expect([saved.x, saved.y, saved.tilesWalked]).toEqual([
before.x,
before.y,
before.tilesWalked,
]);
// Reach naming while the whole hatch stays uncommitted.
settle(w, [1]);
expect(w.dialogue?.choice?.kind).toBe("name");
expect(w.save().party[0].egg).toBe(true);
const replay = world();
replay.restore(w.save());
walk(replay, "right");
settle(replay, [0]);
expect(replay.state.party[0]).toMatchObject({
happiness: 120,
nickname: "TOGEPI",
});
expect(replay.state.party[0].egg).toBeUndefined();
expect(replay.state.events.has(EVENTS.togepiHatched)).toBe(true);
expect(replay.state.stepCount).toBe(128);
expect(replay.save().tilesWalked).toBe(before.tilesWalked + 1);
});
it("counts 512 walking steps for happiness", () => {
const w = world();
const mon = egg(w.assets);
delete mon.egg;
mon.happiness = 70;
w.state.party = [mon];
for (let i = 0; i < 512; i++) {
walk(w, i % 2 ? "right" : "left");
settle(w);
}
expect(mon.happiness).toBe(71);
expect(w.state.stepCount).toBe(0);
expect(w.state.poisonStepCount).toBe(0);
});
it("rolls back the triggering step and Egg cycles if presentation fails", () => {
const w = world();
w.state.party = [egg(w.assets)];
w.state.stepCount = 127;
w.state.repel = 10;
const before = w.save();
w.assets.pokemon.eggPic = () => {
throw Error("Injected hatch picture failure");
};
walk(w, "left");
w.advanceDialogue();
expect(w.notice).not.toBe("");
const after = w.save();
expect([
after.x,
after.y,
after.tilesWalked,
after.stepCount,
after.poisonStepCount,
after.repel,
]).toEqual([
before.x,
before.y,
before.tilesWalked,
before.stepCount,
before.poisonStepCount,
before.repel,
]);
expect(after.party).toEqual(before.party);
expect(after.events).toEqual(before.events);
});
it("does not count a step whose Repel expires", () => {
const w = world();
w.state.repel = 1;
w.state.stepCount = 127;
w.state.party = [egg(w.assets)];
walk(w, "left");
settle(w);
expect(w.state.stepCount).toBe(127);
expect(w.state.party[0].happiness).toBe(1);
expect(w.state.repel).toBe(0);
});
it("migrates absent counters, rejects malformed ones and preserves saves atomically", () => {
const w = world(),
save = w.save();
const old = { ...save } as Partial<typeof save>;
for (const key of [
"stepCount",
"poisonStepCount",
"happinessStepCount",
"bikeStep",
] as const)
delete old[key];
w.restore(old);
expect(w.save()).toMatchObject({
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
});
for (const change of [
{ stepCount: 256 },
{ poisonStepCount: -1 },
{ happinessStepCount: 2 },
{ bikeStep: 65536 },
]) {
expect(() => w.restore({ ...save, ...change })).toThrow();
expect(w.save().stepCount).toBe(0);
}
});
});
+9
View File
@@ -570,6 +570,15 @@ test("MAIL is written when given, and read from the party menu", async ({
"HELLO, CHRIS! \nSEE YOU!", "HELLO, CHRIS! \nSEE YOU!",
); );
await expect(page.locator("#mail-author")).toHaveText("CHRIS"); await expect(page.locator("#mail-author")).toHaveText("CHRIS");
await expect(page.locator("#mail-canvas")).toBeVisible();
await page.keyboard.press("Enter");
await expect(letter).toBeHidden();
await expect(page.locator("#tile-screen-canvas")).toHaveAttribute("aria-label", /Printer Error 2/);
await page.keyboard.press("KeyX");
await expect(page.locator("#tile-screen")).toBeVisible();
await page.keyboard.press("KeyZ");
await expect(letter).toBeVisible();
await expect(page.locator("#mail-message")).toHaveText("HELLO, CHRIS! \nSEE YOU!");
await page.locator("#mail-close").click(); await page.locator("#mail-close").click();
await expect(letter).toBeHidden(); await expect(letter).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText(""); await expect(page.locator("#game-notice")).toHaveText("");
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import {
addHallOfFameEntry,
getHallOfFameParty,
validHallOfFame,
type HallOfFameRecords,
} from "../src/game/party/hall-of-fame";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { CrystalAssets } from "../src/game/rom/assets";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { hallOfFamePC } from "../src/game/scripts/hall-of-fame";
import { GameWorld } from "../src/game/world/world";
const minimal = {
species: 25,
otId: 12345,
dvs: 0xaaaa,
level: 42,
nickname: "SPARK",
} as PartyMon;
describe("Hall of Fame records", () => {
it("copies just the historical fields and skips Eggs, retaining order", () => {
const team = getHallOfFameParty(
[
minimal,
{ ...minimal, species: 175, egg: true },
{ ...minimal, species: 133 },
],
1,
);
expect(team).toEqual({
win: 1,
mons: [minimal, { ...minimal, species: 133 }],
});
team.mons[0].nickname = "OLD";
expect(minimal.nickname).toBe("SPARK");
expect(getHallOfFameParty([{ ...minimal, egg: true }], 2)).toEqual({
win: 2,
mons: [],
});
});
it("shifts out the oldest team at 30 and caps wins at 200 independently of the League flag", () => {
const state = {
hallOfFameCount: 0,
hallOfFameTeams: [],
hallOfFame: false,
} as HallOfFameRecords & { hallOfFame: boolean };
for (let i = 0; i < 35; i++)
addHallOfFameEntry(state, [{ ...minimal, otId: i }]);
expect(state.hallOfFameTeams).toHaveLength(30);
expect(state.hallOfFameTeams.map((team) => team.win)).toEqual(
Array.from({ length: 30 }, (_, i) => 35 - i),
);
expect(state.hallOfFameTeams[29].mons[0].otId).toBe(5);
for (let i = 35; i < 205; i++) addHallOfFameEntry(state, [minimal]);
expect(state.hallOfFameCount).toBe(200);
expect(state.hallOfFameTeams.slice(0, 6).map((team) => team.win)).toEqual(
Array(6).fill(200),
);
expect(state.hallOfFame).toBe(false);
expect(validHallOfFame(state.hallOfFameCount, state.hallOfFameTeams)).toBe(
true,
);
});
it("rejects malformed records and count/history contradictions", () => {
expect(validHallOfFame(0, [])).toBe(true);
for (const [count, teams] of [
[null, []],
[201, []],
[1, []],
[0, [getHallOfFameParty([minimal], 1)]],
[1, null],
[1, [{ win: 1, mons: null }]],
[1, [{ win: 1, mons: [{ ...minimal, species: 253 }] }]],
[1, [{ win: 1, mons: Array(7).fill(minimal) }]],
] as const)
expect(validHallOfFame(count, teams)).toBe(false);
});
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Hall of Fame with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const fixture = () => {
const world = new GameWorld(assets!);
world.state.party = [
generatePartyMon(assets!.pokemon, {
species: 25,
level: 42,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xaaaa,
caughtData: 0,
}),
];
world.restore({
...world.save(),
map: "26:5",
x: 9,
y: 2,
direction: "up",
objects: [],
});
return world;
};
const untilChoice = (world: GameWorld) => {
for (let i = 0; i < 300; i++) {
if (world.dialogue?.choice) return world.dialogue.choice;
if (world.dialogue) world.advanceDialogue();
else if (world.busy) world.tick();
else return;
}
throw Error("PC did not settle");
};
it("uses the count and Pokédex for the PC menu, then routes screen buttons back to the PC", () => {
const world = fixture();
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.hallOfFame = true;
world.interact();
let menu = untilChoice(world);
expect(menu?.kind).toBe("menu");
if (menu?.kind !== "menu") return;
expect(menu.items).not.toContain("HALL OF FAME");
world.choose(-1);
untilChoice(world);
world.state.hallOfFame = false;
addHallOfFameEntry(world.state, world.state.party);
world.interact();
menu = untilChoice(world);
if (menu?.kind !== "menu") throw Error("No PC menu");
expect(menu.items).toContain("HALL OF FAME");
world.choose(menu.items.indexOf("HALL OF FAME"));
const page = untilChoice(world);
expect(page?.kind).toBe("screen");
if (page?.kind === "screen") expect(page.screen.label).toContain("PIKACHU");
world.choose("b");
expect(untilChoice(world)?.kind).toBe("menu");
world.choose(-1);
untilChoice(world);
world.state.flags.delete(ENGINE_FLAGS.pokedex);
world.interact();
menu = untilChoice(world);
if (menu?.kind !== "menu") throw Error("No PC menu");
expect(menu.items).not.toContain("HALL OF FAME");
});
it("browses newest first, ignores directions, advances with A and skips teams with Start", () => {
const teams = [
getHallOfFameParty([minimal, { ...minimal, nickname: "SECOND" }], 3),
{ win: 2, mons: [] },
getHallOfFameParty([{ ...minimal, nickname: "OLDER" }], 1),
];
const run = hallOfFamePC(assets!.pokemon, teams, 0);
const label = (result: ReturnType<typeof run.next>) =>
!result.done && result.value.kind === "screen"
? result.value.screen.label
: "";
expect(label(run.next())).toContain("SPARK");
expect(label(run.next("down"))).toContain("SPARK");
expect(label(run.next("a"))).toContain("SECOND");
expect(label(run.next("start"))).toContain("OLDER");
expect(run.next("a").done).toBe(true);
const cancel = hallOfFamePC(assets!.pokemon, teams, 0);
cancel.next();
expect(cancel.next("b").done).toBe(true);
});
it("round-trips records, migrates by absence, and rejects explicit damage", () => {
const world = fixture();
addHallOfFameEntry(world.state, world.state.party);
const saved = JSON.parse(JSON.stringify(world.save()));
const loaded = fixture();
loaded.restore(saved);
expect(loaded.state.hallOfFameTeams).toEqual(world.state.hallOfFameTeams);
saved.hallOfFameTeams[0].mons[0].nickname = "CHANGED";
expect(loaded.state.hallOfFameTeams[0].mons[0].nickname).toBe("PIKACHU");
const legacy = JSON.parse(JSON.stringify(world.save()));
delete legacy.hallOfFameCount;
delete legacy.hallOfFameTeams;
loaded.restore(legacy);
expect(loaded.state.hallOfFameTeams).toEqual([]);
expect(loaded.state.hallOfFameCount).toBe(0);
for (const mutate of [
(s: any) => (s.hallOfFameCount = null),
(s: any) => (s.hallOfFameTeams = null),
(s: any) => (s.hallOfFameTeams[0].mons[0].level = 101),
]) {
const bad = JSON.parse(JSON.stringify(world.save()));
mutate(bad);
expect(() => loaded.restore(bad)).toThrow(/Invalid native save/);
}
});
});
+105 -1
View File
@@ -3,9 +3,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets"; import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world"; import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon"; import { generatePartyMon } from "../src/game/party/pokemon";
import { addHallOfFameEntry } from "../src/game/party/hall-of-fame";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { CRYSTAL_ROM } from "../src/rom/validate"; import { CRYSTAL_ROM } from "../src/rom/validate";
import { encodeSave } from "../src/storage/saves"; import { encodeSave } from "../src/storage/saves";
import { foundationFixture } from "./foundation-fixture"; import { foundationFixture } from "./foundation-fixture";
import { settle } from "./world-driver";
// Backups for the manual checks on the project board's "A tester" column, // Backups for the manual checks on the project board's "A tester" column,
// built from the local ROM and imported through Savegames → Import backup // built from the local ROM and imported through Savegames → Import backup
@@ -28,7 +31,9 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
function write(name: string, world: GameWorld) { function write(name: string, world: GameWorld) {
const state = new TextEncoder().encode(JSON.stringify(world.save())); const state = new TextEncoder().encode(JSON.stringify(world.save()));
// The backup must load as the app would load it. // The backup must load as the app would load it.
new GameWorld(assets()).restore(JSON.parse(new TextDecoder().decode(state))); new GameWorld(assets()).restore(
JSON.parse(new TextDecoder().decode(state)),
);
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
writeFileSync( writeFileSync(
`${OUT}/${name}.crystal-save.json`, `${OUT}/${name}.crystal-save.json`,
@@ -97,5 +102,104 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
expect(world.mapId).toBe("24:3"); expect(world.mapId).toBe("24:3");
write("headbutt", world); write("headbutt", world);
}); });
/** A Pokémon of the player's, from (species, level, DVs). */
const caught = (
a: CrystalAssets,
world: GameWorld,
species: number,
level: number,
dvs = 0x9abc,
) =>
generatePartyMon(a.pokemon, {
species,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs,
caughtData: 0,
});
it("writes day-care: inside the Day-Care with a pair that breeds", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
// A female Cyndaquil and a male Typhlosion (Attack and Speed DVs 0
// and 15), and a Totodile to keep. The Odd Egg is still to come.
world.state.party = [
caught(a, world, 155, 20, 0x0201), // CYNDAQUIL
caught(a, world, 157, 40, 0xf7f2), // TYPHLOSION
caught(a, world, 158, 15), // TOTODILE
];
world.state.money = 50000;
world.warpTo("11:24", 2, 4); // facing the man
settle(world);
world.restore({ ...world.save(), direction: "up" });
expect(world.mapId).toBe("11:24");
write("day-care", world);
});
it("writes egg-hatching: Route 29 with two Eggs two steps from hatching", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
const egg = (species: number, dvs: number, happiness: number) => ({
...caught(a, world, species, 5, dvs),
egg: true as const,
nickname: "EGG",
happiness,
hp: 0,
});
// The Togepi Egg reaches zero on step 128 and stops DoEggStep; the
// shiny Pichu's is already zero, so both hatch.
world.state.party = [
caught(a, world, 155, 20),
egg(175, 0x8888, 1), // TOGEPI
egg(172, 0x2aaa, 0), // PICHU
];
world.restore({
...world.save(),
map: "24:3",
x: 12,
y: 3,
direction: "left",
stepCount: 0x7e,
});
settle(world);
write("egg-hatching", world);
});
it("writes hall-of-fame: a Pokémon Center whose PC has three teams", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
world.state.flags.add(ENGINE_FLAGS.pokedex);
const record = (party: [number, number, number][]) =>
addHallOfFameEntry(
world.state,
party.map(([species, level, dvs]) =>
caught(a, world, species, level, dvs),
),
);
record([[201, 50, 0xffff]]); // UNOWN
record([
[157, 55, 0x9abc], // TYPHLOSION
[29, 30, 0x0000], // NIDORAN♀
[32, 31, 0xffff], // NIDORAN♂
[25, 100, 0x2aaa], // PIKACHU, shiny
[175, 12, 0x9abc], // TOGEPI
[249, 70, 0x9abc], // LUGIA
]);
record([[158, 42, 0x9abc]]); // TOTODILE
world.state.party = [caught(a, world, 155, 20)];
world.restore({
...world.save(),
map: "26:5",
x: 9,
y: 2,
direction: "up",
objects: [],
});
settle(world);
write("hall-of-fame", world);
});
}, },
); );
+26
View File
@@ -256,6 +256,32 @@ describe.skipIf(!existsSync(romPath))("MAIL", () => {
expect(world.state.bag.items).toEqual([{ item: FLOWER_MAIL, quantity: 1 }]); expect(world.state.bag.items).toEqual([{ item: FLOWER_MAIL, quantity: 1 }]);
}); });
it("prints with Start, waits for B on the error and restores the letter", () => {
const world = fixture();
const mon = world.state.party[0];
mon.item = FLOWER_MAIL;
mon.mail = letter(world, "“HELLO…”");
mailMenu(world);
world.choose(0);
const before = world.dialogue?.choice;
expect(before?.kind).toBe("screen");
world.choose("down");
expect(world.mailView).toEqual(mon.mail);
world.choose("start");
const printer = world.dialogue?.choice;
expect(printer?.kind === "screen" && printer.screen.label).toContain("Printer Error 2");
expect(world.mailView).toBeUndefined();
world.choose("a");
expect(world.dialogue?.choice).toEqual(printer);
world.choose("b");
expect(world.mailView).toEqual(mon.mail);
expect(world.dialogue?.choice).toEqual(before);
world.choose("a");
until(world);
close(world);
expect(world.state.party[0].mail).toEqual(mon.mail);
});
it("keeps a Pokémon holding MAIL out of the boxes", () => { it("keeps a Pokémon holding MAIL out of the boxes", () => {
const world = fixture(); const world = fixture();
const mon = world.state.party[1]; const mon = world.state.party[1];
+10
View File
@@ -317,6 +317,16 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false); expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
}); });
it("lets an Egg with stored HP join Beat Up, but skips a zero-HP Egg", () => {
const party = [
mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }),
mon(TOTODILE, 5, [TACKLE], { nickname: "EGG", egg: true }),
mon(PIDGEY, 5, [TACKLE], { nickname: "EGG", egg: true, hp: 0 }),
];
const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])]));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!", "EGG's attack!"]);
});
it("lets a lone Pokémon beat up once, then ends the move", () => { it("lets a lone Pokémon beat up once, then ends the move", () => {
const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])])); const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])]));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]); expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]);
+4 -4
View File
@@ -67,7 +67,7 @@ describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with th
x: number, x: number,
y: number, y: number,
face: "up" | "down" | "left" | "right", face: "up" | "down" | "left" | "right",
answers: number[] = [], answers: (number | string)[] = [],
) => { ) => {
game.warpTo(map, x, y); game.warpTo(map, x, y);
settle(game); settle(game);
@@ -260,18 +260,18 @@ describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with th
describe("the Photo Studio (PhotoStudio)", () => { describe("the Photo Studio (PhotoStudio)", () => {
/** The photographer stands at (2, 3). */ /** The photographer stands at (2, 3). */
const photo = (game: GameWorld, answers: number[]) => const photo = (game: GameWorld, answers: (number | string)[]) =>
talk(game, PHOTO_STUDIO, 2, 4, "up", answers); talk(game, PHOTO_STUDIO, 2, 4, "up", answers);
it("finds no printer and takes no photo", () => { it("finds no printer and takes no photo", () => {
const { game } = world(); const { game } = world();
game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })]; game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })];
const shown = photo(game, [1, 0]); const shown = photo(game, [1, 0, "b"]);
expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?"); expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?");
expect(shown).toContain("Hold\nstill for a bit."); expect(shown).toContain("Hold\nstill for a bit.");
// PrintPartymon's handshake reads $ff: PRINTER_ERROR_2. // PrintPartymon's handshake reads $ff: PRINTER_ERROR_2.
expect(shown).toContain( expect(shown).toContain(
" Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\n\nPress B to Cancel", " Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\nPress B to Cancel",
); );
expect(shown).toContain("Oh, no picture?\nCome again, OK?"); expect(shown).toContain("Oh, no picture?\nCome again, OK?");
expect(shown).not.toContain("Presto"); expect(shown).not.toContain("Presto");
+8
View File
@@ -456,6 +456,14 @@ describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
until(world); until(world);
pick(world, "FAVS"); pick(world, "FAVS");
expect(pick(world, "PRINT")).toContain("There's no POKéMON."); expect(pick(world, "PRINT")).toContain("There's no POKéMON.");
pick(world, "BOX2");
pick(world, "PRINT");
const printer = world.dialogue?.choice;
expect(printer?.kind === "screen" && printer.screen.label).toContain("Printer Error 2");
world.choose("a");
expect(world.dialogue?.choice).toEqual(printer);
world.choose("b");
until(world);
close(world); close(world);
expect(world.state.storage.current).toBe(1); expect(world.state.storage.current).toBe(1);
expect(world.state.storage.names[2]).toBe("FAVS"); expect(world.state.storage.names[2]).toBe("FAVS");
+4
View File
@@ -191,6 +191,10 @@ export class Playthrough {
const page = dialogue.pages[dialogue.index]; const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1; const last = dialogue.index === dialogue.pages.length - 1;
if (log.at(-1) !== page) log.push(page); if (log.at(-1) !== page) log.push(page);
if (last && dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
world.tick();
continue;
}
if (last && dialogue.choice) { if (last && dialogue.choice) {
const answer = answers.shift(); const answer = answers.shift();
if (dialogue.choice.kind === "name") if (dialogue.choice.kind === "name")
+7 -1
View File
@@ -289,10 +289,16 @@ describe.skipIf(!existsSync(romPath))("the Pokédex screen", () => {
expect(view(world).entry!.text).toBe(world.assets.pokedex.entry(CHIKORITA).pages[1]); expect(view(world).entry!.text).toBe(world.assets.pokedex.entry(CHIKORITA).pages[1]);
press(world, "a"); press(world, "a");
expect(view(world).entry).toMatchObject({ page: 1 }); expect(view(world).entry).toMatchObject({ page: 1 });
// The arrow stops at PRNT and at PAGE; AREA, CRY and PRNT do nothing. // The arrow stops at PRNT and PAGE; PRNT waits for B on printer error.
for (let i = 0; i < 5; i++) press(world, "right"); for (let i = 0; i < 5; i++) press(world, "right");
expect(view(world).entry).toMatchObject({ action: 3 }); expect(view(world).entry).toMatchObject({ action: 3 });
press(world, "a"); press(world, "a");
expect(world.dialogue?.choice?.kind).toBe("screen");
const printChoice = world.dialogue!.choice!;
if (printChoice.kind === "screen") expect(printChoice.screen.label).toContain("Printer Error 2");
world.choose("a");
expect(world.dialogue?.choice?.kind).toBe("screen");
world.choose("b");
expect(view(world).entry).toMatchObject({ action: 3, page: 1 }); expect(view(world).entry).toMatchObject({ action: 3, page: 1 });
press(world, "left"); press(world, "left");
expect(view(world).entry).toMatchObject({ action: 2 }); expect(view(world).entry).toMatchObject({ action: 2 });
+816
View File
@@ -0,0 +1,816 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import {
breedLevelGrowth,
breedingCompatibility,
dayCareStep,
emptyDayCare,
initBreeding,
oddEgg,
retrievedMon,
} from "../src/game/party/day-care";
import { ENGINE_FLAGS, JOHTO } from "../src/game/rom/profile";
import type { BoxMon } from "../src/game/party/storage";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
/** The original Day-Care and breeding routines, executed in the reference
* emulator on random inputs, against the native ones. Only Random is
* replaced (a stub that replays fixed bytes, like reference-smart-ai), so
* every other instruction, table and bank of the cartridge runs. */
const DITTO = 132;
const BOX_STRUCT = 32,
PARTY_STRUCT = 48,
NAME = 11;
/** What one run of a routine in the emulator is asked and returns. */
interface Job {
routine: { bank: number; address: number };
random: { address: number; bytesPerCall: 1 | 2 };
stream: number[];
hROMBank: number;
hRandomAdd: number;
hRandomSub: number;
/** Registers b, d and e after the call are saved at $c0f1 to $c0f3. */
registers?: boolean;
/** Memory to set before each case (once, shared). */
globals: [number, number][];
cases: { writes: [number, number][]; reads: [number, number][] }[];
}
interface Run {
returned: boolean;
draws: number;
registers: number[];
reads: number[][];
}
async function runReference(page: Page, job: Job): Promise<Run[]> {
return page.evaluate(async (job) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const file = (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0];
const rom = new Uint8Array(await file.arrayBuffer());
// Random becomes JP $c080, to a stub that reads the stream at $c100
// with the counter at $c0e0: hRandomSub (the returned byte) and
// hRandomAdd come from one byte, or from two when asked.
rom.set([0xc3, 0x80, 0xc0], job.random.address);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (address: number, value: number) =>
reference.writeMemory(address, value & 0xff);
const stub = [
0xe5, // push hl
0xd5, // push de
0xfa,
0xe0,
0xc0, // ld a, [$c0e0]
0x5f, // ld e, a
0x16,
0x00, // ld d, 0
0x21,
0x00,
0xc1, // ld hl, $c100
0x19, // add hl, de
0xc6,
job.random.bytesPerCall, // add a, n
0xea,
0xe0,
0xc0, // ld [$c0e0], a
0x2a, // ld a, [hli]
0xe0,
job.hRandomSub & 0xff, // ldh [hRandomSub], a
...(job.random.bytesPerCall === 2
? [0x7e, 0xe0, job.hRandomAdd & 0xff, 0xf0, job.hRandomSub & 0xff]
: [0xe0, job.hRandomAdd & 0xff]),
0xd1, // pop de
0xe1, // pop hl
0xc9, // ret
];
stub.forEach((value, index) => write(0xc080 + index, value));
const { bank, address } = job.routine;
// DI; SP = $c7fe; select the bank; CALL routine; [save b, d, e];
// sentinel; loop.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
bank,
0xe0,
job.hROMBank & 0xff,
0xea,
0x00,
0x20,
0xcd,
address & 0xff,
address >> 8,
...(job.registers
? [
0x78, 0xea, 0xf1, 0xc0, 0x7a, 0xea, 0xf2, 0xc0, 0x7b, 0xea, 0xf3,
0xc0,
]
: []),
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xff70, 1);
for (const [address, value] of job.globals) write(address, value);
const runs: Run[] = [];
for (const input of job.cases) {
write(0xc0e0, 0);
write(0xc0f0, 0);
job.stream.forEach((value, index) => write(0xc100 + index, value));
for (const [address, value] of input.writes) write(address, value);
program.forEach((value, index) => write(0xc000 + index, value));
reference.setProgramCounter(0xc000);
// CalcLevel through level 100 needs more than 20 frames.
for (
let frame = 0;
frame < 120 && reference.readMemory(0xc0f0) !== 0x42;
frame++
)
reference.advanceFrames(1);
runs.push({
returned: reference.readMemory(0xc0f0) === 0x42,
draws: reference.readMemory(0xc0e0),
registers: [0xc0f1, 0xc0f2, 0xc0f3].map((address) =>
reference.readMemory(address),
),
reads: input.reads.map(([address, length]) =>
Array.from({ length }, (_, i) => reference.readMemory(address + i)),
),
});
}
reference.dispose();
return runs;
}, job);
}
test.describe("Day-Care routines against the cartridge", () => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Needs the local ROM and the private PRET rebuild.",
);
test.setTimeout(240_000);
const symbols = existsSync(symbolsPath) ? loadSymbols() : undefined;
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const data = () => assets!.pokemon;
const at = (name: string) => symbols!.at(name);
const routine = (name: string) => symbols!.routine(name);
const job = (
name: string,
cases: Job["cases"],
stream: number[],
extra: Partial<Job> = {},
): Job => ({
routine: routine(name),
random: { address: routine("Random").address, bytesPerCall: 1 },
stream,
hROMBank: at("hROMBank"),
hRandomAdd: at("hRandomAdd"),
hRandomSub: at("hRandomSub"),
globals: [],
cases,
...extra,
});
// A small deterministic generator for the cases.
let seed = 0x5eed;
const next = (limit = 256) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return (seed >>> 8) % limit;
};
const pick = <T>(items: readonly T[]) => items[next(items.length)];
const word = (value: number): [number, number] => [
(value >> 8) & 0xff,
value & 0xff,
];
const set = (address: number, bytes: readonly number[]): [number, number][] =>
bytes.map((value, index) => [address + index, value]);
/** A box_struct as it lies in memory. */
const boxBytes = (mon: BoxMon) => [
mon.species,
mon.item,
...mon.moves,
...word(mon.otId),
(mon.exp >> 16) & 0xff,
...word(mon.exp),
...mon.statExp.flatMap(word),
...word(mon.dvs),
...mon.pp,
mon.happiness,
mon.pokerus,
...word(mon.caughtData),
mon.level,
];
/** The charmap's uppercase letters, ending at the terminator. */
const name = (text: string, length = NAME) =>
[...text.split("").map((letter) => 0x80 + letter.charCodeAt(0) - 65), 0x50]
.concat(Array(length).fill(0x50))
.slice(0, length);
const decode = (bytes: number[]) => {
let text = "";
for (const byte of bytes) {
if (byte === 0x50) break;
text +=
byte >= 0x80 && byte < 0x9a
? String.fromCharCode(65 + byte - 0x80)
: "?";
}
return text;
};
const allSpecies = Array.from({ length: 251 }, (_, i) => i + 1);
const breedable = () =>
allSpecies.filter((s) => data().baseData(s).eggGroups !== 0xff);
const groupsOf = (species: number) => {
const groups = data().baseData(species).eggGroups;
return [groups & 15, groups >> 4];
};
/** A random pair that shares an egg group (or has a Ditto or a repeat). */
const pairOfSpecies = (): [number, number] => {
const first = pick(breedable());
const choice = next(8);
if (choice === 0) return [first, first];
if (choice === 1) return [first, DITTO];
if (choice === 2) return [DITTO, first];
const partners = breedable().filter((candidate) =>
groupsOf(candidate).some((group) => groupsOf(first).includes(group)),
);
return [first, pick(partners)];
};
const tmhm = Array.from({ length: 57 }, (_, i) => i).flatMap((i) => {
const move = data().reader.byte(
JOHTO.EggMovePointers - JOHTO.EggMovePointers + 0x1167a + i,
);
return move ? [move] : [];
});
/** Moves that may be inherited, to make the inheritance rules matter. */
const movePool = (baby: number) => [
...data().eggMoves(baby),
...data()
.learnset(baby)
.map((entry) => entry.move),
...tmhm,
...Array.from({ length: 6 }, () => 1 + next(251)),
];
const randomMoves = (pool: number[]): BoxMon["moves"] => {
const count = next(5);
const moves = Array.from({ length: count }, () => pick(pool));
const unique = moves.filter((move, i) => moves.indexOf(move) === i);
return [...unique, 0, 0, 0, 0].slice(0, 4) as BoxMon["moves"];
};
const stored = (
species: number,
moves: BoxMon["moves"],
extra: Partial<BoxMon> = {},
): BoxMon => {
const growth = data().baseData(species).growthRate;
const level = 2 + next(60);
return {
species,
item: 0,
moves,
otId: next(65536),
ot: "KAWA",
nickname: "MON",
exp: data().expAtLevel(growth, level) + next(300),
statExp: [0, 1, 2, 3, 4].map(() => next(65536)) as BoxMon["statExp"],
dvs: next(65536),
pp: moves.map((move) =>
move ? (next(4) << 6) | (1 + next(40)) : 0,
) as BoxMon["pp"],
happiness: next(256),
pokerus: next(256),
caughtData: next(65536),
level,
...extra,
};
};
test("CheckBreedmonCompatibility", async ({ page }) => {
const wBreedMon1 = at("wBreedMon1"),
wBreedMon2 = at("wBreedMon2"),
result = at("wBreedingCompatibility");
const pairs = Array.from({ length: 1500 }, () => {
const [first, second] =
next(5) === 0
? ([pick(allSpecies), pick(allSpecies)] as [number, number])
: pairOfSpecies();
const mon1 = stored(first, [0, 0, 0, 0]),
mon2 = stored(second, [0, 0, 0, 0]);
// Equal IDs, Defense DVs and Special DVs make the special bands matter.
if (next(3) === 0) mon2.otId = mon1.otId;
if (next(4) === 0) mon2.dvs = (mon2.dvs & 0xf0f8) | (mon1.dvs & 0x0f07);
if (next(8) === 0) mon2.dvs = (mon2.dvs & 0x0fff) | (mon1.dvs & 0xf000);
return { mon1, mon2 };
});
await openReference(page);
const runs = await runReference(
page,
job(
"CheckBreedmonCompatibility",
pairs.map(({ mon1, mon2 }) => ({
writes: [
...set(wBreedMon1, boxBytes(mon1)),
...set(wBreedMon2, boxBytes(mon2)),
],
reads: [[result, 1]],
})),
[],
),
);
const seen = new Set<number>();
pairs.forEach(({ mon1, mon2 }, index) => {
expect(runs[index].returned, `pair ${index} returned`).toBe(true);
const native = breedingCompatibility(data(), mon1, mon2);
expect(native, `pair ${index}: ${mon1.species}/${mon2.species}`).toBe(
runs[index].reads[0][0],
);
seen.add(native);
});
// 0, 255, 254, 177, 128 and 51 all occur.
expect([...seen].sort((a, b) => a - b)).toEqual([
0, 51, 128, 177, 254, 255,
]);
});
test("GetBreedMon1LevelGrowth", async ({ page }) => {
const wBreedMon1 = at("wBreedMon1");
const cases = Array.from({ length: 600 }, () => {
const species = pick(allSpecies);
const mon = stored(species, [0, 0, 0, 0]);
const growth = data().baseData(species).growthRate;
const extra =
next(4) === 0 ? 0 : next(3) === 0 ? 1 << 20 : next(100) * 2000;
mon.exp = Math.min(
0xffffff,
data().expAtLevel(growth, 1 + next(100)) + extra - next(3),
);
mon.exp = Math.max(0, mon.exp);
return mon;
});
await openReference(page);
const runs = await runReference(
page,
job(
"GetBreedMon1LevelGrowth",
cases.map((mon) => ({
writes: set(wBreedMon1, boxBytes(mon)),
reads: [],
})),
[],
{ registers: true },
),
);
cases.forEach((mon, index) => {
expect(
runs[index].returned,
`case ${index}: ${JSON.stringify(mon)}`,
).toBe(true);
const { oldLevel, level, growth } = breedLevelGrowth(data(), mon);
expect({ oldLevel, growth, level }, `case ${index}`).toEqual({
oldLevel: runs[index].registers[0],
growth: runs[index].registers[1],
level: runs[index].registers[2],
});
});
});
test("DayCare_InitBreeding: the egg, its species, DVs and inherited moves", async ({
page,
}) => {
const sym = {
man: at("wDayCareMan"),
lady: at("wDayCareLady"),
mon1: at("wBreedMon1"),
mon2: at("wBreedMon2"),
steps: at("wStepsToEgg"),
mother: at("wBreedMotherOrNonDitto"),
egg: at("wEggMon"),
eggNickname: at("wEggMonNickname"),
eggOT: at("wEggMonOT"),
playerId: at("wPlayerID"),
playerName: at("wPlayerName"),
};
const player = { id: 0, name: "KAWA" };
const cases = Array.from({ length: 900 }, (_, index) => {
let [first, second] = pairOfSpecies();
// Nidoran♀ and the baby forms, with the partners that matter.
if (index % 9 === 0) [first, second] = [29, pick([32, 33, 34, DITTO])];
if (index % 9 === 1) [first, second] = [pick([32, 33, 34, DITTO]), 29];
if (index % 9 === 2)
[first, second] = [pick([25, 26, 82, 129, 130]), DITTO];
const baby = data().preEvolution(
data().preEvolution(next(2) ? first : second),
);
const pool = movePool(baby);
const mon1 = stored(first, randomMoves(pool)),
mon2 = stored(second, randomMoves(pool));
if (next(3) === 0) mon2.otId = mon1.otId;
if (next(10) === 0) mon2.dvs = (mon2.dvs & 0xf0f8) | (mon1.dvs & 0x0f07);
const stream = Array.from({ length: 80 }, () => next(256));
return {
mon1,
mon2,
stream,
id: next(65536),
prior: { steps: next(256), mother: next(2), man: next(2) << 5 },
};
});
await openReference(page);
// One job per stream: the stream is part of the job. Batched by case.
for (let start = 0; start < cases.length; start += 60) {
const batch = cases.slice(start, start + 60);
const runs: Run[] = [];
for (const input of batch) {
const [run] = await runReference(
page,
job(
"DayCare_InitBreeding",
[
{
writes: [
...set(sym.man, [1 | input.prior.man]),
...set(sym.lady, [1]),
...set(sym.mon1, boxBytes(input.mon1)),
...set(sym.mon2, boxBytes(input.mon2)),
...set(sym.steps, [input.prior.steps]),
...set(sym.mother, [input.prior.mother]),
...set(sym.playerId, word(input.id)),
...set(sym.playerName, name(player.name)),
// Leftovers the routine must overwrite.
...set(sym.egg, Array(BOX_STRUCT).fill(0xaa)),
],
reads: [
[sym.man, 1],
[sym.steps, 1],
[sym.mother, 1],
[sym.egg, BOX_STRUCT],
[sym.eggNickname, NAME],
[sym.eggOT, NAME],
],
},
],
input.stream,
),
);
runs.push(run);
}
batch.forEach((input, offset) => {
const label = `case ${start + offset}: ${input.mon1.species}/${input.mon2.species}`;
const run = runs[offset];
expect(run.returned, label).toBe(true);
const used = [...input.stream];
let draws = 0;
const host = {
flags: new Set([
ENGINE_FLAGS.dayCareManHasMon,
ENGINE_FLAGS.dayCareLadyHasMon,
]),
dayCare: {
...emptyDayCare(),
mon1: structuredClone(input.mon1),
mon2: structuredClone(input.mon2),
stepsToEgg: input.prior.steps,
motherOrNonDitto: input.prior.mother,
compatible: Boolean(input.prior.man),
},
};
initBreeding(
data(),
host,
{ id: input.id, name: player.name },
() => used[draws++],
);
const [man, steps, mother, egg, eggNickname, eggOT] = run.reads;
expect(draws, `${label}: draws`).toBe(run.draws);
expect(Boolean(man[0] & 0x20), `${label}: compatible`).toBe(
host.dayCare.compatible,
);
expect(steps[0], `${label}: steps`).toBe(host.dayCare.stepsToEgg);
expect(mother[0], `${label}: mother`).toBe(
host.dayCare.motherOrNonDitto,
);
if (host.dayCare.egg) {
expect(egg, `${label}: egg`).toEqual(boxBytes(host.dayCare.egg));
expect(decode(eggNickname), label).toBe(host.dayCare.egg.nickname);
expect(decode(eggOT), label).toBe(host.dayCare.egg.ot);
} else {
// Incompatible: the routine returned before touching the egg.
expect(egg, `${label}: egg untouched`).toEqual(
Array(BOX_STRUCT).fill(0xaa),
);
}
});
}
});
test("DayCareStep: experience, the step count and the egg chance", async ({
page,
}) => {
const sym = {
man: at("wDayCareMan"),
lady: at("wDayCareLady"),
mon1: at("wBreedMon1"),
mon2: at("wBreedMon2"),
steps: at("wStepsToEgg"),
};
const cases = Array.from({ length: 1200 }, () => {
const [first, second] = pairOfSpecies();
const mon1 = stored(first, [0, 0, 0, 0]),
mon2 = stored(second, [0, 0, 0, 0]);
if (next(3) === 0) mon2.otId = mon1.otId;
if (next(8) === 0) mon2.dvs = (mon2.dvs & 0xf0f8) | (mon1.dvs & 0x0f07);
// Experience on a byte boundary, levels at the cap.
for (const mon of [mon1, mon2]) {
if (next(3) === 0) mon.exp = (mon.exp & 0xffff00) | 0xff;
if (next(6) === 0) mon.exp = (mon.exp & 0xff0000) | 0xffff;
if (next(8) === 0) mon.exp = (0x4f << 16) | 0xffff;
if (next(8) === 0) mon.level = 100;
if (next(10) === 0) mon.level = 99;
}
const hasMon1 = next(5) !== 0,
hasMon2 = next(5) !== 0;
return {
mon1,
mon2,
hasMon1,
hasMon2,
compatible: next(4) !== 0,
steps: next(3) === 0 ? 1 : next(256),
stream: Array.from({ length: 4 }, () => next(256)),
};
});
await openReference(page);
for (const [index, input] of cases.entries()) {
const [run] = await runReference(
page,
job(
"DayCareStep",
[
{
writes: [
...set(sym.man, [
(input.hasMon1 ? 1 : 0) | (input.compatible ? 0x20 : 0),
]),
...set(sym.lady, [input.hasMon2 ? 1 : 0]),
...set(sym.mon1, boxBytes(input.mon1)),
...set(sym.mon2, boxBytes(input.mon2)),
...set(sym.steps, [input.steps]),
],
reads: [
[sym.man, 1],
[sym.steps, 1],
[sym.mon1 + 8, 3],
[sym.mon2 + 8, 3],
],
},
],
input.stream,
),
);
expect(run.returned, `case ${index}`).toBe(true);
const used = [...input.stream];
let draws = 0;
const flags = new Set<number>();
if (input.hasMon1) flags.add(ENGINE_FLAGS.dayCareManHasMon);
if (input.hasMon2) flags.add(ENGINE_FLAGS.dayCareLadyHasMon);
const dayCare = {
...emptyDayCare(),
mon1: structuredClone(input.mon1),
mon2: structuredClone(input.mon2),
stepsToEgg: input.steps,
compatible: input.compatible,
};
dayCareStep(data(), { flags, dayCare }, () => used[draws++]);
const [man, steps, exp1, exp2] = run.reads;
const label = `case ${index}: ${JSON.stringify({ ...input, stream: input.stream })}`;
expect(exp1, label).toEqual([
(dayCare.mon1.exp >> 16) & 0xff,
(dayCare.mon1.exp >> 8) & 0xff,
dayCare.mon1.exp & 0xff,
]);
expect(exp2, label).toEqual([
(dayCare.mon2.exp >> 16) & 0xff,
(dayCare.mon2.exp >> 8) & 0xff,
dayCare.mon2.exp & 0xff,
]);
expect(steps[0], label).toBe(dayCare.stepsToEgg);
expect(
{ compatible: Boolean(man[0] & 0x20), egg: Boolean(man[0] & 0x40) },
label,
).toEqual({
compatible: dayCare.compatible,
egg: flags.has(ENGINE_FLAGS.dayCareManHasEgg),
});
expect(draws, label).toBe(run.draws);
}
});
test("RetrieveBreedmon: stats, learned moves, PP, HP and experience", async ({
page,
}) => {
const sym = {
count: at("wPartyCount"),
species: at("wPartySpecies"),
party: at("wPartyMon1"),
nicknames: at("wPartyMonNicknames"),
ots: at("wPartyMonOTs"),
mon1: at("wBreedMon1"),
nickname1: at("wBreedMon1Nickname"),
ot1: at("wBreedMon1OT"),
parameter: at("wPokemonWithdrawDepositParameter"),
level: at("wCurPartyLevel"),
previous: at("wPrevPartyLevel"),
curSpecies: at("wCurPartySpecies"),
skip: at("wSkipMovesBeforeLevelUp"),
};
const cases = Array.from({ length: 700 }, () => {
const species = pick(allSpecies);
const learnLevels = data()
.learnset(species)
.map((entry) => entry.level);
const moves = randomMoves(movePool(species));
const mon = stored(species, moves);
// Level up across learnset entries about half the time.
const oldLevel = next(2)
? 1 + next(40)
: Math.max(1, (pick(learnLevels) ?? 10) - next(3));
const newLevel = Math.min(100, oldLevel + (next(4) === 0 ? 0 : next(30)));
mon.level = oldLevel;
mon.exp =
data().expAtLevel(data().baseData(species).growthRate, newLevel) +
next(500);
return { mon, oldLevel, newLevel, count: next(6) };
});
await openReference(page);
for (const [index, input] of cases.entries()) {
const slot = input.count;
const [run] = await runReference(
page,
job(
"RetrieveBreedmon",
[
{
writes: [
...set(sym.count, [slot]),
...set(sym.mon1, boxBytes(input.mon)),
...set(sym.nickname1, name("NICKNAME")),
...set(sym.ot1, name("OTNAME")),
...set(sym.parameter, [0]),
...set(sym.level, [input.newLevel]),
...set(sym.previous, [input.oldLevel]),
...set(sym.curSpecies, [input.mon.species]),
// Leftovers in the slot, as an earlier occupant leaves.
...set(
sym.party + slot * PARTY_STRUCT,
Array(PARTY_STRUCT).fill(0x5a),
),
],
reads: [
[sym.count, 1],
[sym.species + slot, 2],
[sym.party + slot * PARTY_STRUCT, PARTY_STRUCT],
[sym.nicknames + slot * NAME, NAME],
[sym.ots + slot * NAME, NAME],
],
},
],
[],
),
);
const label = `case ${index}: ${JSON.stringify(input)}`;
expect(run.returned, label).toBe(true);
const mon = retrievedMon(
data(),
{ ...input.mon, nickname: "NICKNAME", ot: "OTNAME" },
input.oldLevel,
input.newLevel,
);
const [count, species, struct, nickname, ot] = run.reads;
expect(count[0], label).toBe(slot + 1);
expect(species, label).toEqual([input.mon.species, 0xff]);
expect(decode(nickname), label).toBe("NICKNAME");
expect(decode(ot), label).toBe("OTNAME");
const word16 = (value: number) => word(value);
const expected = [
...boxBytes(mon),
mon.status,
0,
...word16(mon.hp),
...mon.stats.flatMap(word16),
];
// The unused byte after the status is whatever the slot held; the
// routine does not clear it (HealPartyMon writes it as 0).
expect(struct, label).toEqual(expected);
}
});
test("_GiveOddEgg: the egg chosen by the random word and its party entry", async ({
page,
}) => {
const sym = {
count: at("wPartyCount"),
species: at("wPartySpecies"),
party: at("wPartyMon1"),
nicknames: at("wPartyMonNicknames"),
ots: at("wPartyMonOTs"),
};
// Each OddEggProbabilities entry, and the words either side of it.
const reader = data().reader;
const thresholds = Array.from({ length: 14 }, (_, i) => {
const entry = JOHTO.OddEggProbabilities + i * 2;
return reader.byte(entry) | (reader.byte(entry + 1) << 8);
});
const words = [
...thresholds.flatMap((value) => [value - 1, value, value + 1]),
0,
0xffff,
...Array.from({ length: 300 }, () => next(65536)),
]
.filter((value) => value >= 0 && value <= 0xffff)
.map((value) => [value >> 8, value & 0xff]);
await openReference(page);
const seenSpecies = new Set<number>();
// The map is reached with a nonempty party. AddMobileMonToParty
// assumes that: its decrement loop underflows for an empty party.
for (const [index, [sub, add]] of words.entries()) {
const [run] = await runReference(
page,
job(
"_GiveOddEgg",
[
{
writes: set(sym.count, [1 + (index % 4)]),
reads: [
[sym.count, 1],
[sym.species, 6],
[sym.party + (1 + (index % 4)) * PARTY_STRUCT, PARTY_STRUCT],
[sym.nicknames + (1 + (index % 4)) * NAME, NAME],
[sym.ots + (1 + (index % 4)) * NAME, NAME],
],
},
],
[sub, add],
{ random: { address: routine("Random").address, bytesPerCall: 2 } },
),
);
const label = `word ${sub.toString(16)}${add.toString(16)}`;
expect(run.returned, label).toBe(true);
expect(run.draws, label).toBe(2);
const slot = 1 + (index % 4);
const [count, species, struct, nickname, ot] = run.reads;
const used = [sub, add];
let draws = 0;
const egg = oddEgg(data(), () => used[draws++]);
seenSpecies.add(egg.species);
expect(draws, label).toBe(2);
expect(count[0], label).toBe(slot + 1);
expect(species[slot], label).toBe(0xfd);
const expected = [
egg.species,
egg.item,
...egg.moves,
...word(egg.otId),
(egg.exp >> 16) & 0xff,
...word(egg.exp),
...egg.statExp.flatMap(word),
...word(egg.dvs),
...egg.pp,
egg.happiness,
egg.pokerus,
...word(egg.caughtData),
egg.level,
egg.status,
0,
...word(egg.hp),
...egg.stats.flatMap(word),
];
expect(struct, label).toEqual(expected);
expect(decode(nickname), label).toBe(egg.nickname);
expect(decode(ot), label).toBe(egg.ot);
}
// Pichu, Cleffa, Igglybuff, Smoochum, Magby, Elekid and Tyrogue.
expect([...seenSpecies].sort((a, b) => a - b)).toEqual([
172, 173, 174, 236, 238, 239, 240,
]);
});
});
+233
View File
@@ -0,0 +1,233 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Needs private ROM/reference.",
);
test("Hall of Fame PC reads SRAM and matches the resting tilemap, picture and palette", async ({
page,
}) => {
const { at, routine } = loadSymbols();
const flat = (name: string) => {
const { bank, address } = routine(name);
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
};
const addresses = {
romBank: at("hROMBank"),
cgb: at("hCGB"),
joy: at("hJoyLast"),
tilemap: at("wTilemap"),
attrs: at("wAttrmap"),
palettes: at("wBGPals1"),
frame: at("wTextboxFrame"),
hof: at("sHallOfFame"),
};
const pc = routine("_HallOfFamePC");
const font = routine("_LoadStandardFont");
// Only bypass frame waits and frontpic animation: the native viewer
// presents the resting image. All SRAM decoding, layout, text, gender,
// frontpic decompression and palette selection execute cartridge code.
const skip = [
"WaitBGMap",
"DelayFrame",
"HOF_AnimateFrontpic",
"JoyTextDelay",
].map(flat);
await openReference(page);
const result = await page.evaluate(
async ({ addresses: a, pc, font, skip }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { hallOfFameScreen } =
await import("/src/game/scripts/hall-of-fame.ts");
const { textTiles } = await import("/src/game/rom/font.ts");
const { rgb } = await import("/src/game/scripts/screen.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
for (const p of skip) rom[p] = 0xc9;
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, n: number) => reference.writeMemory(p, n & 255);
const read = (p: number) => reference.readMemory(p);
const cases = [
{
species: 25,
dvs: 0xaaaa,
level: 42,
nickname: "SPARK",
otId: 12345,
win: 1,
},
{
species: 201,
dvs: 0xffff,
level: 100,
nickname: "UNOWN",
otId: 0,
win: 200,
},
{
species: 29,
dvs: 0,
level: 5,
nickname: "NIDORAN♀",
otId: 65535,
win: 99,
},
{
species: 157,
dvs: 0xffff,
level: 99,
nickname: "FIRE",
otId: 42,
win: 100,
},
...Array.from({ length: 251 }, (_, i) => ({
species: i + 1,
dvs: i % 2 ? 0x2aaa : 0xffff,
level: 1 + (i % 100),
nickname: "RECORDED",
otId: i * 251,
win: 1 + (i % 200),
})),
];
const failures: unknown[] = [];
for (const [index, input] of cases.entries()) {
const { win, ...mon } = input;
const team = { win, mons: [mon] };
// SRAM bank 1: one team, a terminator after its first Pokémon, and
// the absent next team. The PC must load this itself.
write(0, 0x0a);
write(0x4000, 1);
for (let i = 0; i < 196; i++) write(a.hof + i, 0);
const record = [
win,
mon.species,
mon.otId >> 8,
mon.otId & 255,
mon.dvs >> 8,
mon.dvs & 255,
mon.level,
...textTiles(mon.nickname),
...Array(10 - mon.nickname.length).fill(0x50),
0xff,
];
record.forEach((value, offset) => write(a.hof + offset, value));
write(0, 0);
write(0xff70, 1);
write(0xff40, 0);
write(a.cgb, 1);
write(a.frame, index % 8);
write(a.joy, 2); // B after the first page.
// JoyTextDelay is stubbed to preserve the fixed B input above.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
font.bank,
0xe0,
a.romBank & 255,
0xea,
0,
0x20,
0xcd,
font.address & 255,
font.address >> 8,
0x3e,
pc.bank,
0xe0,
a.romBank & 255,
0xea,
0,
0x20,
0xcd,
pc.address & 255,
pc.address >> 8,
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xc0f0, 0);
program.forEach((value, offset) => write(0xc000 + offset, value));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 100 && read(0xc0f0) !== 0x42; frame++)
reference.advanceFrames(1);
const native = hallOfFameScreen(assets.pokemon, team, 0, index % 8);
const original = Array.from({ length: 360 }, (_, i) =>
read(a.tilemap + i),
);
const mismatches = original.flatMap((tile, i) =>
tile === native.map[i]
? []
: [{ i, original: tile, native: native.map[i] }],
);
const attrDiff = Array.from({ length: 360 }, (_, i) =>
read(a.attrs + i),
).filter((v, i) => v !== native.attrs[i]).length;
// CGB palette work RAM is bank 5; graphics at $9000 are VRAM bank 0.
write(0xff70, 5);
const palette = Array.from({ length: 4 }, (_, i) =>
rgb(read(a.palettes + i * 2) | (read(a.palettes + i * 2 + 1) << 8)),
);
write(0xff70, 1);
write(0xff4f, 0);
const picture = Array.from({ length: 49 * 16 }, (_, i) =>
read(0x9000 + i),
);
const picDiff = picture.filter((v, i) => v !== native.tiles[i]).length;
const tileDiff = [...new Set(native.map)]
.flatMap((tile) =>
Array.from({ length: 16 }, (_, byte) => ({
tile,
byte,
expected: native.tiles[tile * 16 + byte],
actual: read(
(tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte,
),
})),
)
.filter(({ expected, actual }) => expected !== actual);
if (
read(0xc0f0) !== 0x42 ||
mismatches.length ||
attrDiff ||
picDiff ||
tileDiff.length ||
JSON.stringify(palette) !== JSON.stringify(native.palettes[0])
)
failures.push({
input,
returned: read(0xc0f0) === 0x42,
mismatches: mismatches.slice(0, 12),
attrDiff,
picDiff,
tileDiff: tileDiff.slice(0, 5),
palette,
nativePalette: native.palettes[0],
});
}
reference.dispose();
return { cases: cases.length, failures };
},
{ addresses, pc, font, skip },
);
expect(result.failures, JSON.stringify(result)).toEqual([]);
});
+346
View File
@@ -0,0 +1,346 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Needs private ROM and reference symbols.",
);
for (const [species, dvs] of [
[175, 0x8888],
[172, 0x2aaa],
])
test(`hatch ${species}/${dvs} wobble and shell frames match original OAM, tilemaps and palettes`, async ({
page,
}) => {
test.setTimeout(240_000);
const { at, routine } = loadSymbols();
const names = [
"wNamedObjectIndex",
"wCurSpecies",
"wCurPartySpecies",
"wCurPartyMon",
"wPartyMon1DVs",
"wBoxAlignment",
"hSCX",
"wGlobalAnimXOffset",
"wFrameCounter",
"wShadowOAM",
"wBGPals1",
"wBGPals2",
"wOBPals1",
"wOBPals2",
"hCGB",
"hBGMapMode",
"hVBlank",
"hROMBank",
"wPartyCount",
"wPartySpecies",
"wTimeOfDay",
"wPlayerHPPal",
"wTilemap",
"wAttrmap",
"wSpriteAnimationStructs",
"wSpriteAnimCount",
];
const a: Record<string, number> = {};
for (const n of names) a[n] = at(n);
a.DelayFrame = routine("DelayFrame").address;
const seq = routine("EggHatch_AnimationSequence");
await openReference(page);
const result = await page.evaluate(
async ({ a, seq, species, dvs }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
// Count DelayFrame calls: its first five bytes (ld a, 1; ld [wVBlankOccurred], a)
// jump to a hook in the unused end of bank 0 that bumps a counter at $c6f1.
const expected = [0x3e, 0x01, 0xea, 0xb3, 0xcf, 0x76];
expected.forEach((v, i) => {
if (rom[0x45a + i] !== v)
throw Error("DelayFrame is not where expected");
});
[0xc3, 0xc0, 0x3f, 0x00, 0x00].forEach((v, i) => (rom[0x45a + i] = v));
[
0xfa, 0xf1, 0xc6, 0x3c, 0xea, 0xf1, 0xc6, 0x3e, 0x01, 0xea, 0xb3,
0xcf, 0xc3, 0x5f, 0x04,
].forEach((v, i) => (rom[0x3fc0 + i] = v));
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { hatchAnimation, shellSprites, hatchScreen } =
await import("/src/game/scripts/hatch-animation.ts");
const assets = new CrystalAssets(rom);
const { SYMBOLS } = await import("/src/game/rom/profile.ts");
const animation = hatchAnimation(assets.pokemon, species, dvs);
const native: {
map: number[];
attrs: number[];
oam: number[];
scx: number;
palette: number[][];
}[] = [];
const oam = (
sprites: {
x: number;
y: number;
tile: number;
flipX?: boolean;
flipY?: boolean;
palette: number;
}[],
) => {
const bytes = Array<number>(160).fill(0);
sprites.forEach((s, i) =>
bytes.splice(
i * 4,
4,
(s.y + 16) & 255,
(s.x + 8) & 255,
s.tile - 256,
s.palette | (s.flipX ? 32 : 0) | (s.flipY ? 64 : 0),
),
);
return bytes;
};
for (const effect of animation)
if (effect.kind === "screen")
native.push({
map: [...effect.screen.map],
attrs: [...effect.screen.attrs],
oam: oam(effect.screen.sprites),
scx: (effect.screen.scroll?.x ?? 0) & 255,
palette: effect.screen.palettes[0],
});
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) =>
reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
// Boot to the intro so the game's Init has run.
reference.advanceFrames(240);
// _CGB_Evolution keeps OBJ palettes 0/1 from the overworld.
// Seed daylight palettes exactly as LoadMapPals would.
write(0xff70, 5);
for (let i = 0; i < 16; i++)
write(
0xd040 + i,
assets.reader.byte(SYMBOLS.objectPalettes + 64 + i),
);
write(0xff70, 1);
write(a.hVBlank, 0);
write(a.wNamedObjectIndex, species);
write(a.wCurSpecies, species);
write(a.wCurPartySpecies, species);
write(a.wCurPartyMon, 0);
write(a.wPartyCount, 1);
write(a.wPartySpecies, species);
write(a.wPartySpecies + 1, 0xff);
write(a.wPartyMon1DVs, dvs >> 8);
write(a.wPartyMon1DVs + 1, dvs);
write(a.wBoxAlignment, 0);
write(a.hBGMapMode, 0);
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
seq.bank,
0xe0,
a.hROMBank & 255,
0xea,
0,
0x20,
0xfb, // EI
0xcd,
seq.address & 255,
seq.address >> 8,
0xf3,
0x3e,
0x42,
0xea,
0xf0,
0xc6,
0x18,
0xfe,
];
write(0xc6f0, 0);
program.forEach((v, i) => write(0xc700 + i, v));
// Run the real animation, sampling its DelayFrame calls.
reference.setProgramCounter(0xc700);
const frames: Record<string, unknown>[] = [];
const log: string[] = [];
// One sample per DelayFrame call: within 100 cycles of the counter changing.
let lastCount = read(0xc6f1);
const nextDelayFrame = () => {
for (let guard = 0; guard < 200000; guard++) {
reference.advanceCycles(100);
const count = read(0xc6f1);
if (count !== lastCount) {
lastCount = count;
return true;
}
if (read(0xc6f0) === 0x42) return false;
}
return false;
};
for (let f = 0; f < 1200; f++) {
if (!nextDelayFrame()) {
log.push(`no DelayFrame at ${f}`);
break;
}
const done = read(0xc6f0) === 0x42;
const oam: number[] = [];
for (let i = 0; i < 160; i++) oam.push(read(a.wShadowOAM + i));
const map: number[] = [];
for (let i = 0; i < 360; i++) map.push(read(a.wTilemap + i));
const attrs: number[] = [];
for (let i = 0; i < 360; i++) attrs.push(read(a.wAttrmap + i));
const bg: number[] = [],
ob: number[] = [],
bg2: number[] = [],
ob2: number[] = [];
write(0xff70, 5);
for (let i = 0; i < 64; i++) {
bg.push(read(0xd000 + i));
ob.push(read(0xd040 + i));
bg2.push(read(0xd080 + i));
ob2.push(read(0xd0c0 + i));
}
write(0xff70, 1);
const structs: number[] = [];
for (let i = 0; i < 160; i++)
structs.push(read(a.wSpriteAnimationStructs + i));
const rec = {
f,
structs,
bg2,
ob2,
ly: read(0xff44),
scx: read(0xff43),
hscx: read(a.hSCX),
gx: read(a.wGlobalAnimXOffset),
fc: read(a.wFrameCounter),
lcdc: read(0xff40),
bgmode: read(a.hBGMapMode),
oam,
map,
attrs,
bg,
ob,
done,
};
frames.push(rec);
if (done) {
log.push(`done at ${f}`);
break;
}
}
reference.dispose();
const errors: unknown[] = [];
const eq = (
actual: unknown,
expected: unknown,
phase: string,
frame: number,
) => {
if (
JSON.stringify(actual) !== JSON.stringify(expected) &&
errors.length < 5
)
errors.push({ phase, frame, actual, expected });
};
const trace = frames as {
f: number;
fc: number;
hscx: number;
oam: number[];
map: number[];
attrs: number[];
bg: number[];
structs: number[];
}[];
const start = trace.findIndex(
(f) => f.fc === 1 && f.hscx === 2 && f.map[87] === 49,
);
if (start < 0) throw Error("No wobble began");
for (let frame = 0; frame < 344; frame++) {
const ref = trace[start + frame],
local = native[80 + frame];
eq(ref.oam, local.oam, "wobble OAM", frame);
eq(ref.map, local.map, "wobble tilemap", frame);
eq(ref.attrs, local.attrs, "wobble attrs", frame);
eq(ref.hscx, local.scx, "wobble scroll", frame);
}
let comparedShell = 0;
for (const ref of trace) {
if (ref.fc !== 9 || ref.structs[1] !== 32) continue;
const var1 = ref.structs[12];
if (!var1) continue;
const frame = ref.structs[0] ? var1 / 8 - 1 : 16;
if (frame > 16) continue;
// Once removed, the following frame clears OAM while VAR1 stays 128.
if (!ref.structs[0] && !ref.oam[0]) continue;
eq(
ref.oam,
oam(shellSprites(assets.pokemon, frame)),
"shell OAM",
frame,
);
comparedShell++;
}
const final = trace.find((f) => f.fc === 9 && f.map[66] === 0);
if (!final) throw Error("No hatchling appeared");
const screen = hatchScreen(assets.pokemon, species, dvs, true);
eq(final.map, screen.map, "hatchling tilemap", 0);
eq(final.attrs, screen.attrs, "hatchling attrs", 0);
const rgb = (lo: number, hi: number) => {
const n = lo | (hi << 8);
return [
Math.round(((n & 31) * 255) / 31),
Math.round((((n >> 5) & 31) * 255) / 31),
Math.round((((n >> 10) & 31) * 255) / 31),
];
};
eq(
Array.from({ length: 4 }, (_, i) =>
rgb(final.bg[i * 2], final.bg[i * 2 + 1]),
),
screen.palettes[0],
"hatchling palette",
0,
);
eq(
Array.from({ length: 4 }, (_, i) =>
rgb(trace[start].bg[i * 2], trace[start].bg[i * 2 + 1]),
),
native[80].palette,
"egg palette",
0,
);
const originalObj = (final as unknown as { ob: number[] }).ob;
eq(
Array.from({ length: 8 }, (_, p) =>
Array.from({ length: 4 }, (_, i) =>
rgb(originalObj[p * 8 + i * 2], originalObj[p * 8 + i * 2 + 1]),
),
),
screen.objPalettes,
"object palettes",
0,
);
return { start, comparedWobble: 344, comparedShell, errors };
},
{ a, seq, species, dvs },
);
expect(result.comparedShell).toBeGreaterThanOrEqual(17);
expect(result.errors, JSON.stringify(result)).toEqual([]);
});
+201
View File
@@ -0,0 +1,201 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Needs private ROM and reference symbols.",
);
test("all ten MAIL screens match cartridge tilemaps, graphics, message placement and palettes", async ({
page,
}) => {
const { at, routine } = loadSymbols();
const labels = [
"Flower",
"Surf",
"LiteBlue",
"Portrait",
"Lovely",
"Eon",
"Morph",
"BlueSky",
"Music",
"Mirage",
];
const defs = labels.map((name) => routine(`Load${name}MailGFX`));
const a = Object.fromEntries(
[
"hROMBank",
"hCGB",
"wTilemap",
"wAttrmap",
"wCurPartySpecies",
"wUnownLetter",
"wCurMailIndex",
"wBoxAlignment",
"wBGPals1",
"sPartyMon1Mail",
"sPartyMon1MailAuthor",
].map((n) => [n, at(n)]),
);
const skip = ["ApplyPals", "ApplyAttrmap"].map(routine);
const palette = routine("LoadMailPalettes");
await openReference(page);
const result = await page.evaluate(
async ({ defs, a, skip, palette }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { mailScreen } = await import("/src/game/scripts/mail.ts");
const { textTiles } = await import("/src/game/rom/font.ts");
const { MAIL } = await import("/src/game/rom/profile.ts");
const rom = new Uint8Array(
await (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
// Exclude hardware palette transfer only. The original graphics,
// message/author placement and palette selection execute.
for (const { bank, address } of skip)
rom[bank * 0x4000 + address - 0x4000] = 0xc9;
const ref = new BinjgbEmulator();
await ref.load(rom, document.createElement("canvas"));
const write = (p: number, v: number) => ref.writeMemory(p, v & 255);
const read = (p: number) => ref.readMemory(p);
const call = (routine: { bank: number; address: number }, e = 0) => {
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0xaf,
0xe0,
0x40,
0xe0,
0x4f,
0x3e,
routine.bank,
0xe0,
a.hROMBank & 255,
0xea,
0,
0x20,
0x01,
a.sPartyMon1Mail & 255,
a.sPartyMon1Mail >> 8,
0x1e,
e,
0xcd,
routine.address & 255,
routine.address >> 8,
0x3e,
0x42,
0xea,
0xf0,
0xc0,
0x18,
0xfe,
];
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
ref.setProgramCounter(0xc000);
for (let f = 0; f < 60 && read(0xc0f0) !== 0x42; f++)
ref.advanceFrames(1);
if (read(0xc0f0) !== 0x42)
throw Error(
`MAIL routine ${routine.address.toString(16)} did not return`,
);
};
const failures: unknown[] = [];
for (let index = 0; index < defs.length; index++) {
write(0xff40, 0);
write(0xff4f, 0);
write(0xff70, 1);
write(a.hCGB, 1);
for (let p = 0x8000; p < 0xa000; p++) write(p, 0);
for (let i = 0; i < 360; i++) write(a.wTilemap + i, 0x7f);
write(a.wCurMailIndex, index);
write(a.wBoxAlignment, 0);
write(a.wCurPartySpecies, 201);
write(a.wUnownLetter, 1);
const message = "“HELLO…” FRIEND! ";
write(0x0000, 0x0a);
write(0x4000, 0);
const bytes = [
...textTiles(message),
0x4e,
...textTiles("SEE YOU SOON!"),
0x50,
];
for (let i = 0; i < 47; i++) write(a.sPartyMon1Mail + i, 0x50);
bytes.forEach((v, i) => write(a.sPartyMon1Mail + i, v));
textTiles("CHRIS").forEach((v, i) =>
write(a.sPartyMon1MailAuthor + i, v),
);
call(defs[index]);
const native = mailScreen(assets.pokemon, {
message: message + "\nSEE YOU SOON!",
author: "CHRIS",
authorId: 1,
species: 201,
type: assets.reader.byte(MAIL.MailGFXPointers + index * 3),
});
const map = Array.from({ length: 360 }, (_, i) => read(a.wTilemap + i));
const mapDiff = map.flatMap((v, i) =>
v === native.map[i]
? []
: [{ i, original: v, native: native.map[i] }],
);
const tileDiff: unknown[] = [];
for (const tile of new Set(map)) {
if (tile >= 0x60) continue; // font is loaded by ReadAnyMail, before these routines
for (let b = 0; b < 16; b++)
if (read(0x9000 + tile * 16 + b) !== native.tiles[tile * 16 + b]) {
tileDiff.push({
tile,
b,
original: read(0x9000 + tile * 16 + b),
native: native.tiles[tile * 16 + b],
});
break;
}
}
call(palette, index);
write(0xff70, 5);
const colors = Array.from(
{ length: 4 },
(_, i) =>
read(a.wBGPals1 + i * 2) | (read(a.wBGPals1 + i * 2 + 1) << 8),
);
const rgb = colors.map((v) =>
[v & 31, (v >> 5) & 31, (v >> 10) & 31].map((c) =>
Math.round((c * 255) / 31),
),
);
write(0xff70, 1);
if (
mapDiff.length ||
tileDiff.length ||
JSON.stringify(rgb) !== JSON.stringify(native.palettes[0])
)
failures.push({
index,
mapDiff: mapDiff.slice(0, 12),
tileDiff: tileDiff.slice(0, 12),
rgb,
nativePalette: native.palettes[0],
});
}
ref.dispose();
return { count: defs.length, failures };
},
{ defs, a, skip, palette },
);
expect(result.count).toBe(10);
expect(result.failures).toEqual([]);
});
+8
View File
@@ -84,6 +84,14 @@ describe.skipIf(!existsSync(romPath))("Tin Tower and Whirl Islands with the loca
world.state.party.pop(); world.state.party.pop();
world.state.party.push(mon(world, ENTEI, { ot: name, otId: world.playerId ^ 1 })); world.state.party.push(mon(world, ENTEI, { ot: name, otId: world.playerId ^ 1 }));
expect(beasts()).toEqual([false, true]); expect(beasts()).toEqual([false, true]);
// Preserve CheckOwnMonAnywhere's Day-Care omission: our own Entei
// in either keeper's care still does not satisfy MonCheck.
world.state.party.pop();
for (const [slot, flag] of [["mon1", 6], ["mon2", 7]] as const) {
world.state.dayCare[slot] = toBoxMon(mon(world, ENTEI, { ot: name }));
world.state.flags.add(flag);
expect(beasts()).toEqual([false, true]);
}
// Without any party Pokémon, CheckOwnMonAnywhere finds nothing. // Without any party Pokémon, CheckOwnMonAnywhere finds nothing.
world.state.party = []; world.state.party = [];
expect(beasts()).toEqual([true, true]); expect(beasts()).toEqual([true, true]);
+10
View File
@@ -20,6 +20,10 @@ export function settle(
if (dialogue) { if (dialogue) {
const page = dialogue.pages[dialogue.index]; const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1; const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
world.tick();
continue;
}
if (last && dialogue.choice) { if (last && dialogue.choice) {
shown.push(page); shown.push(page);
// The naming screen waits for the caller unless a name is queued. // The naming screen waits for the caller unless a name is queued.
@@ -28,6 +32,12 @@ export function settle(
world.choose(answers.shift()!); world.choose(answers.shift()!);
continue; continue;
} }
if (dialogue.choice.kind === "screen") {
shown.push(dialogue.choice.screen.label);
if (typeof answers[0] !== "string") return shown;
world.choose(answers.shift()!);
continue;
}
const answer = answers.shift(); const answer = answers.shift();
world.choose( world.choose(
typeof answer === "number" typeof answer === "number"