Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate, Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's room and the Hall of Fame are open. - halloffame (game/scripts/league.ts): the music fades as SAVING RECORD shows, the win and the team (without Eggs) are recorded, each Pokémon's back picture passes and its front picture slides in for its "New Hall of Famer!" page and cry, then the player's card with the play time and Professor Oak's rating; the play time stops meanwhile. The world then continues at New Bark as Continue does after the cartridge's reset. - credits and the credits after the Hall of Fame (CreditsMachine): the ROM's script, strings, banner Pokémon and THE END, frame by frame as VBlank_Credits presents them, with the frames each command group runs over measured on the cartridge. B shortens credits seen before. - wait, appear/disappear of the player, and rival names in battle texts (PlaceEnemysName prints wRivalName alone: "???" before he is named). - The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows their own hSCX. - Reference: the credits match the cartridge on every frame (BG map, banner tiles, CRAM palettes, LY overrides, parser), with and without B; the ceremony's pages match for every species and the player's card for Chris and Kris. - Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four and Lance); the driver follows warps within a map and fights carefully from here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6fdc23508
commit
f05fd8ebee
23 files changed
+1943
-107
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
||||||
|
|
||||||
**Current milestone: all of Johto, from the opening to Blackthorn City, the Rising Badge and the Battle Tower.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
**Current milestone: all of Johto and the Pokémon League, from the opening to the Hall of Fame and the credits.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||||
|
|
||||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||||
|
|
||||||
@@ -30,11 +30,13 @@ Team Rocket then takes over Goldenrod's Radio Tower. Fight up to the fake direct
|
|||||||
|
|
||||||
Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the Clear Bell convinces the sages to let you into Tin Tower, where Suicune puts you to the test. After the League, Tin Tower's upper floors lead to Ho-Oh, and the Whirl Islands hide Lugia for a trainer with the Silver Wing.
|
Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the Clear Bell convinces the sages to let you into Tin Tower, where Suicune puts you to the test. After the League, Tin Tower's upper floors lead to Ho-Oh, and the Whirl Islands hide Lugia for a trainer with the Silver Wing.
|
||||||
|
|
||||||
|
With the eight badges, surf east from New Bark Town along Route 27, climb Tohjo Falls with Waterfall and take Route 26 north to the Victory Road gate, where only a trainer with all eight badges may pass. Your rival waits at the end of Victory Road. At Indigo Plateau, the Elite Four's doors lock behind you: beat Will, Koga, Bruno, Karen and then Lance, and enter the Hall of Fame. Your team is recorded as the original does, with each Pokémon's entrance, your play time and Professor Oak's rating, then the credits roll; you then wake up in New Bark Town. The Hall of Fame's teams can be viewed from any Pokémon Center's PC.
|
||||||
|
|
||||||
On Route 40, the Battle Tower takes a party of three different Pokémon holding different items into a Battle Room of the level you choose, against seven trainers in a row. Win them all for five of a random vitamin. Between battles you can save and quit, and the receptionist picks up where you left off.
|
On Route 40, the Battle Tower takes a party of three different Pokémon holding different items into a Battle Room of the level you choose, against seven trainers in a row. Win them all for five of a random vitamin. Between battles you can save and quit, and the receptionist picks up where you left off.
|
||||||
|
|
||||||
The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported.
|
The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported.
|
||||||
|
|
||||||
Kanto, the League, Olivine's port and the S.S. Aqua, the Day-Care's Eggs, trade evolutions, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
Kanto, Olivine's port and the S.S. Aqua, Mt. Silver, trade evolutions, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
|||||||
| `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/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/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/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/league.ts`, the League, the Hall of Fame and the credits | `engine/events/halloffame.asm` (`HallOfFame`, `RedCredits`, `AnimateHallOfFame`, `AnimateHOFMonEntrance`, `HOF_SlideBackpic`, `HOF_SlideFrontpic`, `HOF_AnimatePlayerPic`), `engine/movie/credits.asm` (`Credits`, `ParseCredits`, `Credits_LoadBorderGFX`, `Credits_LYOverride`, `ConstructCreditsTilemap`, `GetCreditsPalette`), `engine/movie/init_hof_credits.asm`, `engine/events/prof_oaks_pc.asm` (`ProfOaksPCBoot`, `ProfOaksPCRating`, `Rate`, `FindOakRating`), `engine/gfx/player_gfx.asm` (`GetPlayerBackpic`, `HOF_LoadTrainerFrontpic`), `home/vblank.asm` (`VBlank_Credits`), `engine/overworld/scripting.asm` (`Script_halloffame`, `Script_credits`, `Script_wait`), `home/text.asm` (`PlaceEnemysName`) | The ceremony records the team and win, pauses the play time and shows each Pokémon's slides and page, the player's card and Oak's rating; the credits run frame by frame (text by thirds, banner tiles by halves, palette VBlanks, border scroll), with each command group's measured spill frames; after the Hall of Fame the world continues at New Bark as Continue would, after Red at Mt. Silver. Checked against the cartridge by `tests/reference-credits.spec.ts` and `tests/reference-hall-of-fame-ceremony.spec.ts`. |
|
||||||
| `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/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. |
|
||||||
|
|||||||
+13
-2
@@ -515,7 +515,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
## Implemented: the Hall of Fame PC (board #29)
|
## 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.
|
- `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.
|
- `GetHallOfFameParty` (Eggs left out) and `AddHallOfFameEntry` (the oldest of 30 teams drops off, the count stops at 200) are ported as `addHallOfFameEntry`, which the League's ceremony calls (below).
|
||||||
- `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.
|
- `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.
|
- `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.
|
||||||
|
|
||||||
@@ -528,11 +528,22 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Refused with the reason: unported maps, the Bug-Catching Contest, a Battle Tower challenge in progress, characters outside the naming screen (traded Japanese names, ligatures). Not carried over, with a note in the preview: the tiles walked count, Mystery Gift items, link battle records; time-windowed objects follow the clock again.
|
- Refused with the reason: unported maps, the Bug-Catching Contest, a Battle Tower challenge in progress, characters outside the naming screen (traded Japanese names, ligatures). Not carried over, with a note in the preview: the tiles walked count, Mystery Gift items, link battle records; time-windowed objects follow the clock again.
|
||||||
- Savegames → Import backup reads `.sav`/`.srm`/`.dat` files (or any 32 KiB non-JSON file), previews trainer, place, progress, which copy and clock were used and the notes, then adds or replaces a save like a native backup (`SlotSession.previewCartridge`). Tests: `tests/cartridge-save.test.ts` (the committed real `.sav` and Virtual Console `sav.dat`, backup fallback, clock footers, day counters, refusals), `tests/save-slots.test.ts` and `tests/save-slots-browser.spec.ts`.
|
- Savegames → Import backup reads `.sav`/`.srm`/`.dat` files (or any 32 KiB non-JSON file), previews trainer, place, progress, which copy and clock were used and the notes, then adds or replaces a save like a native backup (`SlotSession.previewCartridge`). Tests: `tests/cartridge-save.test.ts` (the committed real `.sav` and Virtual Console `sav.dat`, backup fallback, clock footers, day counters, refusals), `tests/save-slots.test.ts` and `tests/save-slots-browser.spec.ts`.
|
||||||
|
|
||||||
|
## Implemented: the League, the Hall of Fame and the credits (board #33, increment A)
|
||||||
|
|
||||||
|
- Open: Routes 26 and 27 with their three houses, Tohjo Falls, the Victory Road gate (badge check), Victory Road with the rival, Route 23, Indigo Plateau's Pokémon Center and mart, the Elite Four's rooms (doors that lock and open), Lance's room with Mary and Oak's scene, and the Hall of Fame. Route 22, Route 28 and the rest of Kanto stay closed.
|
||||||
|
- `halloffame` (`HallOfFame`, `game/scripts/league.ts`): the music fades as "SAVING RECORD…" shows for 100 frames, `STATUSFLAGS_HALL_OF_FAME_F` is set and the League win and team recorded (`addHallOfFameEntry`), `MUSIC_HALL_OF_FAME` plays, each Pokémon's back picture passes (`HOF_SlideBackpic`, hSCX $90 to $70 by 4) and its front picture slides in (`HOF_SlideFrontpic`) before its "New Hall of Famer!" page and cry (60 frames), then the player's pictures, name, ID and play time (`HOF_AnimatePlayerPic`) with `ProfOaksPCRating`'s count, rating text (drawn into the text box from the ROM's characters, so ligatures keep their one tile) and sound; then the credits. `GAME_TIMER_COUNTING_F` is cleared meanwhile. The cartridge saves and resets after the credits and Continue spawns at New Bark (`SPAWN_LANCE` → `SPAWN_NEW_BARK`, `MAPSETUP_WARP`, `JumpRoamMons`); the port has no title screen and continues there directly; its own save is the script's committed state, so a reload during the ceremony replays it from the Hall of Fame's entrance, counting the win once.
|
||||||
|
- `credits` (`RedCredits`) and the credits themselves (`CreditsMachine`): `CreditsScript`'s strings, scenes and waits from the ROM, the four banner Pokémon's frames, the scrolling borders and THE END, presented as VBlank_Credits does (the text a third of the screen per VBlank, eight banner tiles per VBlank, a palette change taking its whole VBlank, the bottom border scrolled one frame before the top). How many frames each group of commands takes on the cartridge depends on `_UpdateSound`'s work within VBlank, so the 46 groups' spills are recorded as measured. B shortens credits seen before (from a second League win, and after Red); A closes them after THE END. The port reads B pressed rather than held.
|
||||||
|
- `wait` (Script_wait: six frames, `n` times) and `disappear`/`appear` of the player (the sprite is gone until the map loads again) are ported. Battle texts now name a rival (RIVAL1, RIVAL2) by `wRivalName` alone, as `PlaceEnemysName` does: "??? wants to battle!" in Cherrygrove, "SILVER wants to battle!" later (the port printed "RIVAL ?").
|
||||||
|
- The tile-screen renderer reads the background line by line, wrapping at 256 pixels as hSCX and hSCY do, and gives rows their own hSCX (`rowScroll`, wLYOverrides).
|
||||||
|
- Reference: `tests/reference-credits.spec.ts` runs the cartridge's `InitDisplayForRedCredits` and `Credits` with interrupts after a boot and compares every frame (about 7,000, past THE END) with `CreditsMachine`: the displayed BG map, the banner's tiles, the palettes in CRAM, wLYOverrides and the parser where it rests; once without B and once with B held. `tests/reference-hall-of-fame-ceremony.spec.ts` compares `DisplayNewHallOfFamer` for every species and `HOF_AnimatePlayerPic`'s card for Chris and Kris (tilemap, displayed attributes, palette and tiles) with only frame waits, `HOF_AnimateFrontpic` and `ProfOaksPCRating` stubbed.
|
||||||
|
- Tests: `tests/league.test.ts` (Route 27's first step into Kanto, the gate's badge check, the ceremony's records without Eggs, the paused play time, the 200-win cap and 30 teams, B on the credits, the text-box decoder, the renderer's wrap and row scroll). The playthrough gains five stages: back to Ice Path for HM07, home to New Bark, Route 27, Tohjo Falls' waterfall, Route 26 and Victory Road, Indigo Plateau and the Elite Four, Lance and the Hall of Fame (the driver now fights carefully from here: accuracy-weighed moves, Full Heals for poison, paralysis and burns, healing below two thirds). Test saves: `indigo-plateau`, `hall-of-fame-new-bark`, `hall-of-fame-ceremony`.
|
||||||
|
- Not presented: `ANIM_MON_HOF` (pictures at rest, as in the PC), the credits' first 36 frames of setup are shown white without their exact frame count, the rating text appears whole rather than letter by letter. The Plateau's rival (after Mt. Moon) and Kanto are increments B and C.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, the League, Kanto and the postgame remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; Kanto's maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
This is still not a complete new game. The Pokédex's area view, search and other modes, Kanto and the postgame remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; Kanto's maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
||||||
|
|
||||||
## 1. Complete the overworld foundation
|
## 1. Complete the overworld foundation
|
||||||
|
|
||||||
|
|||||||
@@ -247,8 +247,8 @@ for (const table of ["SAVED_WRAM", "WRAM", "SAVE_BLOCK"]) {
|
|||||||
if (sram.get(label) !== Number(literal))
|
if (sram.get(label) !== Number(literal))
|
||||||
throw new Error(`SRAM mismatch: ${label}`);
|
throw new Error(`SRAM mismatch: ${label}`);
|
||||||
}
|
}
|
||||||
// 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, KANTO and AUDIO entries are keyed by their PRET labels.
|
||||||
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS"]) {
|
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO", "MAIL", "EGGS", "KANTO"]) {
|
||||||
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
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)) {
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ export type BattleOutcome = {
|
|||||||
export interface BattleTrainer {
|
export interface BattleTrainer {
|
||||||
name: string;
|
name: string;
|
||||||
className: string;
|
className: string;
|
||||||
|
/** wTrainerClass, where the text needs it (the rivals' <ENEMY>). */
|
||||||
|
classId?: number;
|
||||||
/** wOTPartyMons: level, status and HP follow the Pokémon out only where
|
/** wOTPartyMons: level, status and HP follow the Pokémon out only where
|
||||||
* the original updates them (UpdateEnemyMonInParty: status changes and
|
* the original updates them (UpdateEnemyMonInParty: status changes and
|
||||||
* heals, a faint or a withdrawal); damage alone leaves them stale. */
|
* heals, a faint or a withdrawal); damage alone leaves them stale. */
|
||||||
@@ -700,8 +702,12 @@ export class Battle {
|
|||||||
this.battleType === C.BATTLETYPE_TUTORIAL
|
this.battleType === C.BATTLETYPE_TUTORIAL
|
||||||
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
|
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
|
||||||
: base.player,
|
: base.player,
|
||||||
|
// PlaceEnemysName: a rival (RIVAL1, RIVAL2) is wRivalName alone,
|
||||||
|
// other trainers the class and the name.
|
||||||
trainer: this.options.trainer
|
trainer: this.options.trainer
|
||||||
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
? this.options.trainer.classId === C.RIVAL1 || this.options.trainer.classId === C.RIVAL2
|
||||||
|
? base.rival
|
||||||
|
: `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
||||||
: base.trainer,
|
: base.trainer,
|
||||||
strings: {
|
strings: {
|
||||||
...base.strings,
|
...base.strings,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* macros/scripts/battle_commands.asm). `npm run check:reference` compares
|
* macros/scripts/battle_commands.asm). `npm run check:reference` compares
|
||||||
* every entry with the pinned source, so a wrong value cannot ship. */
|
* every entry with the pinned source, so a wrong value cannot ship. */
|
||||||
export const C = {
|
export const C = {
|
||||||
|
// Trainer classes (constants/trainer_constants.asm) the text treats apart.
|
||||||
|
RIVAL1: 9,
|
||||||
|
RIVAL2: 42,
|
||||||
// Move effects (constants/move_effect_constants.asm)
|
// Move effects (constants/move_effect_constants.asm)
|
||||||
EFFECT_NORMAL_HIT: 0,
|
EFFECT_NORMAL_HIT: 0,
|
||||||
EFFECT_LEECH_HIT: 3,
|
EFFECT_LEECH_HIT: 3,
|
||||||
|
|||||||
@@ -5,6 +5,54 @@ import {
|
|||||||
} from "../scripts/screen";
|
} from "../scripts/screen";
|
||||||
import { drawTile } from "./pixels";
|
import { drawTile } from "./pixels";
|
||||||
|
|
||||||
|
/** The background map is 32x32 tiles and wraps, as hSCX and hSCY do. */
|
||||||
|
const BG_TILES = 32;
|
||||||
|
|
||||||
|
/** The background tile and palette at a background map cell: the screen's
|
||||||
|
* 20x18 tilemap, a row's own 32 tiles (`rowScroll`), or `scroll.fill`. */
|
||||||
|
function backgroundCell(screen: TileScreen, row: number, col: number) {
|
||||||
|
const own = screen.rowScroll?.find((entry) => entry.row === row);
|
||||||
|
if (own?.tiles) return { tile: own.tiles[col], palette: screen.attrs[row * SCREEN_WIDTH] };
|
||||||
|
if (row < SCREEN_HEIGHT && col < SCREEN_WIDTH) {
|
||||||
|
const cell = row * SCREEN_WIDTH + col;
|
||||||
|
return { tile: screen.map[cell], palette: screen.attrs[cell] };
|
||||||
|
}
|
||||||
|
return { tile: screen.scroll?.fill ?? -1, palette: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The background's color index (0-3) and palette at every screen pixel,
|
||||||
|
* line by line: each line reads the background map at hSCY and that
|
||||||
|
* line's hSCX (a `rowScroll` row's own, as wLYOverrides set it), wrapping
|
||||||
|
* at 256 pixels. A pixel of a -1 tile (an overlay's hole) is -1. */
|
||||||
|
export function tileScreenBackground(screen: TileScreen) {
|
||||||
|
const width = SCREEN_WIDTH * 8,
|
||||||
|
height = SCREEN_HEIGHT * 8;
|
||||||
|
const indices = new Int8Array(width * height).fill(-1),
|
||||||
|
palettes = new Uint8Array(width * height);
|
||||||
|
const scrollY = screen.scroll?.y ?? 0;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const screenRow = y >> 3;
|
||||||
|
const scrollX =
|
||||||
|
screen.rowScroll?.find((entry) => entry.row === screenRow)?.x ??
|
||||||
|
screen.scroll?.x ??
|
||||||
|
0;
|
||||||
|
const bgY = (((y + scrollY) % 256) + 256) % 256,
|
||||||
|
row = bgY >> 3;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const bgX = (((x + scrollX) % 256) + 256) % 256;
|
||||||
|
const { tile, palette } = backgroundCell(screen, row % BG_TILES, (bgX >> 3) % BG_TILES);
|
||||||
|
if (tile < 0) continue;
|
||||||
|
const offset = tile * 16 + (bgY & 7) * 2;
|
||||||
|
if (offset < 0 || offset + 2 > screen.tiles.length) continue;
|
||||||
|
const bit = 7 - (bgX & 7);
|
||||||
|
indices[y * width + x] =
|
||||||
|
((screen.tiles[offset] >> bit) & 1) | (((screen.tiles[offset + 1] >> bit) & 1) << 1);
|
||||||
|
palettes[y * width + x] = palette;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { indices, palettes };
|
||||||
|
}
|
||||||
|
|
||||||
/** Draw a tile screen: the background tilemap, then the sprites over it
|
/** Draw a tile screen: the background tilemap, then the sprites over it
|
||||||
* (color 0 transparent). `frame` counts video frames for blinking sprites. */
|
* (color 0 transparent). `frame` counts video frames for blinking sprites. */
|
||||||
export function drawTileScreen(
|
export function drawTileScreen(
|
||||||
@@ -18,30 +66,17 @@ export function drawTileScreen(
|
|||||||
if (canvas.height !== height) canvas.height = height;
|
if (canvas.height !== height) canvas.height = height;
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
const image = context.createImageData(width, height);
|
const image = context.createImageData(width, height);
|
||||||
// Recorded only for OAM_PRIO sprites (`behind`), which need the
|
const background = tileScreenBackground(screen);
|
||||||
// background's own color index to clip against; a screen with none
|
// OAM_PRIO sprites (`behind`) clip against the background's own color
|
||||||
// pays nothing beyond the allocation.
|
// index; a hole in an overlay counts as color 0.
|
||||||
const indices = new Uint8Array(width * height);
|
const indices = new Uint8Array(width * height);
|
||||||
const scrollX = screen.scroll?.x ?? 0;
|
for (let pixel = 0; pixel < width * height; pixel++) {
|
||||||
const scrollY = screen.scroll?.y ?? 0;
|
const index = background.indices[pixel];
|
||||||
for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++)
|
if (index < 0) continue;
|
||||||
for (let col = scrollX < 0 ? -1 : 0; col < SCREEN_WIDTH + (scrollX > 0 ? 1 : 0); col++) {
|
indices[pixel] = index;
|
||||||
const cell = row * SCREEN_WIDTH + col;
|
const color = (screen.palettes[background.palettes[pixel]] ?? screen.palettes[0])[index];
|
||||||
const inside = row >= 0 && row < SCREEN_HEIGHT && col >= 0 && col < SCREEN_WIDTH;
|
image.data.set([color[0], color[1], color[2], 255], pixel * 4);
|
||||||
const tile = inside ? screen.map[cell] : screen.scroll!.fill;
|
}
|
||||||
if (tile < 0) continue;
|
|
||||||
drawTile(
|
|
||||||
image.data,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
screen.tiles,
|
|
||||||
tile,
|
|
||||||
col * 8 - scrollX,
|
|
||||||
row * 8 - scrollY,
|
|
||||||
(inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0],
|
|
||||||
{ indices },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const blinkOff = !(frame & 0x10);
|
const blinkOff = !(frame & 0x10);
|
||||||
for (const sprite of screen.sprites) {
|
for (const sprite of screen.sprites) {
|
||||||
if (sprite.blink && blinkOff) continue;
|
if (sprite.blink && blinkOff) continue;
|
||||||
|
|||||||
@@ -1501,6 +1501,23 @@ export const JOHTO = {
|
|||||||
FontExtra: 0xf8000,
|
FontExtra: 0xf8000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** The League, Kanto and the postgame (board #33), by PRET label. */
|
||||||
|
export const KANTO = {
|
||||||
|
ChrisBackpic: 0x2ba1a,
|
||||||
|
KrisBackpic: 0x88ed6,
|
||||||
|
"AnimateHallOfFame.String_NewHallOfFamer": 0x8652c,
|
||||||
|
"HOF_AnimatePlayerPic.PlayTime": 0x868ed,
|
||||||
|
"InitDisplayForHallOfFame.SavingRecordText": 0x4e8bd,
|
||||||
|
ProfOaksPCBoot: 0x265ee,
|
||||||
|
CreditsBorderGFX: 0x109c24,
|
||||||
|
CopyrightGFX: 0xe4000,
|
||||||
|
TheEndGFX: 0xcbd2e,
|
||||||
|
CreditsPichuGFX: 0x109cb4,
|
||||||
|
CreditsScript: 0x10acb4,
|
||||||
|
CreditsStringsPointers: 0x10ae13,
|
||||||
|
CreditsPalettes: 0x109b6a,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
||||||
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
||||||
* save keeps them by label in `memory`. */
|
* save keeps them by label in `memory`. */
|
||||||
@@ -1936,6 +1953,22 @@ export const NATIVE_MAPS: Record<string, string> = {
|
|||||||
"10:17": "Route 36 National Park gate",
|
"10:17": "Route 36 National Park gate",
|
||||||
"3:15": "National Park",
|
"3:15": "National Park",
|
||||||
"3:16": "National Park (Bug-Catching Contest)",
|
"3:16": "National Park (Bug-Catching Contest)",
|
||||||
|
"24:1": "Route 26",
|
||||||
|
"24:2": "Route 27",
|
||||||
|
"24:10": "Route 26 heal house",
|
||||||
|
"24:11": "Day-of-week siblings’ house",
|
||||||
|
"24:12": "Route 27 sandstorm house",
|
||||||
|
"3:83": "Tohjo Falls",
|
||||||
|
"23:13": "Victory Road gate",
|
||||||
|
"3:91": "Victory Road",
|
||||||
|
"16:1": "Route 23",
|
||||||
|
"16:2": "Pokémon Center",
|
||||||
|
"16:3": "Will’s room",
|
||||||
|
"16:4": "Koga’s room",
|
||||||
|
"16:5": "Bruno’s room",
|
||||||
|
"16:6": "Karen’s room",
|
||||||
|
"16:7": "Lance’s room",
|
||||||
|
"16:8": "Hall of Fame",
|
||||||
};
|
};
|
||||||
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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HallOfFameTeam } from "../party/hall-of-fame";
|
import type { HallOfFameMon, HallOfFameTeam } from "../party/hall-of-fame";
|
||||||
import { PokemonData } from "../rom/pokemon";
|
import { PokemonData } from "../rom/pokemon";
|
||||||
import { JOHTO } from "../rom/profile";
|
import { JOHTO } from "../rom/profile";
|
||||||
import { fontTiles, FRAME_TILE, SPACE_TILE, textTiles } from "../rom/font";
|
import { fontTiles, FRAME_TILE, SPACE_TILE, textTiles } from "../rom/font";
|
||||||
@@ -7,63 +7,67 @@ import { blankScreen, SCREEN_BUTTONS, type TileScreen } from "./screen";
|
|||||||
import { defaultTextContext, readString } from "./text";
|
import { defaultTextContext, readString } from "./text";
|
||||||
import type { GameSound } from "../audio/sound";
|
import type { GameSound } from "../audio/sound";
|
||||||
|
|
||||||
/** _HallOfFamePC.DisplayMonAndStrings: DisplayHOFMon's page for one
|
/** Place text on a screen as tile numbers; '"' is <PK> in a nickname. */
|
||||||
* Pokémon of a team, over _LoadFontsBattleExtra's tiles, in
|
export function placeText(screen: TileScreen, x: number, y: number, value: string) {
|
||||||
* SCGB_PLAYER_OR_MON_FRONTPIC_PALS's single palette. The picture is shown
|
const tiles = (value.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).flatMap((character) =>
|
||||||
* at rest: HOF_AnimateFrontpic's ANIM_MON_HOF animation is not ported. */
|
character === '"' ? [0xe1] : textTiles(character),
|
||||||
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(""),
|
|
||||||
);
|
);
|
||||||
|
screen.map.splice(y * 20 + x, tiles.length, ...tiles);
|
||||||
|
}
|
||||||
|
/** Textbox: a frame with `width` interior columns and `height` interior
|
||||||
|
* rows whose top-left corner is at (x, y). */
|
||||||
|
export function placeTextbox(
|
||||||
|
screen: TileScreen,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
height: number,
|
||||||
|
width = 18,
|
||||||
|
) {
|
||||||
|
const put = (column: number, row: number, values: readonly number[]) =>
|
||||||
|
screen.map.splice(row * 20 + column, values.length, ...values);
|
||||||
|
put(x, y, [FRAME_TILE, ...Array(width).fill(FRAME_TILE + 1), FRAME_TILE + 2]);
|
||||||
|
for (let row = y + 1; row <= y + height; row++) {
|
||||||
|
put(x, row, [FRAME_TILE + 3, ...Array(width).fill(SPACE_TILE), FRAME_TILE + 3]);
|
||||||
|
}
|
||||||
|
put(x, y + height + 1, [
|
||||||
|
FRAME_TILE + 4,
|
||||||
|
...Array(width).fill(FRAME_TILE + 1),
|
||||||
|
FRAME_TILE + 5,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
/** The Hall of Fame screens' tiles: the standard font, the textbox frame
|
||||||
|
* and _LoadFontsBattleExtra's $60-$78 (InitDisplayForHallOfFame and
|
||||||
|
* _HallOfFamePC load the same). */
|
||||||
|
export function hallOfFameTiles(data: PokemonData, frame: number) {
|
||||||
|
const tiles = fontTiles(data.reader, frame);
|
||||||
|
tiles.set(data.reader.slice(JOHTO.FontBattleExtra, 25 * 16), 0x60 * 16);
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DisplayHOFMon: one Pokémon's page, over the Hall of Fame tiles, in
|
||||||
|
* SCGB_PLAYER_OR_MON_FRONTPIC_PALS's single palette, without the title
|
||||||
|
* line its callers print at (1, 2). The picture is shown at rest:
|
||||||
|
* HOF_AnimateFrontpic's ANIM_MON_HOF animation is not ported. */
|
||||||
|
export function displayHOFMon(
|
||||||
|
data: PokemonData,
|
||||||
|
mon: HallOfFameMon,
|
||||||
|
frame: number,
|
||||||
|
label: string,
|
||||||
|
): TileScreen {
|
||||||
|
const tiles = hallOfFameTiles(data, frame);
|
||||||
|
tiles.set(data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)));
|
||||||
const screen = blankScreen(
|
const screen = blankScreen(
|
||||||
tiles,
|
tiles,
|
||||||
`hof-${mon.species}-${mon.dvs}-${frame}`,
|
`hof-${mon.species}-${mon.dvs}-${frame}`,
|
||||||
[data.palette(mon.species, PokemonData.shiny(mon.dvs))],
|
[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")}`,
|
`${label}: ${mon.nickname}, ${data.speciesName(mon.species)}, level ${mon.level}, ID ${String(mon.otId).padStart(5, "0")}`,
|
||||||
);
|
);
|
||||||
screen.map.fill(SPACE_TILE);
|
screen.map.fill(SPACE_TILE);
|
||||||
const put = (x: number, y: number, values: readonly number[]) =>
|
const put = (x: number, y: number, values: readonly number[]) =>
|
||||||
screen.map.splice(y * 20 + x, values.length, ...values);
|
screen.map.splice(y * 20 + x, values.length, ...values);
|
||||||
const text = (x: number, y: number, value: string) =>
|
const text = (x: number, y: number, value: string) => placeText(screen, x, y, value);
|
||||||
put(
|
placeTextbox(screen, 0, 0, 3);
|
||||||
x,
|
placeTextbox(screen, 0, 12, 4);
|
||||||
y,
|
|
||||||
(value.match(/ᴾᴷ|ᴹᴺ|[\s\S]/gu) ?? []).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 x = 0; x < 7; x++)
|
||||||
for (let y = 0; y < 7; y++) put(x + 6, y + 5, [x * 7 + y]);
|
for (let y = 0; y < 7; y++) put(x + 6, y + 5, [x * 7 + y]);
|
||||||
put(1, 13, [0x74, 0xf2]); // № and <DOT>.
|
put(1, 13, [0x74, 0xf2]); // № and <DOT>.
|
||||||
@@ -83,6 +87,28 @@ export function hallOfFameScreen(
|
|||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** _HallOfFamePC.DisplayMonAndStrings: DisplayHOFMon's page for one
|
||||||
|
* Pokémon of a team with the team's "-Time Famer" title. */
|
||||||
|
export function hallOfFameScreen(
|
||||||
|
data: PokemonData,
|
||||||
|
team: HallOfFameTeam,
|
||||||
|
slot: number,
|
||||||
|
frame: number,
|
||||||
|
): TileScreen {
|
||||||
|
const title = readString(
|
||||||
|
data.reader,
|
||||||
|
JOHTO["_HallOfFamePC.TimeFamer"],
|
||||||
|
defaultTextContext(""),
|
||||||
|
);
|
||||||
|
const screen = displayHOFMon(data, team.mons[slot], frame, `${team.win}${title.trimStart()}`);
|
||||||
|
// "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".
|
||||||
|
placeText(screen, 1, 2, title);
|
||||||
|
placeText(screen, 2, 2, String(team.win).padStart(3, " "));
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
|
||||||
/** _HallOfFamePC: LoadHOFTeam takes the teams newest first until an absent
|
/** _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
|
* 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
|
* last one the next team, Start skips to the next team and B leaves. Each
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
EMPTY_ROUTINES,
|
EMPTY_ROUTINES,
|
||||||
ENGINE_FLAGS,
|
ENGINE_FLAGS,
|
||||||
JOHTO,
|
JOHTO,
|
||||||
|
KANTO,
|
||||||
MENU,
|
MENU,
|
||||||
NATIVE_ROUTINES,
|
NATIVE_ROUTINES,
|
||||||
NUM_FRUIT_TREES,
|
NUM_FRUIT_TREES,
|
||||||
@@ -85,6 +86,8 @@ import type { TileScreen } from "./screen";
|
|||||||
import { unownPuzzle } from "./unown-puzzle";
|
import { unownPuzzle } from "./unown-puzzle";
|
||||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||||
import { slotMachine } from "./slot-machine";
|
import { slotMachine } from "./slot-machine";
|
||||||
|
import { hallOfFame, profOaksPCBoot, redCredits, type CeremonyContext } from "./league";
|
||||||
|
import type { GameTime } from "../world/game-time";
|
||||||
import { cardFlip } from "./card-flip";
|
import { cardFlip } from "./card-flip";
|
||||||
import { giveDratini, moveDeletion } from "./blackthorn";
|
import { giveDratini, moveDeletion } from "./blackthorn";
|
||||||
import { beastsCheck, monCheck } from "./tin-tower";
|
import { beastsCheck, monCheck } from "./tin-tower";
|
||||||
@@ -808,6 +811,9 @@ export interface ScriptHost {
|
|||||||
initCallReceiveDelay?(): void;
|
initCallReceiveDelay?(): void;
|
||||||
/** GetWorldMapLocation for any map. */
|
/** GetWorldMapLocation for any map. */
|
||||||
landmarkOf?(mapId: string): number;
|
landmarkOf?(mapId: string): number;
|
||||||
|
/** The play time, and GAME_TIMER_COUNTING_F (halloffame clears it). */
|
||||||
|
gameTime?(): GameTime;
|
||||||
|
gameTimer?(counting: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A sound a script started while its text was still to be shown: it
|
/** A sound a script started while its text was still to be shown: it
|
||||||
@@ -1950,6 +1956,38 @@ export class ScriptRun {
|
|||||||
else Object.assign(memory, { wDunsparceMapGroup: group, wDunsparceMapNumber: number });
|
else Object.assign(memory, { wDunsparceMapGroup: group, wDunsparceMapNumber: number });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 0xa1:
|
||||||
|
case 0xa2: {
|
||||||
|
// halloffame (HallOfFame, the game timer paused meanwhile) and
|
||||||
|
// credits (RedCredits), then Script_endall. After the Hall of
|
||||||
|
// Fame the original resets and Continue spawns at New Bark
|
||||||
|
// (SPAWN_LANCE); after Red the overworld reloads at Mt. Silver
|
||||||
|
// (SPAWN_RED); both by MAPSETUP_WARP, Continue's after
|
||||||
|
// JumpRoamMons. The port has no title screen to reset to.
|
||||||
|
yield* flush();
|
||||||
|
const context = this.ceremonyContext(pages, take);
|
||||||
|
if (opcode === 0xa1) {
|
||||||
|
this.host.gameTimer?.(false);
|
||||||
|
yield* hallOfFame(context);
|
||||||
|
this.host.gameTimer?.(true);
|
||||||
|
} else yield* redCredits(context);
|
||||||
|
const entry = SYMBOLS.spawnPoints + (opcode === 0xa1 ? OW.SPAWN_NEW_BARK : OW.SPAWN_MT_SILVER) * 4;
|
||||||
|
yield synchronous({
|
||||||
|
kind: "warp",
|
||||||
|
map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
|
||||||
|
x: r.byte(entry + 2),
|
||||||
|
y: r.byte(entry + 3),
|
||||||
|
...(opcode === 0xa1 && { roam: "jump" as const }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 0xa8: {
|
||||||
|
// wait: DelayFrames of six frames, `n` times; the map waits.
|
||||||
|
const ticks = byte() * 3;
|
||||||
|
if (pages.length) yield { kind: "text", pages: take(), holdTicks: ticks };
|
||||||
|
else yield synchronous({ kind: "delay", ticks });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 0xa6: {
|
case 0xa6: {
|
||||||
// gettrainerclassname: GetName for TRAINER_NAME, the class's name.
|
// gettrainerclassname: GetName for TRAINER_NAME, the class's name.
|
||||||
const classId = byte();
|
const classId = byte();
|
||||||
@@ -3106,6 +3144,22 @@ export class ScriptRun {
|
|||||||
waitSFX: () => this.waitSFX(),
|
waitSFX: () => this.waitSFX(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/** The specials' context with what the League's ceremony and the
|
||||||
|
* credits need. */
|
||||||
|
private ceremonyContext(pages: string[], take: () => string[]): CeremonyContext {
|
||||||
|
const black: Palette[number] = [0, 0, 0],
|
||||||
|
white: Palette[number] = [255, 255, 255];
|
||||||
|
return {
|
||||||
|
...this.specialContext(pages, take),
|
||||||
|
sound: this.host.sound,
|
||||||
|
gameTime: () => {
|
||||||
|
if (!this.host.gameTime) throw new Error("No game timer.");
|
||||||
|
return this.host.gameTime();
|
||||||
|
},
|
||||||
|
female: this.host.player().gender === 1,
|
||||||
|
mapPalette: this.host.tileset?.().palettes[0] ?? [white, white, black, black],
|
||||||
|
};
|
||||||
|
}
|
||||||
/** PlayMonCry: it plays with the pending page, else now, and waits. */
|
/** PlayMonCry: it plays with the pending page, else now, and waits. */
|
||||||
private *monCry(species: number): Generator<ScriptEffect, void, Input> {
|
private *monCry(species: number): Generator<ScriptEffect, void, Input> {
|
||||||
this.cue(() => this.host.sound!.playMonCry2(species));
|
this.cue(() => this.host.sound!.playMonCry2(species));
|
||||||
@@ -3755,6 +3809,9 @@ export class ScriptRun {
|
|||||||
);
|
);
|
||||||
return Number(solved);
|
return Number(solved);
|
||||||
}
|
}
|
||||||
|
case KANTO.ProfOaksPCBoot:
|
||||||
|
yield* profOaksPCBoot(this.specialContext(pages, take));
|
||||||
|
return value;
|
||||||
case JOHTO.SlotMachine:
|
case JOHTO.SlotMachine:
|
||||||
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
|
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -0,0 +1,762 @@
|
|||||||
|
import type { GameSound } from "../audio/sound";
|
||||||
|
import { SOUND } from "../audio/constants";
|
||||||
|
import { addHallOfFameEntry, type HallOfFameMon } from "../party/hall-of-fame";
|
||||||
|
import { textTiles } from "../rom/font";
|
||||||
|
import { decompress } from "../rom/lz";
|
||||||
|
import { PokemonData } from "../rom/pokemon";
|
||||||
|
import { JOHTO, KANTO, PC, RAM } from "../rom/profile";
|
||||||
|
import type { RomReader } from "../rom/reader";
|
||||||
|
import type { GameTime } from "../world/game-time";
|
||||||
|
import type { Palette } from "../world/types";
|
||||||
|
import {
|
||||||
|
displayHOFMon,
|
||||||
|
hallOfFameTiles,
|
||||||
|
placeText,
|
||||||
|
placeTextbox,
|
||||||
|
} from "./hall-of-fame";
|
||||||
|
import { blankScreen, rgb, SCREEN_WIDTH, type TileScreen } from "./screen";
|
||||||
|
import type { Gen, SpecialContext } from "./special-context";
|
||||||
|
import { defaultTextContext, readString, UnsupportedText } from "./text";
|
||||||
|
|
||||||
|
const SPACE = 0x7f;
|
||||||
|
/** PlacePOKe's "POKé". */
|
||||||
|
const POKE = [0x8f, 0x8e, 0x8a, 0xea];
|
||||||
|
/** HALLOFFAME_COLON: HOF_AnimatePlayerPic's colon tile. */
|
||||||
|
const HALLOFFAME_COLON = 0x63;
|
||||||
|
/** The ▼ LoadBlinkingCursor places at (18, 17) while text waits. */
|
||||||
|
const CURSOR = 0xee;
|
||||||
|
|
||||||
|
/** One state of the text box while PrintText runs: its two lines as
|
||||||
|
* tiles, and whether the text waits for A or B there (cont, para and
|
||||||
|
* prompt) or ends with this page shown (done, text_end). */
|
||||||
|
export interface BoxPage {
|
||||||
|
lines: [number[], number[]];
|
||||||
|
wait: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PrintText's text box, from the ROM's text commands and characters as
|
||||||
|
* tiles (so a ligature such as "'s" keeps its one tile): text_start's
|
||||||
|
* characters, `line`, `cont`, `para`, `prompt` and `done`, text_ram and
|
||||||
|
* text_far. */
|
||||||
|
export function textboxPages(
|
||||||
|
r: RomReader,
|
||||||
|
start: number,
|
||||||
|
ram: (address: number) => string,
|
||||||
|
player: string,
|
||||||
|
): BoxPage[] {
|
||||||
|
const pages: BoxPage[] = [];
|
||||||
|
let lines: [number[], number[]] = [[], []],
|
||||||
|
line = 0;
|
||||||
|
const snap = (wait: boolean) =>
|
||||||
|
pages.push({ lines: [[...lines[0]], [...lines[1]]], wait });
|
||||||
|
/** True when the text ended (done or prompt). */
|
||||||
|
const run = (p: number, depth: number): boolean => {
|
||||||
|
if (depth > 4) throw new Error("Text calls exceed the recursion limit.");
|
||||||
|
for (let count = 0; count < 4096; count++) {
|
||||||
|
const offset = p,
|
||||||
|
command = r.byte(p++);
|
||||||
|
if (command === 0x50) return false;
|
||||||
|
if (command === 0x01) {
|
||||||
|
lines[line].push(...textTiles(ram(r.word(p))));
|
||||||
|
p += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command === 0x16) {
|
||||||
|
if (run(r.near(p, r.byte(p + 2)), depth + 1)) return true;
|
||||||
|
p += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command !== 0x00) throw new UnsupportedText(offset, command);
|
||||||
|
for (let length = 0; length < 1024; length++) {
|
||||||
|
const at = p,
|
||||||
|
code = r.byte(p++);
|
||||||
|
if (code === 0x50) break;
|
||||||
|
if (code === 0x4f) line = 1;
|
||||||
|
else if (code === 0x55) {
|
||||||
|
snap(true);
|
||||||
|
lines = [lines[1], []];
|
||||||
|
line = 1;
|
||||||
|
} else if (code === 0x51) {
|
||||||
|
snap(true);
|
||||||
|
lines = [[], []];
|
||||||
|
line = 0;
|
||||||
|
} else if (code === 0x57) {
|
||||||
|
snap(false);
|
||||||
|
return true;
|
||||||
|
} else if (code === 0x58) {
|
||||||
|
snap(true);
|
||||||
|
return true;
|
||||||
|
} else if (code === 0x54) lines[line].push(...POKE);
|
||||||
|
else if (code === 0x52) lines[line].push(...textTiles(player));
|
||||||
|
else if (code >= 0x60) lines[line].push(code);
|
||||||
|
else throw new UnsupportedText(at, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Text command limit exceeded.");
|
||||||
|
};
|
||||||
|
if (!run(start, 0)) snap(false);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page in the standard text box at the bottom (rows 12-17): its lines
|
||||||
|
* at (1, 14) and (1, 16), and the ▼ while it waits. */
|
||||||
|
export function drawBoxPage(screen: TileScreen, page: BoxPage) {
|
||||||
|
placeTextbox(screen, 0, 12, 4);
|
||||||
|
page.lines.forEach((tiles, row) =>
|
||||||
|
screen.map.splice((14 + row * 2) * SCREEN_WIDTH + 1, tiles.length, ...tiles),
|
||||||
|
);
|
||||||
|
if (page.wait) screen.map[17 * SCREEN_WIDTH + 18] = CURSOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OakRatings: the rating for a caught count, its sound and its text. */
|
||||||
|
export function oakRating(r: RomReader, caught: number) {
|
||||||
|
for (let entry = PC.OakRatings; ; entry += 5) {
|
||||||
|
if (r.byte(entry) >= caught)
|
||||||
|
return { sfx: r.word(entry + 1), text: r.near(entry + 3, r.bank(entry)) };
|
||||||
|
if (entry > PC.OakRatings + 5 * 32) throw new Error("No Oak rating.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Rate's .UpdateRatingBuffers: the seen and caught counts it prints. */
|
||||||
|
export function ratingCounts(context: SpecialContext) {
|
||||||
|
const { seen, caught } = context.state.pokedex;
|
||||||
|
return { seen: seen.size, caught: caught.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ProfOaksPCBoot (Professor Oak's PC in his lab): the completion level,
|
||||||
|
* the counts and the rating with its sound, each page waited for. */
|
||||||
|
export function* profOaksPCBoot(context: SpecialContext): Gen {
|
||||||
|
const { seen, caught } = ratingCounts(context);
|
||||||
|
context.strings[RAM.stringBuffer3] = String(seen);
|
||||||
|
context.strings[RAM.stringBuffer4] = String(caught);
|
||||||
|
context.write(PC.OakPCText2);
|
||||||
|
context.write(PC.OakPCText3);
|
||||||
|
yield* context.flush();
|
||||||
|
const rating = oakRating(context.reader, caught);
|
||||||
|
context.write(rating.text);
|
||||||
|
context.playSFX(rating.sfx);
|
||||||
|
yield* context.flush();
|
||||||
|
yield* context.waitSFX();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the ceremony and the credits need beyond a special's context. */
|
||||||
|
export interface CeremonyContext extends SpecialContext {
|
||||||
|
sound?: GameSound;
|
||||||
|
gameTime(): GameTime;
|
||||||
|
female: boolean;
|
||||||
|
/** The map's BG palette 0, which SetDefaultBGPAndOBP shows again under
|
||||||
|
* "SAVING RECORD…" (wBGPals1 still holds the map's palettes). */
|
||||||
|
mapPalette: Palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frame = Gen<string>;
|
||||||
|
/** One video frame of a screen that runs on its own; the answer is the
|
||||||
|
* button pressed meanwhile ("" for none). */
|
||||||
|
function* frame(screen: TileScreen): Frame {
|
||||||
|
const button = yield { kind: "screen", screen, realtime: true };
|
||||||
|
return typeof button === "string" ? button : "";
|
||||||
|
}
|
||||||
|
function* frames(screen: () => TileScreen, count: number): Gen {
|
||||||
|
for (let i = 0; i < count; i++) yield* frame(screen());
|
||||||
|
}
|
||||||
|
/** JoyWaitAorB, with the screen shown. */
|
||||||
|
function* waitAorB(screen: TileScreen): Gen {
|
||||||
|
for (;;) {
|
||||||
|
const button = yield* frame(screen);
|
||||||
|
if (button === "a" || button === "b") return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** PrintText's pages over a screen (waiting at each cont, para and
|
||||||
|
* prompt); returns the screen with the last page left shown. */
|
||||||
|
function* printText(base: () => TileScreen, pages: BoxPage[]): Gen<TileScreen> {
|
||||||
|
let screen = base();
|
||||||
|
for (const page of pages) {
|
||||||
|
screen = base();
|
||||||
|
drawBoxPage(screen, page);
|
||||||
|
if (page.wait) yield* waitAorB(screen);
|
||||||
|
}
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A blank Hall of Fame screen (spaces, one palette). */
|
||||||
|
function hofScreen(tiles: Uint8Array, key: string, palette: Palette, label: string) {
|
||||||
|
const screen = blankScreen(tiles, key, [palette], label);
|
||||||
|
screen.map.fill(SPACE);
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
const WHITE: Palette = [
|
||||||
|
[255, 255, 255],
|
||||||
|
[255, 255, 255],
|
||||||
|
[255, 255, 255],
|
||||||
|
[255, 255, 255],
|
||||||
|
];
|
||||||
|
/** PlaceGraphic: a picture's tiles column by column from `first`. */
|
||||||
|
function placeGraphic(screen: TileScreen, x: number, y: number, width: number, height: number, first: number) {
|
||||||
|
for (let column = 0; column < width; column++)
|
||||||
|
for (let row = 0; row < height; row++)
|
||||||
|
screen.map[(y + row) * SCREEN_WIDTH + x + column] = first + column * height + row;
|
||||||
|
}
|
||||||
|
/** The background moved by hSCX/hSCY over a map of spaces. */
|
||||||
|
const scrolled = (screen: TileScreen, scx: number, scy: number): TileScreen => ({
|
||||||
|
...screen,
|
||||||
|
scroll: { x: scx, y: scy, fill: SPACE },
|
||||||
|
});
|
||||||
|
/** HOF_SlideBackpic: hSCX up by 4 a frame from $90 until $70 (wrapping),
|
||||||
|
* the back picture passing right to left over the lower screen at
|
||||||
|
* hSCY $d0; then HOF_SlideFrontpic's front picture from hSCX `from` down
|
||||||
|
* by 2 a frame to 0. */
|
||||||
|
function* slides(back: TileScreen, front: TileScreen, from: number): Gen {
|
||||||
|
yield* frame(scrolled(back, 0x90, 0xd0));
|
||||||
|
for (let scx = 0x90; scx !== 0x70; ) {
|
||||||
|
scx = (scx + 4) & 0xff;
|
||||||
|
yield* frame(scrolled(back, scx, 0xd0));
|
||||||
|
}
|
||||||
|
yield* frame(scrolled(front, from, 0));
|
||||||
|
for (let scx = from; scx; ) {
|
||||||
|
scx = (scx - 2) & 0xff;
|
||||||
|
yield* frame(scrolled(front, scx, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HallOfFame (engine/events/halloffame.asm), from the `halloffame`
|
||||||
|
* command: the music fades as "SAVING RECORD…" shows, the League win is
|
||||||
|
* counted and the party recorded (STATUSFLAGS_HALL_OF_FAME_F set), each
|
||||||
|
* Pokémon's back picture passes and its front picture slides in as a "New
|
||||||
|
* Hall of Famer!" with its cry, then the player with the play time and
|
||||||
|
* Professor Oak's rating, and the credits. The game's own save is the
|
||||||
|
* world's: the script's state commits when it ends. */
|
||||||
|
export function* hallOfFame(context: CeremonyContext): Gen {
|
||||||
|
const r = context.reader,
|
||||||
|
data = context.data,
|
||||||
|
state = context.state,
|
||||||
|
sound = context.sound;
|
||||||
|
// Credits: B shortens them only for a team that had entered before.
|
||||||
|
const skippable = state.hallOfFame;
|
||||||
|
// HallOfFame_FadeOutMusic: MUSIC_NONE at fade rate 10, the screen white,
|
||||||
|
// InitDisplayForHallOfFame's text, then 100 frames.
|
||||||
|
sound?.fadeOutTo(SOUND.MUSIC_NONE, 10);
|
||||||
|
const frameOption = state.options.textboxFrame;
|
||||||
|
const tiles = hallOfFameTiles(data, frameOption);
|
||||||
|
const saving = hofScreen(tiles, `hof-saving-${frameOption}`, WHITE, "Saving record");
|
||||||
|
const savingText = textboxPages(r, KANTO["InitDisplayForHallOfFame.SavingRecordText"], () => "", context.player().name);
|
||||||
|
drawBoxPage(saving, savingText[savingText.length - 1]);
|
||||||
|
saving.palettes = [context.mapPalette];
|
||||||
|
yield* frames(() => saving, 100);
|
||||||
|
state.hallOfFame = true;
|
||||||
|
addHallOfFameEntry(state, state.party);
|
||||||
|
const team = state.hallOfFameTeams[0];
|
||||||
|
// AnimateHallOfFame: MUSIC_HALL_OF_FAME after one frame of MUSIC_NONE.
|
||||||
|
sound?.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
yield* frame(saving);
|
||||||
|
sound?.playMusic(SOUND.MUSIC_HALL_OF_FAME);
|
||||||
|
for (const mon of team.mons) {
|
||||||
|
yield* monEntrance(context, mon);
|
||||||
|
// The cry and 60 frames (HOF_AnimateFrontpic's ANIM_MON_HOF animation
|
||||||
|
// is not ported).
|
||||||
|
const page = newHallOfFamerScreen(data, mon, frameOption);
|
||||||
|
sound?.playMonCry2(mon.species);
|
||||||
|
yield* frames(() => page, 60);
|
||||||
|
}
|
||||||
|
yield* playerPicture(context);
|
||||||
|
// RotateThreePalettesRight to white, then 8 frames.
|
||||||
|
yield* frames(() => hofScreen(tiles, "hof-white", WHITE, ""), 12);
|
||||||
|
yield* credits(context, skippable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AnimateHallOfFame.DisplayNewHallOfFamer: DisplayHOFMon's page with
|
||||||
|
* "New Hall of Famer!" at (1, 2). */
|
||||||
|
export function newHallOfFamerScreen(data: PokemonData, mon: HallOfFameMon, frame: number) {
|
||||||
|
const title = readString(data.reader, KANTO["AnimateHallOfFame.String_NewHallOfFamer"], defaultTextContext(""));
|
||||||
|
const page = displayHOFMon(data, mon, frame, title);
|
||||||
|
placeText(page, 1, 2, title);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HOF_AnimatePlayerPic's card once the front picture is in place: the
|
||||||
|
* player's picture at (12, 5), the boxes, the name, the ID and the play
|
||||||
|
* time (three digits, HALLOFFAME_COLON, two digits), over the Hall of
|
||||||
|
* Fame tiles with the colon and the player's back picture loaded. */
|
||||||
|
export function hallOfFamePlayerCard(
|
||||||
|
data: PokemonData,
|
||||||
|
player: { name: string; id: number; female: boolean; time: GameTime },
|
||||||
|
frame: number,
|
||||||
|
): TileScreen {
|
||||||
|
const r = data.reader,
|
||||||
|
{ name, id, female, time } = player;
|
||||||
|
const tiles = playerTiles(data, female, frame);
|
||||||
|
tiles.set(data.playerPic(female));
|
||||||
|
const screen = hofScreen(
|
||||||
|
tiles,
|
||||||
|
`hof-player-${female ? "kris" : "chris"}-${frame}`,
|
||||||
|
data.trainerPalette(female ? 1 : 0),
|
||||||
|
`${name}, ID ${String(id).padStart(5, "0")}, play time ${time.hours}:${String(time.minutes).padStart(2, "0")}`,
|
||||||
|
);
|
||||||
|
placeGraphic(screen, 12, 5, 7, 7, 0);
|
||||||
|
placeTextbox(screen, 0, 2, 8, 9);
|
||||||
|
placeTextbox(screen, 0, 12, 4);
|
||||||
|
placeText(screen, 2, 4, name);
|
||||||
|
screen.map.splice(6 * SCREEN_WIDTH + 1, 3, 0x73, 0x74, 0xf3);
|
||||||
|
placeText(screen, 4, 6, String(id).padStart(5, "0"));
|
||||||
|
placeText(screen, 1, 8, readString(r, KANTO["HOF_AnimatePlayerPic.PlayTime"], defaultTextContext(name)));
|
||||||
|
placeText(screen, 3, 9, String(time.hours).padStart(3, " "));
|
||||||
|
screen.map[9 * SCREEN_WIDTH + 6] = HALLOFFAME_COLON;
|
||||||
|
placeText(screen, 7, 9, String(time.minutes).padStart(2, "0"));
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
/** The Hall of Fame tiles with HOF_AnimatePlayerPic's colon (FontExtra +
|
||||||
|
* 13 tiles) at HALLOFFAME_COLON and the back picture from $31. */
|
||||||
|
function playerTiles(data: PokemonData, female: boolean, frame: number) {
|
||||||
|
const r = data.reader;
|
||||||
|
const tiles = hallOfFameTiles(data, frame);
|
||||||
|
tiles.set(r.slice(JOHTO.FontExtra + 13 * 16, 16), HALLOFFAME_COLON * 16);
|
||||||
|
// GetKrisBackpic copies Kris's uncompressed picture; Chris's is LZ.
|
||||||
|
const back = female ? r.slice(KANTO.KrisBackpic, 36 * 16) : decompress(r, KANTO.ChrisBackpic, 0x2000);
|
||||||
|
if (back.length < 36 * 16) throw new Error("Picture data is truncated.");
|
||||||
|
tiles.set(back.slice(0, 36 * 16), 0x31 * 16);
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AnimateHOFMonEntrance: the screen white for a frame, the back picture
|
||||||
|
* (6x6 at (6, 6), tiles from $31) passing, then the front picture (7x7
|
||||||
|
* at (6, 5)) sliding in from the left, in the Pokémon's palette. */
|
||||||
|
function* monEntrance(context: CeremonyContext, mon: HallOfFameMon): Gen {
|
||||||
|
const data = context.data,
|
||||||
|
letter = PokemonData.unownLetter(mon.dvs),
|
||||||
|
palette = data.palette(mon.species, PokemonData.shiny(mon.dvs));
|
||||||
|
const tiles = hallOfFameTiles(data, context.state.options.textboxFrame);
|
||||||
|
tiles.set(data.frontPic(mon.species, letter));
|
||||||
|
tiles.set(data.backPic(mon.species, letter), 0x31 * 16);
|
||||||
|
const key = `hof-entrance-${mon.species}-${mon.dvs}`;
|
||||||
|
yield* frame(hofScreen(tiles, `${key}-white`, WHITE, ""));
|
||||||
|
const back = hofScreen(tiles, `${key}-back`, palette, data.speciesName(mon.species));
|
||||||
|
placeGraphic(back, 6, 6, 6, 6, 0x31);
|
||||||
|
const front = hofScreen(tiles, `${key}-front`, palette, data.speciesName(mon.species));
|
||||||
|
placeGraphic(front, 6, 5, 7, 7, 0);
|
||||||
|
yield* slides(back, front, 0x70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HOF_AnimatePlayerPic: the player's back picture passes, the front
|
||||||
|
* picture slides in at (12, 5), then the name, ID and play time, and
|
||||||
|
* ProfOaksPCRating (the music stops for the rating's sound). */
|
||||||
|
function* playerPicture(context: CeremonyContext): Gen {
|
||||||
|
const r = context.reader,
|
||||||
|
data = context.data,
|
||||||
|
female = context.female,
|
||||||
|
frameOption = context.state.options.textboxFrame,
|
||||||
|
palette = data.trainerPalette(female ? 1 : 0);
|
||||||
|
const tiles = playerTiles(data, female, frameOption);
|
||||||
|
const key = `hof-player-${female ? "kris" : "chris"}`;
|
||||||
|
yield* frame(hofScreen(tiles, `${key}-white`, WHITE, ""));
|
||||||
|
const backScreen = hofScreen(tiles, `${key}-back`, palette, context.player().name);
|
||||||
|
placeGraphic(backScreen, 6, 6, 6, 6, 0x31);
|
||||||
|
const frontTiles = tiles.slice();
|
||||||
|
frontTiles.set(data.playerPic(female));
|
||||||
|
const front = hofScreen(frontTiles, `${key}-front`, palette, context.player().name);
|
||||||
|
placeGraphic(front, 12, 5, 7, 7, 0);
|
||||||
|
yield* slides(backScreen, front, 0xc0);
|
||||||
|
const player = { ...context.player(), female, time: context.gameTime() };
|
||||||
|
const card = () => hallOfFamePlayerCard(data, player, frameOption);
|
||||||
|
// ProfOaksPCRating: Rate, MUSIC_NONE, the rating's sound, A or B, and
|
||||||
|
// WaitSFX.
|
||||||
|
const { seen, caught } = ratingCounts(context);
|
||||||
|
const ram = (address: number) => {
|
||||||
|
if (address === RAM.stringBuffer3) return String(seen);
|
||||||
|
if (address === RAM.stringBuffer4) return String(caught);
|
||||||
|
throw new Error(`Text reads RAM 0x${address.toString(16)}, which has no native value.`);
|
||||||
|
};
|
||||||
|
const name = context.player().name;
|
||||||
|
let shown = yield* printText(card, textboxPages(r, PC.OakPCText3, ram, name));
|
||||||
|
yield* waitAorB(shown);
|
||||||
|
const rating = oakRating(r, caught);
|
||||||
|
shown = yield* printText(card, textboxPages(r, rating.text, ram, name));
|
||||||
|
context.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
context.sound?.playSFX(rating.sfx);
|
||||||
|
yield* waitAorB(shown);
|
||||||
|
while (context.sound?.sfxPlaying) yield* frame(shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ credits
|
||||||
|
|
||||||
|
const CREDITS_END = 0xff,
|
||||||
|
CREDITS_WAIT = 0xfe,
|
||||||
|
CREDITS_SCENE = 0xfd,
|
||||||
|
CREDITS_CLEAR = 0xfc,
|
||||||
|
CREDITS_MUSIC = 0xfb,
|
||||||
|
CREDITS_WAIT2 = 0xfa,
|
||||||
|
CREDITS_THEEND = 0xf9;
|
||||||
|
/** constants/credits_constants.asm: the one string ParseCredits prints
|
||||||
|
* from column 2 rather than 0. */
|
||||||
|
const COPYRIGHT = 0x62;
|
||||||
|
/** How many VBlanks pass while ParseCredits runs each group of commands
|
||||||
|
* (one per wait), measured on the cartridge (tests/reference-credits.spec
|
||||||
|
* .ts): printing a name takes about 60 of a frame's 154 lines, and how
|
||||||
|
* long VBlank_Credits takes depends on _UpdateSound's work for the music,
|
||||||
|
* so the spills are recorded rather than counted from cycles. They hold
|
||||||
|
* for credits that B does not shorten. CREDITS_MUSIC's own DelayFrame is
|
||||||
|
* not counted here. */
|
||||||
|
const PARSE_SPILLS = [
|
||||||
|
1, 0, 0, 1, 1, 2, 2, 1, 3, 2, 1, 0, 3, 1, 2, 2, 2, 2, 1, 1, 1, 0, 2, 1, 2, 1, 2, 1, 0, 1, 1, 1,
|
||||||
|
1, 1, 1, 1, 2, 2, 2, 1, 2, 1, 1, 1, 0, 0,
|
||||||
|
];
|
||||||
|
/** Credits' jumptable (Credits_Jumptable.Jumptable), one entry a frame. */
|
||||||
|
const enum Step {
|
||||||
|
Parse = 0,
|
||||||
|
PrepBGMapUpdate = 3,
|
||||||
|
UpdateGFX = 4,
|
||||||
|
RequestGFX = 5,
|
||||||
|
LYOverride = 6,
|
||||||
|
UpdateGFX2 = 10,
|
||||||
|
RequestGFX2 = 11,
|
||||||
|
LoopBack = 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credits (engine/movie/credits.asm) frame by frame, as VBlank_Credits
|
||||||
|
* presents it: the parser waits on wCreditsTimer (one tick per 13-frame
|
||||||
|
* jumptable cycle), prints into wTilemap, and the text reaches the screen
|
||||||
|
* a third per VBlank; the banner's 16 tiles arrive 8 per VBlank; a
|
||||||
|
* palette update takes its VBlank whole; the borders scroll right by 2
|
||||||
|
* pixels a cycle through wLYOverrides (the bottom border the same frame,
|
||||||
|
* the top one the next). */
|
||||||
|
export class CreditsMachine {
|
||||||
|
private index = 0;
|
||||||
|
private group = 0;
|
||||||
|
private exit = false;
|
||||||
|
private pos = 0;
|
||||||
|
private timer = 0;
|
||||||
|
private scene = 0;
|
||||||
|
private borderFrame = 0xff;
|
||||||
|
private lyOverride = 0;
|
||||||
|
private topOverride = 0;
|
||||||
|
private bgMode = 0;
|
||||||
|
private third = 0;
|
||||||
|
private request: { size: number; source: number; dest: number; data: Uint8Array } = {
|
||||||
|
size: 0,
|
||||||
|
source: 0,
|
||||||
|
dest: 0,
|
||||||
|
data: new Uint8Array(),
|
||||||
|
};
|
||||||
|
private palUpdate = false;
|
||||||
|
readonly tilemap: number[];
|
||||||
|
readonly shown: number[];
|
||||||
|
readonly vram: Uint8Array;
|
||||||
|
private palettes: Palette[];
|
||||||
|
private readonly frames: Uint8Array;
|
||||||
|
private readonly blank: Uint8Array;
|
||||||
|
/** The sound to play when the parser reaches CREDITS_MUSIC or the end. */
|
||||||
|
onMusic?: (music: "credits" | "post") => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly r: RomReader,
|
||||||
|
base: Uint8Array,
|
||||||
|
readonly skippable: boolean,
|
||||||
|
) {
|
||||||
|
this.vram = base.slice();
|
||||||
|
// Credits_LoadBorderGFX's .init: wCreditsBlankFrame2bpp, 16 tiles of
|
||||||
|
// color 2.
|
||||||
|
this.blank = new Uint8Array(16 * 16);
|
||||||
|
for (let i = 0; i < this.blank.length; i += 2) {
|
||||||
|
this.blank[i] = 0;
|
||||||
|
this.blank[i + 1] = 0xff;
|
||||||
|
}
|
||||||
|
this.vram.set(r.slice(KANTO.CreditsBorderGFX, 9 * 16), 0x20 * 16);
|
||||||
|
this.vram.set(r.slice(KANTO.CopyrightGFX, 29 * 16), 0x60 * 16);
|
||||||
|
this.vram.set(r.slice(KANTO.TheEndGFX, 16 * 16), 0x40 * 16);
|
||||||
|
this.vram.set(this.blank, 0);
|
||||||
|
this.frames = r.slice(KANTO.CreditsPichuGFX, 4 * 64 * 16);
|
||||||
|
// ConstructCreditsTilemap.
|
||||||
|
this.tilemap = Array<number>(360).fill(SPACE);
|
||||||
|
for (let x = 0; x < SCREEN_WIDTH; x++) {
|
||||||
|
this.tilemap[4 * SCREEN_WIDTH + x] = 0x24 + (x & 3);
|
||||||
|
this.tilemap[17 * SCREEN_WIDTH + x] = 0x20 + (x & 3);
|
||||||
|
}
|
||||||
|
for (let block = 0; block < 5; block++)
|
||||||
|
for (let row = 0; row < 4; row++)
|
||||||
|
for (let col = 0; col < 4; col++)
|
||||||
|
this.tilemap[row * SCREEN_WIDTH + block * 4 + col] = row * 4 + col;
|
||||||
|
this.shown = [...this.tilemap];
|
||||||
|
this.palettes = this.creditsPalettes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetCreditsPalette: the scene's three palettes. */
|
||||||
|
private creditsPalettes(): Palette[] {
|
||||||
|
const base = KANTO.CreditsPalettes + (this.scene & 3) * 24;
|
||||||
|
return Array.from({ length: 3 }, (_, palette) =>
|
||||||
|
Array.from({ length: 4 }, (_, color) => rgb(this.r.word(base + palette * 8 + color * 2))),
|
||||||
|
) as Palette[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wJumptableIndex's step, wCreditsPos, wCreditsTimer, the banner's
|
||||||
|
* scene and frame and wCreditsLYOverride, for comparisons. */
|
||||||
|
debug() {
|
||||||
|
return {
|
||||||
|
index: this.index,
|
||||||
|
exit: this.exit,
|
||||||
|
pos: this.pos,
|
||||||
|
timer: this.timer,
|
||||||
|
scene: this.scene,
|
||||||
|
borderFrame: this.borderFrame,
|
||||||
|
lyOverride: this.lyOverride,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** wBGPals2's first three palettes as stored words. */
|
||||||
|
paletteWords(): number[] {
|
||||||
|
return this.palettes.flatMap((palette) =>
|
||||||
|
palette.map(([red, green, blue]) =>
|
||||||
|
Math.round((red * 31) / 255) | (Math.round((green * 31) / 255) << 5) | (Math.round((blue * 31) / 255) << 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen as this frame draws it. */
|
||||||
|
screen(): TileScreen {
|
||||||
|
const attrs = Array.from({ length: 360 }, (_, i) => {
|
||||||
|
const row = Math.floor(i / SCREEN_WIDTH);
|
||||||
|
return row < 4 ? 0 : row === 4 || row === 17 ? 1 : 2;
|
||||||
|
});
|
||||||
|
const border = (first: number) => Array.from({ length: 32 }, (_, x) => first + (x & 3));
|
||||||
|
return {
|
||||||
|
tiles: this.vram.slice(),
|
||||||
|
map: [...this.shown],
|
||||||
|
attrs,
|
||||||
|
palettes: this.palettes,
|
||||||
|
sprites: [],
|
||||||
|
objPalettes: this.palettes,
|
||||||
|
tileset: `credits-${Array.from(this.vram.subarray(0, 16 * 16)).join(",")}`,
|
||||||
|
label: this.label(),
|
||||||
|
rowScroll: [
|
||||||
|
{ row: 4, x: this.topOverride, tiles: border(0x24) },
|
||||||
|
{ row: 17, x: this.lyOverride, tiles: border(0x20) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** The text shown, for the screen's description. */
|
||||||
|
private label() {
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (let row = 5; row < 17; row++) {
|
||||||
|
const text = this.shown
|
||||||
|
.slice(row * SCREEN_WIDTH, (row + 1) * SCREEN_WIDTH)
|
||||||
|
.map((tile) => CHARS[tile] ?? " ")
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
if (text) rows.push(text);
|
||||||
|
}
|
||||||
|
return rows.join(", ") || "Credits";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frame's logic before its VBlank: the buttons held, then one
|
||||||
|
* jumptable entry. Returns true when A ends the credits. It yields at
|
||||||
|
* each VBlank that passes inside the entry: CREDITS_MUSIC's DelayFrame
|
||||||
|
* ("music", after MUSIC_NONE) and a parse running into later frames. */
|
||||||
|
*step(held: string): Generator<"music" | "frame", boolean, void> {
|
||||||
|
// Credits_HandleBButton: B makes the timer run faster once the first
|
||||||
|
// 13 script bytes have passed.
|
||||||
|
if (held === "b" && this.skippable && this.pos >= 0x0d && this.timer) this.timer--;
|
||||||
|
// Credits_HandleAButton.
|
||||||
|
if (held === "a" && this.exit) return true;
|
||||||
|
switch (this.index & 0xf) {
|
||||||
|
case Step.Parse:
|
||||||
|
yield* this.parse();
|
||||||
|
break;
|
||||||
|
case Step.PrepBGMapUpdate:
|
||||||
|
this.bgMode = 0;
|
||||||
|
this.index++;
|
||||||
|
break;
|
||||||
|
case Step.UpdateGFX:
|
||||||
|
case Step.UpdateGFX2:
|
||||||
|
this.request = { ...this.borderGraphics(), dest: 0, size: 8 };
|
||||||
|
this.index++;
|
||||||
|
break;
|
||||||
|
case Step.RequestGFX:
|
||||||
|
case Step.RequestGFX2:
|
||||||
|
this.request.size = 8;
|
||||||
|
this.index++;
|
||||||
|
break;
|
||||||
|
case Step.LYOverride:
|
||||||
|
this.lyOverride = (this.lyOverride - 2) & 0xff;
|
||||||
|
this.index++;
|
||||||
|
break;
|
||||||
|
case Step.LoopBack:
|
||||||
|
this.index &= 0xf0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.index++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** VBlank_Credits: palettes, else a third of the tilemap and the 2bpp
|
||||||
|
* request; the top border takes the new scroll from the next frame. */
|
||||||
|
vblank() {
|
||||||
|
if (this.palUpdate) {
|
||||||
|
this.palUpdate = false;
|
||||||
|
this.palettes = this.creditsPalettes();
|
||||||
|
} else {
|
||||||
|
if (this.bgMode) {
|
||||||
|
const from = this.third * 6 * SCREEN_WIDTH;
|
||||||
|
for (let i = from; i < from + 6 * SCREEN_WIDTH; i++) this.shown[i] = this.tilemap[i];
|
||||||
|
this.third = (this.third + 1) % 3;
|
||||||
|
}
|
||||||
|
const request = this.request;
|
||||||
|
if (request.size) {
|
||||||
|
this.vram.set(
|
||||||
|
request.data.subarray(request.source * 16, (request.source + request.size) * 16),
|
||||||
|
request.dest * 16,
|
||||||
|
);
|
||||||
|
request.source += request.size;
|
||||||
|
request.dest += request.size;
|
||||||
|
request.size = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.topOverride = this.lyOverride;
|
||||||
|
}
|
||||||
|
/** Credits_LoadBorderGFX: the next of the scene's four frames, or the
|
||||||
|
* blank frame while the banner is cleared. */
|
||||||
|
private borderGraphics() {
|
||||||
|
if (this.borderFrame === 0xff) return { data: this.blank, source: 0 };
|
||||||
|
const frame = this.borderFrame & 3;
|
||||||
|
this.borderFrame = (frame + 1) & 3;
|
||||||
|
return { data: this.frames, source: ((this.scene & 3) * 4 + frame) * 16 };
|
||||||
|
}
|
||||||
|
private get(): number {
|
||||||
|
return this.r.byte(KANTO.CreditsScript + this.pos++);
|
||||||
|
}
|
||||||
|
/** ParseCredits. */
|
||||||
|
private *parse(): Generator<"music" | "frame", void, void> {
|
||||||
|
if (this.exit || this.timer) {
|
||||||
|
if (!this.exit) this.timer--;
|
||||||
|
this.index++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bgMode = 0;
|
||||||
|
this.tilemap.fill(SPACE, 5 * SCREEN_WIDTH, 17 * SCREEN_WIDTH);
|
||||||
|
const spills = PARSE_SPILLS[this.group++] ?? 0;
|
||||||
|
for (let count = 0; count < 256; count++) {
|
||||||
|
const command = this.r.byte(KANTO.CreditsScript + this.pos);
|
||||||
|
// The VBlanks a long parse runs into pass before its last command,
|
||||||
|
// with the tilemap copy still off.
|
||||||
|
if (command === CREDITS_END || command === CREDITS_WAIT || command === CREDITS_WAIT2)
|
||||||
|
for (let spill = 0; spill < spills; spill++) yield "frame";
|
||||||
|
this.pos++;
|
||||||
|
switch (command) {
|
||||||
|
case CREDITS_END:
|
||||||
|
this.exit = true;
|
||||||
|
this.onMusic?.("post");
|
||||||
|
return;
|
||||||
|
case CREDITS_WAIT:
|
||||||
|
this.timer = this.get();
|
||||||
|
this.third = 0;
|
||||||
|
this.bgMode = 1;
|
||||||
|
this.index++;
|
||||||
|
return;
|
||||||
|
case CREDITS_WAIT2:
|
||||||
|
this.timer = this.get();
|
||||||
|
this.index++;
|
||||||
|
return;
|
||||||
|
case CREDITS_SCENE:
|
||||||
|
this.scene = this.get();
|
||||||
|
this.borderFrame = 0;
|
||||||
|
this.palUpdate = true;
|
||||||
|
break;
|
||||||
|
case CREDITS_CLEAR:
|
||||||
|
this.borderFrame = 0xff;
|
||||||
|
break;
|
||||||
|
case CREDITS_MUSIC:
|
||||||
|
// PlayMusic MUSIC_NONE, a frame, then MUSIC_CREDITS.
|
||||||
|
yield "music";
|
||||||
|
this.onMusic?.("credits");
|
||||||
|
break;
|
||||||
|
case CREDITS_THEEND:
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
this.tilemap[9 * SCREEN_WIDTH + 6 + i] = 0x40 + i;
|
||||||
|
this.tilemap[10 * SCREEN_WIDTH + 6 + i] = 0x48 + i;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const pointer = this.r.near(
|
||||||
|
KANTO.CreditsStringsPointers + command * 2,
|
||||||
|
this.r.bank(KANTO.CreditsStringsPointers),
|
||||||
|
);
|
||||||
|
const x = command === COPYRIGHT ? 2 : 0;
|
||||||
|
this.placeString(pointer, x, 6 + this.get() * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Credits script command limit exceeded.");
|
||||||
|
}
|
||||||
|
/** PlaceString: characters as tiles, '#' as POKé, <NEXT> two rows down. */
|
||||||
|
private placeString(p: number, x: number, y: number) {
|
||||||
|
let column = x,
|
||||||
|
line = y;
|
||||||
|
for (let length = 0; length < 64; length++) {
|
||||||
|
const code = this.r.byte(p++);
|
||||||
|
if (code === 0x50) return;
|
||||||
|
if (code === 0x4e) {
|
||||||
|
line += 2;
|
||||||
|
column = x;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tiles = code === 0x54 ? POKE : [code];
|
||||||
|
for (const tile of tiles) {
|
||||||
|
if (line < 18 && column < SCREEN_WIDTH) this.tilemap[line * SCREEN_WIDTH + column] = tile;
|
||||||
|
column++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unterminated credits string.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Tile codes as characters, for the credits' accessible description. */
|
||||||
|
const CHARS: Record<number, string> = (() => {
|
||||||
|
const chars: Record<number, string> = { 0x7f: " ", 0xe3: "-", 0xe8: ".", 0xf4: ",", 0xe9: "&", 0xe6: "?", 0xea: "é", 0xe0: "'", 0x9a: "(", 0x9b: ")", 0xf3: "/" };
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
chars[0x80 + i] = String.fromCharCode(65 + i);
|
||||||
|
chars[0xa0 + i] = String.fromCharCode(97 + i);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 10; i++) chars[0xf6 + i] = String(i);
|
||||||
|
return chars;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** RedCredits (the credits command, after the battle with Red): the
|
||||||
|
* music fades out, the screen turns white (InitDisplayForRedCredits) for
|
||||||
|
* eight frames, and the credits play, which B can shorten. */
|
||||||
|
export function* redCredits(context: CeremonyContext): Gen {
|
||||||
|
context.sound?.fadeOutTo(SOUND.MUSIC_NONE, 10);
|
||||||
|
const tiles = hallOfFameTiles(context.data, context.state.options.textboxFrame);
|
||||||
|
yield* frames(() => hofScreen(tiles, "credits-white", WHITE, ""), 8);
|
||||||
|
yield* credits(context, context.state.hallOfFame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credits, from the Hall of Fame (`skippable` when the team had entered
|
||||||
|
* before) or RedCredits: each frame yields the screen and reads the
|
||||||
|
* button held, until A is pressed after THE END; the screen then turns
|
||||||
|
* white (ClearBGPalettes). B held is read as B pressed. */
|
||||||
|
export function* credits(context: CeremonyContext, skippable: boolean): Gen {
|
||||||
|
const tiles = hallOfFameTiles(context.data, context.state.options.textboxFrame);
|
||||||
|
const machine = new CreditsMachine(context.reader, tiles, skippable);
|
||||||
|
// Credits' setup (ClearBGPalettes, the graphics requests and
|
||||||
|
// ConstructCreditsTilemap's WaitBGMap2s) keeps the screen white for
|
||||||
|
// about 36 frames.
|
||||||
|
yield* frames(() => hofScreen(tiles, "credits-white", WHITE, ""), 36);
|
||||||
|
machine.onMusic = (music) => {
|
||||||
|
const sound = context.sound;
|
||||||
|
if (!sound) return;
|
||||||
|
if (music === "credits") sound.playMusic(SOUND.MUSIC_CREDITS);
|
||||||
|
else sound.fadeOutTo(SOUND.MUSIC_POST_CREDITS, 32);
|
||||||
|
};
|
||||||
|
let held = "";
|
||||||
|
for (let frames = 0; frames < 200_000; frames++) {
|
||||||
|
const logic = machine.step(held);
|
||||||
|
let result = logic.next();
|
||||||
|
while (!result.done) {
|
||||||
|
// A frame shows and its VBlank runs inside the parser.
|
||||||
|
if (result.value === "music") context.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
yield* frame(machine.screen());
|
||||||
|
machine.vblank();
|
||||||
|
result = logic.next();
|
||||||
|
}
|
||||||
|
if (result.value) {
|
||||||
|
yield* frame(hofScreen(tiles, "credits-white", WHITE, ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
held = yield* frame(machine.screen());
|
||||||
|
machine.vblank();
|
||||||
|
}
|
||||||
|
throw new Error("The credits did not end.");
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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 { hallOfFamePC } from "./hall-of-fame";
|
import { hallOfFamePC } from "./hall-of-fame";
|
||||||
|
import { oakRating } from "./league";
|
||||||
import { readMail } from "./mail";
|
import { readMail } from "./mail";
|
||||||
import { printerDisconnected } from "./printer";
|
import { printerDisconnected } from "./printer";
|
||||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||||
@@ -857,10 +858,7 @@ export class PcSession {
|
|||||||
this.host.strings[RAM.stringBuffer3] = String(seen.size);
|
this.host.strings[RAM.stringBuffer3] = String(seen.size);
|
||||||
this.host.strings[RAM.stringBuffer4] = String(caught.size);
|
this.host.strings[RAM.stringBuffer4] = String(caught.size);
|
||||||
yield* this.print(PC.OakPCText3);
|
yield* this.print(PC.OakPCText3);
|
||||||
// FindOakRating: the first entry whose count reaches the owned total.
|
yield* this.print(oakRating(r, caught.size).text);
|
||||||
let entry = PC.OakRatings;
|
|
||||||
while (r.byte(entry) < caught.size) entry += 5;
|
|
||||||
yield* this.print(r.near(entry + 3, r.bank(entry)));
|
|
||||||
}
|
}
|
||||||
yield* this.print(PC.OakPCText4);
|
yield* this.print(PC.OakPCText4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export interface TileScreen {
|
|||||||
* moves left by `x` and 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?: { x?: number; y: number; fill: number };
|
scroll?: { x?: number; y: number; fill: number };
|
||||||
|
/** Tile rows with their own hSCX, as wLYOverrides scroll the credits'
|
||||||
|
* borders: `tiles` are the row's 32 background map tiles (in its first
|
||||||
|
* column's palette), for a row scrolled beyond the tilemap. */
|
||||||
|
rowScroll?: { row: number; x: number; tiles?: number[] }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A GBC color (RGB555, little-endian word) as RGB. */
|
/** A GBC color (RGB555, little-endian word) as RGB. */
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export const OW = {
|
|||||||
MAPSETUP_FLY: 0xfc,
|
MAPSETUP_FLY: 0xfc,
|
||||||
NUM_SPAWNS: 28,
|
NUM_SPAWNS: 28,
|
||||||
SPAWN_INDIGO: 13,
|
SPAWN_INDIGO: 13,
|
||||||
|
SPAWN_NEW_BARK: 14,
|
||||||
|
SPAWN_MT_SILVER: 26,
|
||||||
JOHTO_FLYPOINT: 0,
|
JOHTO_FLYPOINT: 0,
|
||||||
KANTO_FLYPOINT: 12,
|
KANTO_FLYPOINT: 12,
|
||||||
NUM_FLYPOINTS: 24,
|
NUM_FLYPOINTS: 24,
|
||||||
|
|||||||
+18
-2
@@ -779,6 +779,8 @@ export class GameWorld {
|
|||||||
/** wGameTime. It counts from the end of the introduction, through
|
/** wGameTime. It counts from the end of the introduction, through
|
||||||
* scenes, menus and battles, as GameTimer does in every VBlank. */
|
* scenes, menus and battles, as GameTimer does in every VBlank. */
|
||||||
gameTime: GameTime = emptyGameTime();
|
gameTime: GameTime = emptyGameTime();
|
||||||
|
/** halloffame clears GAME_TIMER_COUNTING_F until the credits end. */
|
||||||
|
private gameTimerPaused = false;
|
||||||
state: ScriptState = emptyScriptState();
|
state: ScriptState = emptyScriptState();
|
||||||
dialogue: Dialogue | undefined;
|
dialogue: Dialogue | undefined;
|
||||||
emote: { object: number; emote: number } | undefined;
|
emote: { object: number; emote: number } | undefined;
|
||||||
@@ -2519,6 +2521,8 @@ export class GameWorld {
|
|||||||
phoneService: () => this.assets.map(mapId).phoneService,
|
phoneService: () => this.assets.map(mapId).phoneService,
|
||||||
facingObject: () => this.facingObject(),
|
facingObject: () => this.facingObject(),
|
||||||
day: () => this.gameDay(),
|
day: () => this.gameDay(),
|
||||||
|
gameTime: () => this.gameTime,
|
||||||
|
gameTimer: (counting) => (this.gameTimerPaused = !counting),
|
||||||
tileset: () => ({
|
tileset: () => ({
|
||||||
graphics: this.assets.tileset(this.map).graphics,
|
graphics: this.assets.tileset(this.map).graphics,
|
||||||
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
||||||
@@ -2905,6 +2909,12 @@ export class GameWorld {
|
|||||||
}
|
}
|
||||||
case "appear":
|
case "appear":
|
||||||
case "disappear": {
|
case "disappear": {
|
||||||
|
// DeleteObjectStruct on the player's own struct: the sprite is gone
|
||||||
|
// until the map loads again (Lance leading the champion away).
|
||||||
|
if (effect.object === PLAYER) {
|
||||||
|
this.player.hidden = effect.kind === "disappear";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const object = this.map.objects.find(
|
const object = this.map.objects.find(
|
||||||
(candidate) => candidate.id === effect.object,
|
(candidate) => candidate.id === effect.object,
|
||||||
);
|
);
|
||||||
@@ -3014,6 +3024,7 @@ export class GameWorld {
|
|||||||
const trainer = towerTrainer ?? (opponent && {
|
const trainer = towerTrainer ?? (opponent && {
|
||||||
name: opponent.name,
|
name: opponent.name,
|
||||||
className: opponent.className,
|
className: opponent.className,
|
||||||
|
classId: effect.trainer!.classId,
|
||||||
party: trainers.createParty(opponent, {
|
party: trainers.createParty(opponent, {
|
||||||
otId: this.playerId,
|
otId: this.playerId,
|
||||||
ot: this.name,
|
ot: this.name,
|
||||||
@@ -3637,6 +3648,7 @@ export class GameWorld {
|
|||||||
|
|
||||||
private finishScript() {
|
private finishScript() {
|
||||||
const script = this.script!;
|
const script = this.script!;
|
||||||
|
this.gameTimerPaused = false;
|
||||||
this.soundCues = [];
|
this.soundCues = [];
|
||||||
this.waitingPage = undefined;
|
this.waitingPage = undefined;
|
||||||
if (script.warp) {
|
if (script.warp) {
|
||||||
@@ -3659,6 +3671,7 @@ export class GameWorld {
|
|||||||
y,
|
y,
|
||||||
direction: facing ?? (keep ? this.player.direction : "down"),
|
direction: facing ?? (keep ? this.player.direction : "down"),
|
||||||
motion: undefined,
|
motion: undefined,
|
||||||
|
hidden: false,
|
||||||
};
|
};
|
||||||
this.applyMap(entry, door ? "door" : "warp");
|
this.applyMap(entry, door ? "door" : "warp");
|
||||||
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
|
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
|
||||||
@@ -3721,6 +3734,7 @@ export class GameWorld {
|
|||||||
}
|
}
|
||||||
private abortScript(error: unknown) {
|
private abortScript(error: unknown) {
|
||||||
const script = this.script!;
|
const script = this.script!;
|
||||||
|
this.gameTimerPaused = false;
|
||||||
this.soundCues = [];
|
this.soundCues = [];
|
||||||
this.waitingPage = undefined;
|
this.waitingPage = undefined;
|
||||||
this.battle = undefined;
|
this.battle = undefined;
|
||||||
@@ -4340,8 +4354,9 @@ export class GameWorld {
|
|||||||
this.sound.setStereo(((this.state.options.options >> OW.STEREO) & 1) === 1);
|
this.sound.setStereo(((this.state.options.options >> OW.STEREO) & 1) === 1);
|
||||||
this.sound.frame();
|
this.sound.frame();
|
||||||
this.sound.frame();
|
this.sound.frame();
|
||||||
// GameTimer: two frames per overworld tick, once the game has started.
|
// GameTimer: two frames per overworld tick, once the game has started
|
||||||
if (!this.introPending) {
|
// and while GAME_TIMER_COUNTING_F is set.
|
||||||
|
if (!this.introPending && !this.gameTimerPaused) {
|
||||||
advanceGameTime(this.gameTime);
|
advanceGameTime(this.gameTime);
|
||||||
advanceGameTime(this.gameTime);
|
advanceGameTime(this.gameTime);
|
||||||
}
|
}
|
||||||
@@ -5524,6 +5539,7 @@ export class GameWorld {
|
|||||||
this.avatar = save.avatar;
|
this.avatar = save.avatar;
|
||||||
this.tilesWalked = save.tilesWalked;
|
this.tilesWalked = save.tilesWalked;
|
||||||
this.gameTime = { ...save.gameTime };
|
this.gameTime = { ...save.gameTime };
|
||||||
|
this.gameTimerPaused = false;
|
||||||
this.playerId = save.playerId;
|
this.playerId = save.playerId;
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
this.statsScreen = undefined;
|
this.statsScreen = undefined;
|
||||||
|
|||||||
@@ -168,6 +168,26 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
|
|||||||
write("egg-hatching", world);
|
write("egg-hatching", world);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes hall-of-fame-ceremony: the Hall of Fame's entrance, where Lance's scene and the ceremony start", () => {
|
||||||
|
const a = assets();
|
||||||
|
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
|
||||||
|
world.state.flags.add(ENGINE_FLAGS.pokedex);
|
||||||
|
for (let badge = 0x1b; badge < 0x1b + 8; badge++) world.state.flags.add(badge);
|
||||||
|
world.state.party = [
|
||||||
|
caught(a, world, 157, 60, 0x9abc), // TYPHLOSION
|
||||||
|
caught(a, world, 25, 40, 0x2aaa), // PIKACHU, shiny
|
||||||
|
caught(a, world, 201, 40, 0xffff), // UNOWN
|
||||||
|
{ ...caught(a, world, 175, 5), egg: true }, // an Egg, left out
|
||||||
|
];
|
||||||
|
for (const species of [157, 25, 201]) {
|
||||||
|
world.state.pokedex.seen.add(species);
|
||||||
|
world.state.pokedex.caught.add(species);
|
||||||
|
}
|
||||||
|
// The Hall of Fame's own warp; continuing runs HallOfFameEnterScene.
|
||||||
|
world.restore({ ...world.save(), map: "16:8", x: 4, y: 13, direction: "up", objects: [] });
|
||||||
|
write("hall-of-fame-ceremony", world);
|
||||||
|
});
|
||||||
|
|
||||||
it("writes hall-of-fame: a Pokémon Center whose PC has three teams", () => {
|
it("writes hall-of-fame: a Pokémon Center whose PC has three teams", () => {
|
||||||
const a = assets();
|
const a = assets();
|
||||||
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
|
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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 { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { CreditsMachine, textboxPages } from "../src/game/scripts/league";
|
||||||
|
import { hallOfFameTiles } from "../src/game/scripts/hall-of-fame";
|
||||||
|
import { PC, RAM } from "../src/game/rom/profile";
|
||||||
|
import { tileScreenBackground } from "../src/game/rendering/tile-screen";
|
||||||
|
import { blankScreen } from "../src/game/scripts/screen";
|
||||||
|
import { settle } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
|
||||||
|
describe("tile screens scrolled like the background map", () => {
|
||||||
|
// Tile 1 is solid color 3, tile 0 color 0.
|
||||||
|
const tiles = new Uint8Array(2 * 16);
|
||||||
|
tiles.fill(0xff, 16);
|
||||||
|
const screen = () => {
|
||||||
|
const s = blankScreen(tiles, "t", [[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]], "");
|
||||||
|
s.map[0] = 1; // the top-left tile
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
it("wraps hSCX at 256 pixels, with `fill` beyond the tilemap", () => {
|
||||||
|
const s = screen();
|
||||||
|
s.scroll = { x: 256 - 8, y: 0, fill: 0 };
|
||||||
|
const { indices } = tileScreenBackground(s);
|
||||||
|
// The tile moves right by one tile; the column scrolled in is fill.
|
||||||
|
expect(indices[0]).toBe(0);
|
||||||
|
expect(indices[8]).toBe(3);
|
||||||
|
expect(indices[16]).toBe(0);
|
||||||
|
});
|
||||||
|
it("gives a row its own hSCX and background tiles", () => {
|
||||||
|
const s = screen();
|
||||||
|
s.map[4 * 20] = 1;
|
||||||
|
s.rowScroll = [{ row: 4, x: 0xfe, tiles: Array.from({ length: 32 }, (_, x) => (x === 31 ? 1 : 0)) }];
|
||||||
|
const { indices } = tileScreenBackground(s);
|
||||||
|
// Row 4 shows its 32nd tile's last two columns, then its own tiles.
|
||||||
|
expect(indices[32 * 160]).toBe(3);
|
||||||
|
expect(indices[32 * 160 + 2]).toBe(0);
|
||||||
|
// Row 0 is not scrolled.
|
||||||
|
expect(indices[0]).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("the League with the local ROM", () => {
|
||||||
|
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||||
|
const ROUTE_27 = "24:2",
|
||||||
|
VICTORY_ROAD_GATE = "23:13",
|
||||||
|
HALL_OF_FAME = "16:8",
|
||||||
|
NEW_BARK = "24:4";
|
||||||
|
const world = () => {
|
||||||
|
let seed = 21;
|
||||||
|
return new GameWorld(
|
||||||
|
new CrystalAssets(bytes),
|
||||||
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
|
() => {
|
||||||
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||||
|
return (seed >>> 16) & 255;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const mon = (game: GameWorld, species: number, level = 50) =>
|
||||||
|
generatePartyMon(game.assets.pokemon, {
|
||||||
|
species,
|
||||||
|
level,
|
||||||
|
item: 0,
|
||||||
|
otId: game.playerId,
|
||||||
|
ot: "KAWA",
|
||||||
|
dvs: 0x8888,
|
||||||
|
caughtData: 0,
|
||||||
|
});
|
||||||
|
/** Run the world to the end of its scripts, pressing A on any screen
|
||||||
|
* that waits, and count the ticks the credits' screens take. */
|
||||||
|
const drive = (game: GameWorld, b = false) => {
|
||||||
|
const shown: string[] = [];
|
||||||
|
let screens = 0,
|
||||||
|
still = 0,
|
||||||
|
label = "";
|
||||||
|
for (let tick = 0; tick < 60_000; tick++) {
|
||||||
|
expect(game.notice, game.diagnostic).toBe("");
|
||||||
|
const dialogue = game.dialogue;
|
||||||
|
if (dialogue?.choice?.kind === "screen") {
|
||||||
|
screens++;
|
||||||
|
const now = dialogue.choice.screen.label;
|
||||||
|
if (now !== label) [label, still] = [now, 0];
|
||||||
|
if (!shown.includes(now)) shown.push(now);
|
||||||
|
// B on the credits' pages only (the rating takes B as well as A).
|
||||||
|
if (b && /, /.test(now) && !now.includes("play time")) game.bufferScreenButton("b");
|
||||||
|
if (++still > 60) {
|
||||||
|
game.bufferScreenButton("a");
|
||||||
|
still = 0;
|
||||||
|
}
|
||||||
|
game.tick();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dialogue) {
|
||||||
|
const last = dialogue.index === dialogue.pages.length - 1;
|
||||||
|
if (!shown.includes(dialogue.pages[dialogue.index])) shown.push(dialogue.pages[dialogue.index]);
|
||||||
|
if (last && dialogue.choice) game.choose(0);
|
||||||
|
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
||||||
|
else game.advanceDialogue();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!game.busy) {
|
||||||
|
game.tick();
|
||||||
|
if (!game.busy) return { shown, screens };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
game.tick();
|
||||||
|
}
|
||||||
|
throw new Error("The world did not settle.");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("has Route 27's fisher stop a first step into Kanto", () => {
|
||||||
|
const game = world();
|
||||||
|
game.warpTo(ROUTE_27, 18, 11);
|
||||||
|
settle(game);
|
||||||
|
game.restore({ ...game.save(), x: 18, y: 11, direction: "up" });
|
||||||
|
expect(game.move("up")).toBe(true);
|
||||||
|
const shown = settle(game).join(" ");
|
||||||
|
expect(shown).toContain("Hey!");
|
||||||
|
expect(shown).toContain("Do you know what\nyou just did?");
|
||||||
|
expect(game.state.scenes[ROUTE_27]).toBe(1); // SCENE_ROUTE27_NOOP
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns back a trainer without the eight Johto badges at the Victory Road gate", () => {
|
||||||
|
const game = world();
|
||||||
|
game.warpTo(VICTORY_ROAD_GATE, 10, 12);
|
||||||
|
settle(game);
|
||||||
|
game.restore({ ...game.save(), x: 10, y: 12, direction: "up" });
|
||||||
|
expect(game.move("up")).toBe(true);
|
||||||
|
const shown = settle(game).join(" ");
|
||||||
|
expect(shown).toContain("You don't have all\nthe GYM BADGES of\nJOHTO.");
|
||||||
|
expect({ x: game.x, y: game.y }).toEqual({ x: 10, y: 12 });
|
||||||
|
for (let badge = 0x1b; badge < 0x1b + 8; badge++) game.state.flags.add(badge);
|
||||||
|
game.restore({ ...game.save(), x: 10, y: 12, direction: "up" });
|
||||||
|
expect(game.move("up")).toBe(true);
|
||||||
|
expect(settle(game).join(" ")).toContain("BADGES of JOHTO!");
|
||||||
|
game.restore({ ...game.save(), x: 10, y: 10, direction: "down" });
|
||||||
|
expect(game.move("down")).toBe(true);
|
||||||
|
expect(settle(game)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the team without its Egg, keeps the play time still and wakes in New Bark", () => {
|
||||||
|
const game = world();
|
||||||
|
const egg = { ...mon(game, 175, 5), egg: true };
|
||||||
|
game.state.party = [mon(game, 157, 60), egg, mon(game, 147, 30)];
|
||||||
|
game.warpTo(HALL_OF_FAME, 4, 13);
|
||||||
|
let before: { hours: number; minutes: number; seconds: number } | undefined;
|
||||||
|
const { shown } = (() => {
|
||||||
|
// Note the play time when the ceremony's first screen appears.
|
||||||
|
for (let tick = 0; tick < 2000 && !before; tick++) {
|
||||||
|
if (game.dialogue?.choice?.kind === "screen") before = { ...game.gameTime };
|
||||||
|
else if (game.dialogue) game.advanceDialogue();
|
||||||
|
else game.tick();
|
||||||
|
}
|
||||||
|
return drive(game);
|
||||||
|
})();
|
||||||
|
expect(before).toBeDefined();
|
||||||
|
expect(shown.join(" ")).toContain("Saving record");
|
||||||
|
expect(shown).toContain("New Hall of Famer!: TYPHLOSION, TYPHLOSION, level 60, ID " + String(game.playerId).padStart(5, "0"));
|
||||||
|
expect(shown.some((label) => label.startsWith("STAFF") || label.includes("CRYSTAL VERSION"))).toBe(true);
|
||||||
|
expect(game.state.hallOfFame).toBe(true);
|
||||||
|
expect(game.state.hallOfFameCount).toBe(1);
|
||||||
|
expect(game.state.hallOfFameTeams).toHaveLength(1);
|
||||||
|
expect(game.state.hallOfFameTeams[0]).toMatchObject({ win: 1, mons: [{ species: 157 }, { species: 147 }] });
|
||||||
|
expect(game.state.party.every((member) => member.egg || member.hp === member.stats[0])).toBe(true);
|
||||||
|
// halloffame clears GAME_TIMER_COUNTING_F for the ceremony and credits.
|
||||||
|
expect({ hours: game.gameTime.hours, minutes: game.gameTime.minutes }).toEqual({
|
||||||
|
hours: before!.hours,
|
||||||
|
minutes: before!.minutes,
|
||||||
|
});
|
||||||
|
expect(game.mapId).toBe(NEW_BARK);
|
||||||
|
expect({ x: game.x, y: game.y }).toEqual({ x: 13, y: 6 });
|
||||||
|
// The ceremony does not play again.
|
||||||
|
game.warpTo(HALL_OF_FAME, 4, 13);
|
||||||
|
expect(drive(game).screens).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the League wins at 200 and keeps the 30 newest teams; B shortens credits seen before", () => {
|
||||||
|
const run = (count: number, b: boolean) => {
|
||||||
|
const game = world();
|
||||||
|
game.state.party = [mon(game, 157, 60)];
|
||||||
|
game.state.hallOfFame = count > 0;
|
||||||
|
game.state.hallOfFameCount = count;
|
||||||
|
game.state.hallOfFameTeams = Array.from({ length: Math.min(30, count) }, (_, i) => ({
|
||||||
|
win: count - i,
|
||||||
|
mons: [{ species: 25, otId: 1, dvs: 0, level: 5, nickname: "PIKACHU" }],
|
||||||
|
}));
|
||||||
|
game.warpTo(HALL_OF_FAME, 4, 13);
|
||||||
|
return { game, ...drive(game, b) };
|
||||||
|
};
|
||||||
|
const capped = run(200, false);
|
||||||
|
expect(capped.game.state.hallOfFameCount).toBe(200);
|
||||||
|
expect(capped.game.state.hallOfFameTeams).toHaveLength(30);
|
||||||
|
expect(capped.game.state.hallOfFameTeams[0]).toMatchObject({ win: 200, mons: [{ species: 157 }] });
|
||||||
|
// The first time, B does nothing; a team that has entered before can
|
||||||
|
// hurry the credits along with it.
|
||||||
|
expect(run(0, true).screens).toBe(run(0, false).screens);
|
||||||
|
expect(run(5, true).screens).toBeLessThan(run(5, false).screens - 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Credits", () => {
|
||||||
|
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
|
||||||
|
const runCredits = (press: (frame: number) => string) => {
|
||||||
|
const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false);
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (let frame = 0; frame < 20_000; frame++) {
|
||||||
|
const step = machine.step(press(frame));
|
||||||
|
let next = step.next();
|
||||||
|
while (!next.done) {
|
||||||
|
machine.vblank();
|
||||||
|
next = step.next();
|
||||||
|
}
|
||||||
|
if (next.value) return { frame, labels, machine };
|
||||||
|
const label = machine.screen().label;
|
||||||
|
if (labels.at(-1) !== label) labels.push(label);
|
||||||
|
machine.vblank();
|
||||||
|
}
|
||||||
|
return { frame: -1, labels, machine };
|
||||||
|
};
|
||||||
|
it("roll from the staff to THE END and close only on A after it", () => {
|
||||||
|
// A held throughout ends the credits right after CREDITS_END.
|
||||||
|
const { frame, labels, machine } = runCredits(() => "a");
|
||||||
|
expect(labels).toContain("POKéMON, CRYSTAL VERSION, STAFF");
|
||||||
|
expect(labels).toContain("DIRECTOR, SATOSHI TAJIRI");
|
||||||
|
expect(labels).toContain("EXECUTIVE PRODUCER, HIROSHI YAMAUCHI");
|
||||||
|
expect(frame).toBeGreaterThan(6900);
|
||||||
|
expect(machine.debug().exit).toBe(true);
|
||||||
|
// THE END's 16 tiles at (6, 9) and (6, 10).
|
||||||
|
expect(machine.shown.slice(9 * 20 + 6, 9 * 20 + 14)).toEqual([0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]);
|
||||||
|
expect(machine.shown.slice(10 * 20 + 6, 10 * 20 + 14)).toEqual([0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f]);
|
||||||
|
});
|
||||||
|
it("scroll the borders two pixels a cycle and animate the banner", () => {
|
||||||
|
const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false);
|
||||||
|
const scrolls = new Set<number>(),
|
||||||
|
banners = new Set<string>();
|
||||||
|
for (let frame = 0; frame < 800; frame++) {
|
||||||
|
const step = machine.step("");
|
||||||
|
let next = step.next();
|
||||||
|
while (!next.done) {
|
||||||
|
machine.vblank();
|
||||||
|
next = step.next();
|
||||||
|
}
|
||||||
|
const screen = machine.screen();
|
||||||
|
scrolls.add(screen.rowScroll![1].x);
|
||||||
|
banners.add(screen.tileset);
|
||||||
|
machine.vblank();
|
||||||
|
}
|
||||||
|
expect([...scrolls].every((x) => x % 2 === 0)).toBe(true);
|
||||||
|
expect(scrolls.size).toBeGreaterThan(40);
|
||||||
|
expect(banners.size).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes the rating's text box to tiles, keeping \"'s\" as one tile", () => {
|
||||||
|
const assets = new CrystalAssets(bytes);
|
||||||
|
const ram = (address: number) =>
|
||||||
|
address === RAM.stringBuffer3 ? "42" : address === RAM.stringBuffer4 ? "7" : "";
|
||||||
|
const pages = textboxPages(assets.reader, PC.OakPCText3, ram, "KAWA");
|
||||||
|
expect(pages).toHaveLength(2);
|
||||||
|
// "42 POKéMON seen" / "7 POKéMON owned", then a para.
|
||||||
|
expect(pages[0].wait).toBe(true);
|
||||||
|
expect(pages[0].lines[0].slice(0, 2)).toEqual([0xfa, 0xf8]);
|
||||||
|
expect(pages[0].lines[1][0]).toBe(0xfd);
|
||||||
|
// "PROF.OAK's" / "Rating:" ends the text without a wait.
|
||||||
|
expect(pages[1].wait).toBe(false);
|
||||||
|
expect(pages[1].lines[0]).toContain(0xd4);
|
||||||
|
expect(pages[1].lines[0]).toHaveLength(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,8 +135,9 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the
|
|||||||
expect(world.move("right")).toBe(true);
|
expect(world.move("right")).toBe(true);
|
||||||
const shown = drive(world);
|
const shown = drive(world);
|
||||||
expect(world.notice, world.diagnostic).toBe("");
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
// TrainerClassNames' "RIVAL" and Rival1Group's placeholder name "?".
|
// PlaceEnemysName prints a rival's wRivalName alone: "???" until the
|
||||||
expect(shown).toContain("RIVAL ?\nwas defeated!");
|
// officer asks for it.
|
||||||
|
expect(shown).toContain("???\nwas defeated!");
|
||||||
expect(shown).toContain("My name's ???.");
|
expect(shown).toContain("My name's ???.");
|
||||||
expect(world.state.pokedex.seen.has(rivalSpecies)).toBe(true);
|
expect(world.state.pokedex.seen.has(rivalSpecies)).toBe(true);
|
||||||
expect(world.state.money).toBe(money + 300);
|
expect(world.state.money).toBe(money + 300);
|
||||||
@@ -154,7 +155,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the
|
|||||||
const world = questRivalFixtureWorld(assets);
|
const world = questRivalFixtureWorld(assets);
|
||||||
world.restore({ ...world.save(), x: 34, y: 6, direction: "left" });
|
world.restore({ ...world.save(), x: 34, y: 6, direction: "left" });
|
||||||
expect(world.move("left")).toBe(true);
|
expect(world.move("left")).toBe(true);
|
||||||
expect(drive(world)).toContain("RIVAL ?\nwas defeated!");
|
expect(drive(world)).toContain("???\nwas defeated!");
|
||||||
expect(world.notice, world.diagnostic).toBe("");
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
expect(world.state.scenes["26:3"]).toBe(0);
|
expect(world.state.scenes["26:3"]).toBe(0);
|
||||||
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
|
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
|
||||||
|
|||||||
+17
-11
@@ -520,23 +520,29 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
() => new Date("2026-09-22T12:00:00"),
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
);
|
);
|
||||||
const start = world.save();
|
const start = world.save();
|
||||||
// New Bark Town's east edge is water leading to Route 27, which
|
// New Bark Town's east edge is water leading to Route 27; with Route
|
||||||
// stays closed: a surfer is refused.
|
// 27 closed for this test, a surfer is refused.
|
||||||
const town = world.assets.map("24:4"),
|
const town = world.assets.map("24:4"),
|
||||||
right = town.width - 1;
|
right = town.width - 1;
|
||||||
const edge = Array.from({ length: town.height }, (_, y) => y).find(
|
const edge = Array.from({ length: town.height }, (_, y) => y).find(
|
||||||
(y) =>
|
(y) =>
|
||||||
world.assets.permission(world.assets.collision(town, right, y)) === 1,
|
world.assets.permission(world.assets.collision(town, right, y)) === 1,
|
||||||
)!;
|
)!;
|
||||||
world.restore({
|
const route27 = NATIVE_MAPS["24:2"];
|
||||||
...world.save(),
|
delete NATIVE_MAPS["24:2"];
|
||||||
map: "24:4",
|
try {
|
||||||
x: right,
|
world.restore({
|
||||||
y: edge,
|
...world.save(),
|
||||||
playerState: 4, // PLAYER_SURF
|
map: "24:4",
|
||||||
});
|
x: right,
|
||||||
expect(world.move("right")).toBe(false);
|
y: edge,
|
||||||
expect(world.notice).toBe("Route 27 is not ported yet.");
|
playerState: 4, // PLAYER_SURF
|
||||||
|
});
|
||||||
|
expect(world.move("right")).toBe(false);
|
||||||
|
expect(world.notice).toBe("Route 27 is not ported yet.");
|
||||||
|
} finally {
|
||||||
|
NATIVE_MAPS["24:2"] = route27;
|
||||||
|
}
|
||||||
world.restore(start);
|
world.restore(start);
|
||||||
const {
|
const {
|
||||||
scenes,
|
scenes,
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export class Playthrough {
|
|||||||
/** Plan ledge jumps (Tin Tower, Route 45). Off for the earlier stages,
|
/** Plan ledge jumps (Tin Tower, Route 45). Off for the earlier stages,
|
||||||
* whose seeded runs were recorded walking around ledges. */
|
* whose seeded runs were recorded walking around ledges. */
|
||||||
ledges = false;
|
ledges = false;
|
||||||
|
/** Fight as a player facing the League would (off for the stages
|
||||||
|
* recorded before it): weigh each move by its accuracy, cure poison,
|
||||||
|
* paralysis and burns with a Full Heal in a trainer battle, and heal
|
||||||
|
* below two thirds of the HP rather than half. */
|
||||||
|
careful = false;
|
||||||
seed = 0x2a;
|
seed = 0x2a;
|
||||||
private stage = 0;
|
private stage = 0;
|
||||||
readonly now = () => new Date("2026-09-22T12:00:00");
|
readonly now = () => new Date("2026-09-22T12:00:00");
|
||||||
@@ -82,14 +87,18 @@ export class Playthrough {
|
|||||||
targetTypes,
|
targetTypes,
|
||||||
identified: false,
|
identified: false,
|
||||||
}).damage;
|
}).damage;
|
||||||
|
const weighed = (move: number) =>
|
||||||
|
this.careful ? score(move) * this.assets.pokemon.move(move).accuracy : score(move);
|
||||||
let best = -1;
|
let best = -1;
|
||||||
mon.moves.forEach((move, i) => {
|
mon.moves.forEach((move, i) => {
|
||||||
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
||||||
if (best < 0 || score(move) > score(mon.moves[best])) best = i;
|
if (best < 0 || weighed(move) > weighed(mon.moves[best])) best = i;
|
||||||
});
|
});
|
||||||
return Math.max(0, best);
|
return Math.max(0, best);
|
||||||
};
|
};
|
||||||
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
||||||
|
private screenLabel = "";
|
||||||
|
private screenStill = 0;
|
||||||
/** Run scripts and battles until the player is free again. Battles use
|
/** Run scripts and battles until the player is free again. Battles use
|
||||||
* the strongest move with PP; a wild battle below half HP is run from,
|
* the strongest move with PP; a wild battle below half HP is run from,
|
||||||
* and a trainer battle (which cannot be run from) instead reaches for
|
* and a trainer battle (which cannot be run from) instead reaches for
|
||||||
@@ -122,6 +131,9 @@ export class Playthrough {
|
|||||||
const awakening = world.liveState.bag.items.findIndex(
|
const awakening = world.liveState.bag.items.findIndex(
|
||||||
(slot) => slot.item === C.AWAKENING,
|
(slot) => slot.item === C.AWAKENING,
|
||||||
);
|
);
|
||||||
|
const fullHeal = world.liveState.bag.items.findIndex(
|
||||||
|
(slot) => slot.item === C.FULL_HEAL,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
engine.battleMode === C.WILD_BATTLE &&
|
engine.battleMode === C.WILD_BATTLE &&
|
||||||
engine.enemy.mon.species === this.wanted &&
|
engine.enemy.mon.species === this.wanted &&
|
||||||
@@ -143,9 +155,17 @@ export class Playthrough {
|
|||||||
)
|
)
|
||||||
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
|
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
|
||||||
else if (
|
else if (
|
||||||
|
this.careful &&
|
||||||
engine.battleMode !== C.WILD_BATTLE &&
|
engine.battleMode !== C.WILD_BATTLE &&
|
||||||
!prompt.noItems &&
|
!prompt.noItems &&
|
||||||
mon.hp * 2 < mon.maxHp &&
|
mon.status & ((1 << C.PSN) | (1 << C.PAR) | (1 << C.BRN)) &&
|
||||||
|
fullHeal >= 0
|
||||||
|
)
|
||||||
|
world.battleAction({ action: "item", item: C.FULL_HEAL, slot: fullHeal });
|
||||||
|
else if (
|
||||||
|
engine.battleMode !== C.WILD_BATTLE &&
|
||||||
|
!prompt.noItems &&
|
||||||
|
mon.hp * (this.careful ? 3 : 2) < mon.maxHp * (this.careful ? 2 : 1) &&
|
||||||
potion >= 0
|
potion >= 0
|
||||||
)
|
)
|
||||||
world.battleAction({
|
world.battleAction({
|
||||||
@@ -192,6 +212,15 @@ export class Playthrough {
|
|||||||
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) {
|
if (last && dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
||||||
|
// A player watching a screen that runs on its own (the Hall of
|
||||||
|
// Fame's rating, THE END) presses A once it has stood still for
|
||||||
|
// four seconds.
|
||||||
|
const label = dialogue.choice.screen.label;
|
||||||
|
if (label !== this.screenLabel) [this.screenLabel, this.screenStill] = [label, 0];
|
||||||
|
else if (++this.screenStill >= 120) {
|
||||||
|
world.bufferScreenButton("a");
|
||||||
|
this.screenStill = 0;
|
||||||
|
}
|
||||||
world.tick();
|
world.tick();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -223,15 +252,25 @@ export class Playthrough {
|
|||||||
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Where a warp of the current map lands when it leads elsewhere on the
|
||||||
|
* same map (Victory Road's ladders, a building's inner stairs). */
|
||||||
|
private sameMapArrival = (x: number, y: number) => {
|
||||||
|
const world = this.world;
|
||||||
|
const warp = world.map.warps.find((warp) => warp.x === x && warp.y === y);
|
||||||
|
return warp?.targetMap === world.mapId ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||||
|
};
|
||||||
/** Walk to a tile on the current map, replanning after ledges, roaming
|
/** Walk to a tile on the current map, replanning after ledges, roaming
|
||||||
* NPCs and scenes that move the player. A warp target leaves the map. */
|
* NPCs and scenes that move the player. A warp target leaves the map, or
|
||||||
|
* lands at its destination on the same map. */
|
||||||
walk = (x: number, y: number) => {
|
walk = (x: number, y: number) => {
|
||||||
const world = this.world,
|
const world = this.world,
|
||||||
map = world.mapId,
|
map = world.mapId,
|
||||||
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y),
|
||||||
|
arrival = this.sameMapArrival(x, y);
|
||||||
for (let attempt = 0; attempt < 40; attempt++) {
|
for (let attempt = 0; attempt < 40; attempt++) {
|
||||||
this.play();
|
this.play();
|
||||||
if (warp && world.mapId !== map) return;
|
if (warp && world.mapId !== map) return;
|
||||||
|
if (arrival && world.x === arrival.x && world.y === arrival.y) return;
|
||||||
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
||||||
if (world.x === x && world.y === y) return;
|
if (world.x === x && world.y === y) return;
|
||||||
// A surfer crosses water until it steps ashore.
|
// A surfer crosses water until it steps ashore.
|
||||||
@@ -511,22 +550,29 @@ export class Playthrough {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A surfer reaches a cave's stairs over water, then steps on.
|
// A surfer reaches a cave's stairs over water, then steps on.
|
||||||
|
const landedHere = () => {
|
||||||
|
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||||
|
return Boolean(arrival && world.x === arrival.x && world.y === arrival.y);
|
||||||
|
};
|
||||||
if (water) {
|
if (water) {
|
||||||
try {
|
try {
|
||||||
this.sail(warp.x, warp.y, { whirlpools: true });
|
this.sail(warp.x, warp.y, { whirlpools: true });
|
||||||
} catch {
|
} catch {
|
||||||
if (world.mapId === from) continue;
|
if (world.mapId === from && !landedHere()) continue;
|
||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
try {
|
try {
|
||||||
this.walk(warp.x, warp.y);
|
this.walk(warp.x, warp.y);
|
||||||
} catch {
|
} catch {
|
||||||
// A warp panel on the way can land elsewhere on the same map.
|
// A warp panel on the way can land elsewhere on the same map.
|
||||||
if (world.mapId === from) continue;
|
if (world.mapId === from && !landedHere()) continue;
|
||||||
}
|
}
|
||||||
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
||||||
if (world.mapId === from && exit && world.move(exit)) this.play();
|
if (world.mapId === from && exit && world.move(exit)) this.play();
|
||||||
if (world.mapId === from) continue;
|
// A warp to another place on the same map lands at its partner.
|
||||||
|
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||||
|
const moved = arrival ? world.x === arrival.x && world.y === arrival.y : world.mapId !== from;
|
||||||
|
if (!moved) continue;
|
||||||
if (world.mapId === target) return;
|
if (world.mapId === target) return;
|
||||||
const key = `${world.mapId} ${world.x},${world.y}`;
|
const key = `${world.mapId} ${world.x},${world.y}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
|
|||||||
+168
-1
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
import { C } from "../src/game/battle/constants";
|
import { C } from "../src/game/battle/constants";
|
||||||
|
import { tmhmItem } from "../src/game/rom/pokemon";
|
||||||
import { Playthrough } from "./playthrough-driver";
|
import { Playthrough } from "./playthrough-driver";
|
||||||
import { findPath } from "./world-driver";
|
import { findPath } from "./world-driver";
|
||||||
|
|
||||||
@@ -214,6 +215,29 @@ const ROUTE_46 = "5:9",
|
|||||||
TIN_TOWER_1F = "3:4",
|
TIN_TOWER_1F = "3:4",
|
||||||
EVENT_RANG_CLEAR_BELL_2 = 0x767,
|
EVENT_RANG_CLEAR_BELL_2 = 0x767,
|
||||||
EVENT_FOUGHT_SUICUNE = 0x335;
|
EVENT_FOUGHT_SUICUNE = 0x335;
|
||||||
|
const WISE_TRIOS_ROOM_EXIT = "4:2",
|
||||||
|
MAHOGANY_ROUTE_44 = "2:6",
|
||||||
|
ROUTE_27 = "24:2",
|
||||||
|
TOHJO_FALLS = "3:83",
|
||||||
|
ROUTE_26 = "24:1",
|
||||||
|
VICTORY_ROAD_GATE = "23:13",
|
||||||
|
VICTORY_ROAD = "3:91",
|
||||||
|
ROUTE_23 = "16:1",
|
||||||
|
INDIGO_PLATEAU = "16:2",
|
||||||
|
ELITE_FOUR = ["16:3", "16:4", "16:5", "16:6"],
|
||||||
|
LANCES_ROOM = "16:7";
|
||||||
|
const HM07 = 0xf9,
|
||||||
|
WATERFALL = 127,
|
||||||
|
IRON_TAIL = 231,
|
||||||
|
TYPHLOSION = 157,
|
||||||
|
EVENT_GOT_HM07_WATERFALL = 0x688,
|
||||||
|
EVENT_RIVAL_VICTORY_ROAD = 0x6c2,
|
||||||
|
EVENT_BEAT_ELITE_4_WILL = 0x5b8,
|
||||||
|
EVENT_BEAT_ELITE_4_KOGA = 0x5b9,
|
||||||
|
EVENT_BEAT_ELITE_4_BRUNO = 0x5ba,
|
||||||
|
EVENT_BEAT_ELITE_4_KAREN = 0x5bb,
|
||||||
|
EVENT_BEAT_CHAMPION_LANCE = 0x5bc,
|
||||||
|
EVENT_BEAT_ELITE_FOUR = 0x44;
|
||||||
|
|
||||||
/** The acceptance run for PLAN section 2: a new game played from the
|
/** The acceptance run for PLAN section 2: a new game played from the
|
||||||
* introduction to the catching tutorial through the world's own inputs,
|
* introduction to the catching tutorial through the world's own inputs,
|
||||||
@@ -343,7 +367,8 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
// The rival's scene blocks the way east to Route 29.
|
// The rival's scene blocks the way east to Route 29.
|
||||||
cross("right", ROUTE_29);
|
cross("right", ROUTE_29);
|
||||||
const rival = p.log.join("\n");
|
const rival = p.log.join("\n");
|
||||||
expect(rival).toContain("RIVAL ?");
|
// PlaceEnemysName: a rival is wRivalName alone, "???" until named.
|
||||||
|
expect(rival).toContain("???\nwants to battle!");
|
||||||
expect(rival).toContain("My name's ???.");
|
expect(rival).toContain("My name's ???.");
|
||||||
expect(p.world.state.scenes[CHERRYGROVE]).toBe(0);
|
expect(p.world.state.scenes[CHERRYGROVE]).toBe(0);
|
||||||
reload();
|
reload();
|
||||||
@@ -1486,5 +1511,147 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
reload();
|
reload();
|
||||||
writeTestSave("tin-tower");
|
writeTestSave("tin-tower");
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
|
|
||||||
|
it("goes back to Ice Path for HM07 and walks home to New Bark Town", () => {
|
||||||
|
traceFailures();
|
||||||
|
// From here on the run fights as a player facing the League would.
|
||||||
|
p.careful = true;
|
||||||
|
// Tin Tower's grounds lead back through the Wise Trio's room and the
|
||||||
|
// entrance building, whose far side comes down by its stairs.
|
||||||
|
stairs(ECRUTEAK);
|
||||||
|
stairs(WISE_TRIOS_ROOM_EXIT);
|
||||||
|
stairs(TIN_TOWER_ENTRANCE);
|
||||||
|
walk(17, 14);
|
||||||
|
p.world.move("down");
|
||||||
|
play();
|
||||||
|
stairs(ECRUTEAK);
|
||||||
|
stairs(ROUTE_42_GATE);
|
||||||
|
stairs(ROUTE_42);
|
||||||
|
cross("right", MAHOGANY, { water: true });
|
||||||
|
heal(MAHOGANY_CENTER, MAHOGANY);
|
||||||
|
cross("right", MAHOGANY_ROUTE_44);
|
||||||
|
stairs(ICE_PATH[0]);
|
||||||
|
// The HM07 ball at (31, 7), reached from its left over the ice.
|
||||||
|
walk(30, 7);
|
||||||
|
if (p.world.save().direction !== "right") p.world.move("right");
|
||||||
|
p.world.interact();
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_GOT_HM07_WATERFALL)).toBe(true);
|
||||||
|
expect(p.world.state.bag.tmsHms[56]).toBe(1);
|
||||||
|
stairs(MAHOGANY_ROUTE_44);
|
||||||
|
cross("left", MAHOGANY);
|
||||||
|
cross("left", ROUTE_42, { water: true });
|
||||||
|
sail(2, 8);
|
||||||
|
stairs(ROUTE_42_GATE);
|
||||||
|
stairs(ECRUTEAK);
|
||||||
|
cross("down", ROUTE_37);
|
||||||
|
cross("down", ROUTE_36);
|
||||||
|
cross("right", VIOLET);
|
||||||
|
stairs(ROUTE_31_GATE);
|
||||||
|
stairs(ROUTE_31);
|
||||||
|
cross("down", ROUTE_30);
|
||||||
|
cross("down", CHERRYGROVE);
|
||||||
|
cross("right", ROUTE_29);
|
||||||
|
cross("right", NEW_BARK);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("surfs Route 27, climbs Tohjo Falls and takes Victory Road to the Plateau", () => {
|
||||||
|
traceFailures();
|
||||||
|
// Dratini learns Waterfall; Typhlosion takes Iron Tail for Leer.
|
||||||
|
p.teach(HM07, 5, 0);
|
||||||
|
p.teach(tmhmItem(23), 0, 1);
|
||||||
|
expect(p.world.state.party[5].moves[0]).toBe(WATERFALL);
|
||||||
|
expect(p.world.state.party[0].moves[1]).toBe(IRON_TAIL);
|
||||||
|
cross("right", ROUTE_27, { water: true });
|
||||||
|
travel(TOHJO_FALLS, [], { water: true });
|
||||||
|
// Up the western waterfall, along the top pool and down the eastern
|
||||||
|
// one to the far shore and Route 27's other half.
|
||||||
|
sail(9, 12);
|
||||||
|
if (p.world.save().direction !== "up") p.world.move("up");
|
||||||
|
p.world.interact();
|
||||||
|
play([1]);
|
||||||
|
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 9, y: 7 });
|
||||||
|
sail(22, 14);
|
||||||
|
walk(25, 15);
|
||||||
|
if (p.world.mapId === TOHJO_FALLS) {
|
||||||
|
p.world.move("down");
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
expect(p.world.mapId).toBe(ROUTE_27);
|
||||||
|
cross("right", ROUTE_26, { water: true });
|
||||||
|
travel(VICTORY_ROAD_GATE, [], { water: true });
|
||||||
|
// VictoryRoadGateBadgeCheckScript on the way north.
|
||||||
|
stairs(VICTORY_ROAD);
|
||||||
|
expect(p.log.join(" ")).toContain("BADGES of JOHTO!");
|
||||||
|
// Victory Road's floors are joined by its own ladders; the rival
|
||||||
|
// waits by the exit.
|
||||||
|
travel(ROUTE_23, [VICTORY_ROAD]);
|
||||||
|
// The rival leaves (EVENT_RIVAL_VICTORY_ROAD hides him) after the battle.
|
||||||
|
expect(p.world.state.events.has(EVENT_RIVAL_VICTORY_ROAD)).toBe(true);
|
||||||
|
expect(p.log.join(" ")).toContain("SILVER\nwants to battle!");
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("stocks up at Indigo Plateau and beats Will", () => {
|
||||||
|
traceFailures();
|
||||||
|
stairs(INDIGO_PLATEAU);
|
||||||
|
talkTo(2); // the nurse, across the counter
|
||||||
|
play();
|
||||||
|
expect(p.world.state.party.every((mon) => mon.hp === mon.stats[0])).toBe(true);
|
||||||
|
writeTestSave("indigo-plateau");
|
||||||
|
buy(3, C.FULL_HEAL, 15);
|
||||||
|
buy(3, C.MAX_POTION, Math.floor((p.world.state.money - 2000) / 2500));
|
||||||
|
stairs(ELITE_FOUR[0]);
|
||||||
|
play(); // the door locks behind the player
|
||||||
|
talkTo(p.world.map.objects[0].id);
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_BEAT_ELITE_4_WILL)).toBe(true);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("beats Koga, Bruno and Karen", () => {
|
||||||
|
traceFailures();
|
||||||
|
const events = [EVENT_BEAT_ELITE_4_KOGA, EVENT_BEAT_ELITE_4_BRUNO, EVENT_BEAT_ELITE_4_KAREN];
|
||||||
|
ELITE_FOUR.slice(1).forEach((room, index) => {
|
||||||
|
stairs(room);
|
||||||
|
play();
|
||||||
|
talkTo(p.world.map.objects[0].id);
|
||||||
|
play();
|
||||||
|
expect(p.world.mapId).toBe(room);
|
||||||
|
expect(p.world.state.events.has(events[index])).toBe(true);
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("beats Lance and enters the Hall of Fame, then wakes in New Bark Town", () => {
|
||||||
|
traceFailures();
|
||||||
|
stairs(LANCES_ROOM);
|
||||||
|
play();
|
||||||
|
// Script_ApproachLanceFromRight: the step towards Lance starts it;
|
||||||
|
// after the battle Mary and Oak rush in and Lance leads the way to
|
||||||
|
// the Hall of Fame, where the ceremony and the credits play.
|
||||||
|
walk(5, 6);
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
const log = p.log.join(" ");
|
||||||
|
expect(log).toContain("CHAMPION LANCE\nwas defeated!");
|
||||||
|
expect(log).toContain("MARY: Let's inter-");
|
||||||
|
expect(log).toContain("and your partners\nas CHAMPIONS!");
|
||||||
|
expect(p.world.state.events.has(EVENT_BEAT_CHAMPION_LANCE)).toBe(true);
|
||||||
|
expect(p.world.state.events.has(EVENT_BEAT_ELITE_FOUR)).toBe(true);
|
||||||
|
expect(p.world.state.hallOfFame).toBe(true);
|
||||||
|
expect(p.world.state.hallOfFameCount).toBe(1);
|
||||||
|
expect(p.world.state.hallOfFameTeams[0].mons.map((mon) => mon.species)).toEqual(
|
||||||
|
p.world.state.party.map((mon) => mon.species),
|
||||||
|
);
|
||||||
|
expect(p.world.state.hallOfFameTeams[0].mons[0].species).toBe(TYPHLOSION);
|
||||||
|
// Continue after the credits: SPAWN_NEW_BARK, outside the lab.
|
||||||
|
expect(p.world.mapId).toBe(NEW_BARK);
|
||||||
|
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 13, y: 6 });
|
||||||
|
expect(p.world.busy).toBe(false);
|
||||||
|
reload();
|
||||||
|
writeTestSave("hall-of-fame-new-bark");
|
||||||
|
}, 300_000);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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.");
|
||||||
|
|
||||||
|
/** The cartridge's own InitDisplayForRedCredits and Credits run with their
|
||||||
|
* interrupts after the game has booted; each video frame the screen's
|
||||||
|
* background map, the banner's 16 tiles, wBGPals2's three palettes,
|
||||||
|
* wLYOverrides and the parser's position and timer are compared with
|
||||||
|
* CreditsMachine's, from the first parse to well past THE END. Without B
|
||||||
|
* and with B held (which only shortens credits seen before). */
|
||||||
|
for (const skip of [false, true])
|
||||||
|
test(`Credits match the cartridge frame by frame${skip ? " with B held" : ""}`, async ({ page }) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
const { at, routine } = loadSymbols();
|
||||||
|
const a = {
|
||||||
|
romBank: at("hROMBank"),
|
||||||
|
pos: at("wCreditsPos"),
|
||||||
|
timer: at("wCreditsTimer"),
|
||||||
|
bgPals2: at("wBGPals2"),
|
||||||
|
lyOverrides: at("wLYOverrides"),
|
||||||
|
program: at("wOverworldMapBlocks"),
|
||||||
|
stackTop: at("wStackTop"),
|
||||||
|
};
|
||||||
|
const init = routine("InitDisplayForRedCredits"),
|
||||||
|
credits = routine("Credits");
|
||||||
|
await openReference(page);
|
||||||
|
const result = await page.evaluate(
|
||||||
|
async ({ a, init, credits, skip }) => {
|
||||||
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||||
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||||
|
const { CreditsMachine } = await import("/src/game/scripts/league.ts");
|
||||||
|
const { hallOfFameTiles } = await import("/src/game/scripts/hall-of-fame.ts");
|
||||||
|
const rom = new Uint8Array(
|
||||||
|
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
||||||
|
);
|
||||||
|
const assets = new CrystalAssets(rom);
|
||||||
|
const reference = new BinjgbEmulator();
|
||||||
|
await reference.load(rom, document.createElement("canvas"));
|
||||||
|
reference.setMuted(true);
|
||||||
|
const read = (p: number) => reference.readMemory(p);
|
||||||
|
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||||
|
// Boot to the intro so the interrupt handlers and hardware are set up.
|
||||||
|
reference.advanceFrames(600);
|
||||||
|
// DI; SP=wStackTop; EI; InitDisplayForRedCredits; B = the status
|
||||||
|
// flags (bit 6: the Hall of Fame); Credits; a sentinel.
|
||||||
|
const bank = (n: number) => [0x3e, n, 0xe0, a.romBank & 255, 0xea, 0, 0x20];
|
||||||
|
const sentinel = a.program + 0x80;
|
||||||
|
const program = [
|
||||||
|
0xf3,
|
||||||
|
0x31, a.stackTop & 255, a.stackTop >> 8,
|
||||||
|
0xfb,
|
||||||
|
...bank(init.bank),
|
||||||
|
0xcd, init.address & 255, init.address >> 8,
|
||||||
|
...bank(credits.bank),
|
||||||
|
0x06, skip ? 0x40 : 0,
|
||||||
|
0xcd, credits.address & 255, credits.address >> 8,
|
||||||
|
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
|
||||||
|
0x18, 0xfe,
|
||||||
|
];
|
||||||
|
write(sentinel, 0);
|
||||||
|
program.forEach((v, i) => write(a.program + i, v));
|
||||||
|
reference.setProgramCounter(a.program);
|
||||||
|
if (skip) reference.setButton("b", true);
|
||||||
|
const machine = new CreditsMachine(assets.reader, hallOfFameTiles(assets.pokemon, 0), skip);
|
||||||
|
type State = { map: number[]; tiles: number[]; pals: number[]; ly: number[]; pos: number; timer: number; lcd?: number[] };
|
||||||
|
// The parser's work RAM at line 143 (the frame's logic has run,
|
||||||
|
// its VBlank has not), then what that VBlank presents at line 152:
|
||||||
|
// the BG map, the banner's tiles and the palettes in CRAM.
|
||||||
|
const toLine = (line: number) => {
|
||||||
|
for (let guard = 0; read(0xff44) !== line; guard++) {
|
||||||
|
if (guard > 20000) throw new Error("The LCD does not reach the line.");
|
||||||
|
reference.advanceCycles(16);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sample = (): State => {
|
||||||
|
toLine(143);
|
||||||
|
const wram = {
|
||||||
|
ly: [read(a.lyOverrides + 0x1f), read(a.lyOverrides + 0x87)],
|
||||||
|
pos: read(a.pos) | (read(a.pos + 1) << 8),
|
||||||
|
timer: read(a.timer),
|
||||||
|
};
|
||||||
|
toLine(152);
|
||||||
|
const cram = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
write(0xff68, i * 2);
|
||||||
|
const low = read(0xff69);
|
||||||
|
write(0xff68, i * 2 + 1);
|
||||||
|
return low | (read(0xff69) << 8);
|
||||||
|
});
|
||||||
|
const state = {
|
||||||
|
map: Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20))),
|
||||||
|
tiles: Array.from({ length: 256 }, (_, i) => read(0x9000 + i)),
|
||||||
|
pals: cram,
|
||||||
|
...wram,
|
||||||
|
lcd: [read(0xff44), read(0xff41) & 3],
|
||||||
|
};
|
||||||
|
reference.advanceCycles(456 * 140);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
const native = (): State => {
|
||||||
|
const d = machine.debug();
|
||||||
|
return {
|
||||||
|
map: [...machine.shown],
|
||||||
|
tiles: Array.from(machine.vram.subarray(0, 256)),
|
||||||
|
pals: machine.paletteWords(),
|
||||||
|
ly: [d.lyOverride, d.lyOverride],
|
||||||
|
pos: d.pos,
|
||||||
|
timer: d.timer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Sample once a frame at the last visible line: the frame's logic
|
||||||
|
// has run and its VBlank has not.
|
||||||
|
let guard = 0;
|
||||||
|
while (read(a.pos) === 0 && read(a.pos + 1) === 0 && guard++ < 300) reference.advanceFrames(1);
|
||||||
|
while (read(0xff44) !== 143 && guard++ < 100000) reference.advanceCycles(16);
|
||||||
|
const original: State[] = [];
|
||||||
|
for (let frame = 0; frame < 12000 && read(sentinel) !== 0x42; frame++) original.push(sample());
|
||||||
|
const nativeFrames: State[] = [];
|
||||||
|
const present = () => {
|
||||||
|
const before = native();
|
||||||
|
machine.vblank();
|
||||||
|
const after = native();
|
||||||
|
nativeFrames.push({ ...after, ly: before.ly, pos: before.pos, timer: before.timer });
|
||||||
|
};
|
||||||
|
for (let frame = 0; frame < original.length + 8; frame++) {
|
||||||
|
const logic = machine.step(skip ? "b" : "");
|
||||||
|
let next = logic.next();
|
||||||
|
while (!next.done) {
|
||||||
|
present();
|
||||||
|
next = logic.next();
|
||||||
|
}
|
||||||
|
present();
|
||||||
|
}
|
||||||
|
// Positions between two waits are a parse in progress, whose own
|
||||||
|
// progress the native parser does not model: compare the parser
|
||||||
|
// where it rests, and the screen always.
|
||||||
|
const { KANTO } = await import("/src/game/rom/profile.ts");
|
||||||
|
const boundaries = new Set<number>([0]);
|
||||||
|
for (let p = 0, guard = 0; guard < 1024; guard++) {
|
||||||
|
const command = rom[KANTO.CreditsScript + p++];
|
||||||
|
if (command === 0xff) {
|
||||||
|
boundaries.add(p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (command === 0xfe || command === 0xfa) boundaries.add(++p);
|
||||||
|
else if (command === 0xfd || command < 0xf9) p++;
|
||||||
|
}
|
||||||
|
const rest = ({ lcd: _, ...x }: State) => (boundaries.has(x.pos) ? x : { ...x, pos: -1, timer: -1 });
|
||||||
|
const same = (x: State, y: State) => JSON.stringify(rest(x)) === JSON.stringify(boundaries.has(x.pos) ? y : rest(y));
|
||||||
|
// The first emulator sample falls after the first parse; find its
|
||||||
|
// native frame, then every later frame must match in order.
|
||||||
|
const offset = nativeFrames.findIndex((frame) => frame.pos === original[0].pos && same(original[0], frame));
|
||||||
|
const mismatches: unknown[] = [];
|
||||||
|
if (offset >= 0)
|
||||||
|
original.forEach((frame, i) => {
|
||||||
|
const mine = nativeFrames[offset + i];
|
||||||
|
if (mismatches.length >= 5 || (mine && same(frame, mine))) return;
|
||||||
|
if (!mine) return void mismatches.push({ frame: i, native: "missing" });
|
||||||
|
const diff: Record<string, unknown> = { frame: i, pos: frame.pos, timer: [frame.timer, mine.timer], lcd: frame.lcd };
|
||||||
|
for (const key of ["map", "tiles", "pals", "ly"] as const) {
|
||||||
|
const cells = frame[key].flatMap((value, index) =>
|
||||||
|
value === mine[key][index] ? [] : [`${index}:${value}/${mine[key][index]}`],
|
||||||
|
);
|
||||||
|
if (cells.length) diff[key] = cells.slice(0, 12).join(" ");
|
||||||
|
}
|
||||||
|
if (frame.pos !== mine.pos) diff.nativePos = mine.pos;
|
||||||
|
mismatches.push(diff);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
frames: original.length,
|
||||||
|
offset,
|
||||||
|
first: original[0] && { pos: original[0].pos, timer: original[0].timer },
|
||||||
|
debugFirst: offset < 0 ? [original[0], ...nativeFrames.slice(0, 4)].map((x) => ({ ...x, map: x.map.slice(80, 140).join(","), tiles: x.tiles.slice(0, 16).join(",") })) : undefined,
|
||||||
|
endPos: original.at(-1)?.pos,
|
||||||
|
mismatches,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ a, init, credits, skip },
|
||||||
|
);
|
||||||
|
expect(result.offset, JSON.stringify(result.debugFirst)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.frames).toBeGreaterThan(skip ? 3000 : 8000);
|
||||||
|
expect(result.mismatches).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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.");
|
||||||
|
|
||||||
|
/** The cartridge's AnimateHallOfFame.DisplayNewHallOfFamer and
|
||||||
|
* HOF_AnimatePlayerPic run with the LCD off (so graphics load at once) and
|
||||||
|
* only their frame waits, HOF_AnimateFrontpic and ProfOaksPCRating
|
||||||
|
* stubbed; the tilemap, attributes, BG palette 0 and the tiles each screen
|
||||||
|
* shows are compared with the native screens. */
|
||||||
|
test("the Hall of Fame ceremony's pages match the cartridge's tilemaps, tiles and palettes", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const { at, routine } = loadSymbols();
|
||||||
|
const flat = (name: string) => {
|
||||||
|
const { bank, address } = routine(name);
|
||||||
|
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||||
|
};
|
||||||
|
const a = {
|
||||||
|
romBank: at("hROMBank"),
|
||||||
|
cgb: at("hCGB"),
|
||||||
|
tilemap: at("wTilemap"),
|
||||||
|
attrs: at("wAttrmap"),
|
||||||
|
palettes: at("wBGPals1"),
|
||||||
|
frame: at("wTextboxFrame"),
|
||||||
|
temp: at("wHallOfFameTempMon1"),
|
||||||
|
name: at("wPlayerName"),
|
||||||
|
id: at("wPlayerID"),
|
||||||
|
hours: at("wGameTimeHours"),
|
||||||
|
minutes: at("wGameTimeMinutes"),
|
||||||
|
gender: at("wPlayerGender"),
|
||||||
|
layout: at("GetSGBLayout"),
|
||||||
|
setupFlags: at("wPlayerSpriteSetupFlags"),
|
||||||
|
species: at("wCurPartySpecies"),
|
||||||
|
tempDVs: at("wTempMonDVs"),
|
||||||
|
};
|
||||||
|
const routines = {
|
||||||
|
font: routine("_LoadStandardFont"),
|
||||||
|
extra: routine("LoadFontsBattleExtra"),
|
||||||
|
famer: routine("AnimateHallOfFame.DisplayNewHallOfFamer"),
|
||||||
|
player: routine("HOF_AnimatePlayerPic"),
|
||||||
|
};
|
||||||
|
const skip = ["WaitBGMap", "WaitBGMap2", "DelayFrame", "DelayFrames", "HOF_AnimateFrontpic", "ProfOaksPCRating"].map(flat);
|
||||||
|
await openReference(page);
|
||||||
|
const result = await page.evaluate(
|
||||||
|
async ({ a, routines, skip, get2bpp, request2bpp }) => {
|
||||||
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||||
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||||
|
const { newHallOfFamerScreen, hallOfFamePlayerCard } = await import("/src/game/scripts/league.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;
|
||||||
|
// Request2bpp waits for VBlank even with the LCD off; Get2bpp copies
|
||||||
|
// at once then, from the same arguments.
|
||||||
|
rom.set([0xc3, get2bpp & 255, get2bpp >> 8], request2bpp);
|
||||||
|
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 call = (r: { bank: number; address: number }) => [
|
||||||
|
0x3e, r.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||||
|
0xcd, r.address & 255, r.address >> 8,
|
||||||
|
];
|
||||||
|
/** Run the font loaders, the routine (HL = `hl`), then a sentinel;
|
||||||
|
* with `layout`, GetSGBLayout's SCGB_PLAYER_OR_MON_FRONTPIC_PALS
|
||||||
|
* first, as AnimateHOFMonEntrance applies it for the same Pokémon
|
||||||
|
* before DisplayNewHallOfFamer. */
|
||||||
|
const run = (target: { bank: number; address: number }, hl = 0, layout = false) => {
|
||||||
|
write(0xff70, 1);
|
||||||
|
write(0xff40, 0);
|
||||||
|
write(a.cgb, 1);
|
||||||
|
const program = [
|
||||||
|
0xf3, 0x31, 0xfe, 0xc7,
|
||||||
|
...call(routines.font),
|
||||||
|
...call(routines.extra),
|
||||||
|
...(layout ? [0x06, 0x1a, 0xcd, a.layout & 255, a.layout >> 8] : []),
|
||||||
|
0x21, hl & 255, hl >> 8,
|
||||||
|
...call(target),
|
||||||
|
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 < 200 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||||
|
return read(0xc0f0) === 0x42;
|
||||||
|
};
|
||||||
|
type Screen = { map: number[]; attrs: number[]; palettes: number[][][]; tiles: Uint8Array };
|
||||||
|
const compare = (label: unknown, native: Screen, tiles: number[]) => {
|
||||||
|
const map = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i));
|
||||||
|
// The attributes shown: WaitBGMap copies tiles only, so the text
|
||||||
|
// boxes' palette in wAttrmap stays off screen; GetSGBLayout's
|
||||||
|
// ApplyAttrmap set VRAM bank 1.
|
||||||
|
write(0xff4f, 1);
|
||||||
|
const attrs = Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20)));
|
||||||
|
write(0xff4f, 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 tileDiff = tiles.flatMap((tile) =>
|
||||||
|
Array.from({ length: 16 }, (_, byte) => {
|
||||||
|
const actual = read((tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte);
|
||||||
|
return actual === native.tiles[tile * 16 + byte] ? [] : [{ tile, byte, actual, expected: native.tiles[tile * 16 + byte] }];
|
||||||
|
}).flat(),
|
||||||
|
);
|
||||||
|
const mapDiff = map.flatMap((tile, i) => (tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }]));
|
||||||
|
const attrDiff = attrs.filter((v, i) => v !== native.attrs[i]).length;
|
||||||
|
const paletteDiff = JSON.stringify(palette) !== JSON.stringify(native.palettes[0]);
|
||||||
|
return mapDiff.length || attrDiff || paletteDiff || tileDiff.length
|
||||||
|
? [{ label, mapDiff: mapDiff.slice(0, 10), attrDiff, palette, nativePalette: native.palettes[0], tileDiff: tileDiff.slice(0, 5) }]
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
const failures: unknown[] = [];
|
||||||
|
const mons = [
|
||||||
|
{ species: 25, dvs: 0xaaaa, level: 42, nickname: "SPARK", otId: 12345 },
|
||||||
|
{ species: 201, dvs: 0xffff, level: 100, nickname: "UNOWN", otId: 0 },
|
||||||
|
{ species: 29, dvs: 0, level: 5, nickname: "NIDORAN♀", otId: 65535 },
|
||||||
|
{ species: 157, dvs: 0xfaaa, level: 64, nickname: "TYPHLOSION", otId: 35209 },
|
||||||
|
...Array.from({ length: 251 }, (_, i) => ({
|
||||||
|
species: i + 1,
|
||||||
|
dvs: i % 3 ? 0x2aaa : 0xffff,
|
||||||
|
level: 1 + (i % 100),
|
||||||
|
nickname: "INDUCTED",
|
||||||
|
otId: i * 257,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
for (const [index, mon] of mons.entries()) {
|
||||||
|
const record = [
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
record.forEach((value, offset) => write(a.temp + offset, value));
|
||||||
|
write(a.species, mon.species);
|
||||||
|
write(a.tempDVs, mon.dvs >> 8);
|
||||||
|
write(a.tempDVs + 1, mon.dvs & 255);
|
||||||
|
write(a.frame, index % 8);
|
||||||
|
const returned = run(routines.famer, a.temp, true);
|
||||||
|
const native = newHallOfFamerScreen(assets.pokemon, mon, index % 8);
|
||||||
|
const tiles = [...new Set(native.map)];
|
||||||
|
failures.push(...(returned ? compare(mon, native, tiles) : [{ mon, returned }]));
|
||||||
|
}
|
||||||
|
const players = [
|
||||||
|
{ name: "KAWA", id: 35209, female: false, time: { hours: 1, minutes: 23 } },
|
||||||
|
{ name: "KRIS", id: 0, female: true, time: { hours: 999, minutes: 59 } },
|
||||||
|
{ name: "ABCDEFG", id: 65535, female: false, time: { hours: 0, minutes: 0 } },
|
||||||
|
{ name: "ᴾᴷᴹᴺ", id: 42, female: true, time: { hours: 42, minutes: 7 } },
|
||||||
|
];
|
||||||
|
for (const [index, player] of players.entries()) {
|
||||||
|
write(0xff70, 1);
|
||||||
|
const name = [...textTiles(player.name), 0x50];
|
||||||
|
name.forEach((value, offset) => write(a.name + offset, value));
|
||||||
|
write(a.id, player.id >> 8);
|
||||||
|
write(a.id + 1, player.id & 255);
|
||||||
|
write(a.hours, player.time.hours >> 8);
|
||||||
|
write(a.hours + 1, player.time.hours & 255);
|
||||||
|
write(a.minutes, player.time.minutes);
|
||||||
|
write(a.gender, player.female ? 1 : 0);
|
||||||
|
write(a.setupFlags, 0);
|
||||||
|
write(a.frame, index % 8);
|
||||||
|
const returned = run(routines.player);
|
||||||
|
const native = hallOfFamePlayerCard(
|
||||||
|
assets.pokemon,
|
||||||
|
{ ...player, time: { ...player.time, seconds: 0, frames: 0, capped: false } },
|
||||||
|
index % 8,
|
||||||
|
);
|
||||||
|
// The card's tiles, and the back picture loaded from $31.
|
||||||
|
const tiles = [...new Set([...native.map, ...Array.from({ length: 36 }, (_, i) => 0x31 + i)])];
|
||||||
|
failures.push(...(returned ? compare(player, native, tiles) : [{ player, returned }]));
|
||||||
|
}
|
||||||
|
reference.dispose();
|
||||||
|
return { mons: mons.length, failures };
|
||||||
|
},
|
||||||
|
{ a, routines, skip, get2bpp: at("Get2bpp"), request2bpp: flat("Request2bpp") },
|
||||||
|
);
|
||||||
|
expect(result.failures, JSON.stringify(result.failures.slice(0, 3))).toEqual([]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user