Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm, Olivine City and its buildings, the Lighthouse's six floors, Routes 40 and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's house, the Poke Seer and the Lugia speech house. Olivine's port, the Battle Tower gate and Whirl Islands stay closed. The story runs from the rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to Amphy's cure, Jasmine, the MINERALBADGE and TM23. Port GiveShuckle and ReturnShuckie with every return code and Mania's wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and reused caught level, PhotoStudio's no-printer path (Printer Error 2), and PlaySlowCry. wMooMooBerries joins the saved script bytes. Port trainer item AI: the 13 healing, status and X items, the highest-level gate, item use while Mean Look or Wrap prevents switching, and the original's random and state quirks, checked against the ROM over 5,632 states. Leaders with items are no longer refused. Game Corner sprites match the original frame by frame: cursor and reel positions, Golem's whole-frame flips, frame timing, the frame a removed sprite is still drawn, and the Golem roll's screen shake. The Burned Tower rival's garbage trainer header is documented as a boundary: its seen text dispatches text command $7f into home code before any battle. The playthrough continues for six stages to Jasmine; its driver can Surf, fish and travel through floors joined by holes. New tests cover trainer items, Olivine and Cianwood side content, and reference checks for trainer items, trainer sight and Game Corner animations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c4e8521d4
commit
b5c2b19094
29 files changed
+2708
-139
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.
|
||||
|
||||
**Current milestone: from the opening to Ecruteak City and the Fog Badge.** 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. Save, export and import work now. 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: from the opening to Olivine City and the Mineral Badge.** 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. Save, export and import work now. 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.
|
||||
|
||||
@@ -22,7 +22,9 @@ In Goldenrod, get the Bicycle, challenge Whitney for the Plain Badge and answer
|
||||
|
||||
North of Goldenrod, Route 35 leads through the National Park to Route 36. On Tuesdays, Thursdays and Saturdays, enter the Bug-Catching Contest: catch the best bug you can with the Park Balls before time runs out, then wait for the judging and the prizes. Water the odd tree on Route 36 with the SquirtBottle from Goldenrod's flower shop, then follow Route 37 to Ecruteak City. Beat the five Kimono Girls in the Dance Theater for HM03 Surf. In the Burned Tower, your rival challenges you before the floor gives way and three legendary Pokémon flee; Raikou and Entei then roam Johto's routes, where you may meet them in the grass. Morty is then back in his gym for the Fog Badge.
|
||||
|
||||
The routes beyond Ecruteak, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
West of Ecruteak, Routes 38 and 39 lead past the MooMoo farm, where seven Berries cure the sick Miltank, to Olivine City. Your rival is there ahead of you, and Jasmine is at the top of the Lighthouse with her sick Ampharos. The Café gives HM04 Strength and a fisherman gives the Good Rod. Surf Routes 40 and 41 to Cianwood City: Suicune flees across the water and Eusine challenges you, the pharmacy hands over the SecretPotion, and Chuck waits behind the boulders of his gym for the Storm Badge. His wife gives HM02 Fly. Mania lends you Shuckie, the Poké Seer reads where you met your Pokémon, and the photographer offers a photo, which needs a Game Boy Printer. Bring the medicine back to the Lighthouse, then challenge Jasmine for the Mineral Badge.
|
||||
|
||||
The routes beyond Olivine, the Battle Tower and Whirl Islands, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
+11
-4
@@ -46,11 +46,12 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. |
|
||||
| `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. |
|
||||
| `game/scripts/goldenrod.ts`, `game/world/day-timers.ts` | `engine/events/name_rater.asm`, `haircut.asm`, `lucky_number.asm`, `engine/overworld/time.asm`, `engine/menus/intro_menu.asm` | Name Rater ownership checks, ROM haircut happiness tables, Lucky Number party and box matching with first-winner ties, saved number and Friday countdown with 140-day rollover. |
|
||||
| Goldenrod shops, gifts and prizes | `engine/items/mart.asm`, `engine/pokemon/move_mon.asm` (`GivePoke`), `engine/events/specials.asm` (`GameCornerPrizeMonCheckDex`), `engine/pokedex/new_pokedex_entry.asm` | All five mart types, gift OT/nickname and MAIL, full-party gifts to the box, coin commands and balance boxes, and first-owned prize registration with two A/B pages. Boxed gifts approximate an unspecified stale enemy held item as zero. SlotMachine and CardFlip remain refused. |
|
||||
| Route 35, Route 36, Route 37, Ecruteak and Burned Tower | `home/trainers.asm` (`_CheckTrainerBattle`), `mobile/mobile_41.asm` (`CheckMobileAdapterStatusSpecial`), `engine/overworld/overworld.asm` (`LoadUsedSpritesGFX`, `RefreshSprites`) | These maps' scripts (Floria and the Sudowoodo Squirtbottle battle, Arthur, Sunny, the Itemfinder house, the Kimono Girls and HM03 Surf, Eusine and the rival's Burned Tower cutscene) run on already-ported primitives. `checkTrainers` skips a map trainer whose script is `ObjectEvent` (the Burned Tower rival) once its own flag is set: the original reads `ObjectEvent`'s bytes as flag $f251, bit 1 of `wNumKeyItems` through echo RAM, which is set with the usual two or three key items; the other case is unverified. `CheckMobileAdapterStatusSpecial` is unused outside the Japanese release and always clears its result; `LoadUsedSpritesGFX`/`RefreshSprites` are VRAM sprite tile loading, presentation only. |
|
||||
| Goldenrod shops, gifts and prizes | `engine/items/mart.asm`, `engine/pokemon/move_mon.asm` (`GivePoke`), `engine/events/specials.asm` (`GameCornerPrizeMonCheckDex`), `engine/pokedex/new_pokedex_entry.asm` | All five mart types, gift OT/nickname and MAIL, full-party gifts to the box, coin commands and balance boxes, and first-owned prize registration with two A/B pages. Boxed gifts approximate an unspecified stale enemy held item as zero. SlotMachine and CardFlip run as described below. |
|
||||
| Route 35, Route 36, Route 37, Ecruteak and Burned Tower | `home/trainers.asm` (`_CheckTrainerBattle`), `mobile/mobile_41.asm` (`CheckMobileAdapterStatusSpecial`), `engine/overworld/overworld.asm` (`LoadUsedSpritesGFX`, `RefreshSprites`) | These maps' scripts (Floria and the Sudowoodo Squirtbottle battle, Arthur, Sunny, the Itemfinder house, the Kimono Girls and HM03 Surf, Eusine and the rival's Burned Tower cutscene) run on already-ported primitives. `checkTrainers` skips a map trainer whose script is `ObjectEvent` (the Burned Tower rival) once its own flag is set: the original reads `ObjectEvent`'s bytes as flag $f251, bit 1 of `wNumKeyItems` through echo RAM. `tests/reference-trainer-sight.spec.ts` runs `_CheckTrainerBattle` for every `wNumKeyItems` 0-6: the original matches the port only with two, three or six key items; otherwise it reports a sighting and `LoadTrainer_continue` copies `ObjectEvent`'s own bytes into `wTempTrainer` as a trainer header (class $26 SWIMMERM, ID $16), one past SWIMMERM's last trainer. `ReadTrainerParty`'s name-skipping loop then runs off the end of its table into the next one with no padding between them, landing on a real, undamaged record: SwimmerF's ELAINE (a single level 21 Staryu), shown with SWIMMERM's sprite, cry and class text — confirmed by running `ReadTrainerParty` and `Battle_GetTrainerName` themselves. That battle is never reached cleanly: `StartBattleWithMapTrainerScript` first prints the decoded seen text ($46ee in the map scripts' bank, the middle of `SproutTower1FStatueText`). Its first byte, the space $7f, is dispatched as a text command; `TextCommands` has 23 entries, so the pointer comes from home code beyond the table and execution jumps to $0428, inside the VBlank handlers. What follows depends on the whole CPU and stack state, so the port keeps skipping the sighting rather than inventing an outcome. The pursuit stops here: the trainer check runs before `CheckTileEvent`'s warp, so the sighting is reachable after the cutscene's `warpcheck`, but no end-to-end emulator replay of the scene was made (the repository has no cartridge-save export). `CheckMobileAdapterStatusSpecial` is unused outside the Japanese release and always clears its result; `LoadUsedSpritesGFX`/`RefreshSprites` are VRAM sprite tile loading, presentation only. |
|
||||
| `game/world/roam-mons.ts` | `engine/overworld/wildmons.asm` (`InitRoamMons`, `CheckEncounterRoamMon`, `UpdateRoamMons`, `JumpRoamMons`), `data/wild/roammon_maps.asm` | Raikou and Entei roam from `InitRoamMons`; Suicune's slot is kept but not started. `UpdateRoamMons` runs on connections, door and pit warps and after one wild battle in sixteen; `JumpRoamMons` on Fly, Teleport and Continue. A roaming beast can take a grass encounter, and the battle writes its HP back or clears the slot. |
|
||||
| Bug-Catching Contest | `engine/events/bug_contest/*.asm`, `maps/Route35NationalParkGate.asm`, `maps/Route36NationalParkGate.asm`, `engine/menus/start_menu.asm` | Registration, Park Balls, contestant selection, the timer on the game clock, out of balls, QUIT, judging from `BugContestantPointers` with ties to the earlier entry, prizes and `CheckPartyFullAfterContest`. The contest state saves. The status box is a text page above the start menu. 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 are checked against the ROM. Golem's walk uses two ROM frames with flips; no sound. |
|
||||
| `game/scripts/cianwood.ts`, Routes 38–41, Olivine and Cianwood | `engine/events/shuckle.asm` (`GiveShuckle`, `ReturnShuckie`), `engine/pokemon/bills_pc_top.asm` (`CheckCurPartyMonFainted`), `engine/pokemon/caught_data.asm` (`SetGiftPartyMonCaughtData`), `engine/events/poke_seer.asm`, `engine/events/print_photo.asm`, `engine/printer/printer.asm` (`PrintPartymon`, `CheckPrinterStatus`), `engine/printer/printer_serial.asm`, `engine/events/specials.asm` (`PlaySlowCry`) | Shuckie is a level 15 party-only gift with MANIA's OT, ID 518, nickname, a Berry and `LANDMARK_GIFT` caught data; it sets `ENGINE_GOT_SHUCKIE_TODAY`. `ReturnShuckie` returns all five codes; `CheckCurPartyMonFainted` looks at the rest of the party, and Mania answers `SHUCKIE_FAINTED` with his happiness speech, as the original does. `PokeSeer` follows `ReadCaughtData`, including the missing `cp [hl]` that compares only the OT ID's high byte, `GetCaughtLocation`'s event and gift landmarks, `GetCaughtLevel`'s ??? that leaves `wSeerCaughtLevel` unchanged, and `SeerAdvice`'s inclusive thresholds. `wSeerCaughtLevel` lives in the "Miscellaneous WRAM 1" union: the port keeps the Seer's own last value until a load, while in the original other users of those bytes may change it. `PhotoStudio` has no printer to talk to: the handshake reads $ff, so `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and `SendScreenToPrinter` retries until B sets `hPrinter`. The port shows "Printer Error 2" with "Press B to Cancel" on one page, then the no-photo text; the print page behind the box and its alternation with "CHECKING LINK" are not drawn. `PlaySlowCry` is audio. The story maps run on ported script commands; `tests/olivine-cianwood.test.ts` and six playthrough stages cover them. |
|
||||
| `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. |
|
||||
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
||||
|
||||
Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks.
|
||||
@@ -119,7 +120,13 @@ Later fields keep, by absence-migrated defaults: `memory`, the saved WRAM bytes
|
||||
|
||||
`home/trainers.asm` and `engine/events/trainer_scripts.asm`: talking to an `OBJECTTYPE_TRAINER` object runs `TalkToTrainerScript` with the object's header copied as `wTempTrainer`; `_CheckTrainerBattle` runs `SeenByTrainerScript` with `wSeenTrainerDistance`. The world checks sight in `PlayerEvents` order: before warps, coordinate events and encounters at the end of a step (a seen player's tile events are then skipped, as `DisableEvents` does), and on idle ticks before scene scripts and input. Walls and objects do not block sight. `TrainerWalkToPlayer`/`ComputePathToWalkToPlayer` build the raw `wMovementBuffer` bytes, which `applymovementlasttalked` decodes with the normal movement reader. `wRunningTrainerBattleScript` lives in the script run: zero at the start, set by `loadmem` after the battle so `endifjustbattled` ends the after-script. Trainer commands without a map trainer are refused. `MomTriesToBuySomething` after scripted battles does nothing while Mom's savings (not ported) are zero.
|
||||
|
||||
`engine/battle/core.asm` and `engine/battle/ai/items.asm`, `switch.asm`: when a trainer's Pokémon faints and others remain, `UpdateBattleStateAndExperienceAfterEnemyFaint` zeroes its party HP and runs `NewEnemyMonStatus`; `EnemyPartyMonEntrance` then sends out the Pokémon chosen by `FindMonInOTPartyToSwitchIntoBattle`. When every candidate is threatened it draws `BattleRandom & 7`, which tests pin. `AI_SwitchOrTryItem` runs before either side moves: `CheckAbleToSwitch`, the Often/Rarely/Sometimes thresholds, `AI_TrySwitch` and `AI_Switch` (withdraw text, HP and status back to the party). `CheckTypeMatchup` ignores the type its switch-AI callers load and uses `BATTLE_VARS_MOVE_TYPE`, so every such matchup uses the enemy's chosen move type; `FindEnemyMonsWithASuperEffectiveMove` never resets its result between Pokémon; `FindEnemyMonsWithAtLeastQuarterMaxHP` rotates HP bits instead of multiplying. All three bugs are kept. `UsedMoveText` records `wPlayerUsedMoves`. Refused: Pursuit on a switching Pokémon, Perish Song, Spikes, and trainer items. Side effects of the type scans on the move structs (overwritten types and animation) are not modelled; the next `ParseEnemyAction`/`ParsePlayerAction` reloads them.
|
||||
`engine/battle/core.asm` and `engine/battle/ai/items.asm`, `switch.asm`: when a trainer's Pokémon faints and others remain, `UpdateBattleStateAndExperienceAfterEnemyFaint` zeroes its party HP and runs `NewEnemyMonStatus`; `EnemyPartyMonEntrance` then sends out the Pokémon chosen by `FindMonInOTPartyToSwitchIntoBattle`. When every candidate is threatened it draws `BattleRandom & 7`, which tests pin. `AI_SwitchOrTryItem` runs before either side moves: `CheckAbleToSwitch`, the Often/Rarely/Sometimes thresholds, `AI_TrySwitch` and `AI_Switch` (withdraw text, HP and status back to the party). `CheckTypeMatchup` ignores the type its switch-AI callers load and uses `BATTLE_VARS_MOVE_TYPE`, so every such matchup uses the enemy's chosen move type; `FindEnemyMonsWithASuperEffectiveMove` never resets its result between Pokémon; `FindEnemyMonsWithAtLeastQuarterMaxHP` rotates HP bits instead of multiplying. All three bugs are kept. `UsedMoveText` records `wPlayerUsedMoves`. Refused: Pursuit on a switching Pokémon, Perish Song and Spikes. Side effects of the type scans on the move structs (overwritten types and animation) are not modelled; the next `ParseEnemyAction`/`ParsePlayerAction` reloads them.
|
||||
|
||||
`AI_SwitchOrTryItem`'s DontSwitch path runs `AI_TryItem`, including when Mean Look or Wrap blocks switching. Only the trainer's highest-level Pokémon (ties included) may use the two class items. All 13 `AI_Items` entries are ported: Full Restore, Max/Hyper/Super Potion, Potion, Full Heal, X Accuracy, Guard Spec, Dire Hit and the four X stats. The HP, status, first-turn and random gates preserve CONTEXT_USE, ALWAYS_USE and UNKNOWN_USE. A used item consumes the enemy's turn, updates the party, and clears the original move counters and substatuses.
|
||||
|
||||
The original bugs remain: Full Heal leaves confusion; status healing leaves Nightmare and burn/paralysis stat penalties; X stats see stale miss/failure flags; a declined second-slot item strands the first slot for the rest of the scan. That pointer can also read the base reward as a third item. The port models this extra slot, but not further drift into AI flag bytes; no real two-item trainer's base reward matches an item in this ROM. Battle Tower is still closed. `wEnemyItemState`'s redraw counter is omitted.
|
||||
|
||||
`tests/trainer-items.test.ts` covers every item and the switch-blocked paths. `tests/reference-trainer-items.spec.ts` compares `AI_TryItem` over 5,632 controlled states, including HP boundaries, stale miss/failure flags, item/party/HP/status/substatus/stat-level state and RNG draw counts. Four more reference cases cover the base-reward collision directly and through no-switch-style, Mean Look and Wrap dispatch. These isolated calls redirect Random and bypass `PrintText`, `PlaySFX`, `AnimateHPBar` and `UpdateEnemyHUD`; they do not verify presentation. `tests/trainer-battle.test.ts` exercises Clair's real Kingdra and class items through `Battle.run()`.
|
||||
|
||||
`BGEVENT_ITEM` runs `HiddenItemScript` with `wHiddenItemData`; `SetMemEvent` sets the item's flag.
|
||||
|
||||
|
||||
+21
-2
@@ -396,9 +396,28 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- Roaming Pokémon (`game/world/roam-mons.ts`): `InitRoamMons`, `UpdateRoamMons` on connections, door and pit warps and one wild battle in sixteen, `JumpRoamMons` on Fly, Teleport and Continue, and `CheckEncounterRoamMon` in the grass. Suicune's slot is kept but not started, as the ROM leaves it. Saves gain `roamMons` and `roamMonsMap`. A rollback restore (a failed import) keeps the beasts in place; only Continue moves them.
|
||||
- The Bug-Catching Contest: the gates on contest days, `GiveParkBalls`, `SelectRandomBugContestContestants`, `ContestDropOffMons`/`ContestReturnMons`, the timer on the game clock, running out of balls, QUIT in the start menu, the catch-and-swap prompt, `BugContestJudging` with the ROM's contestant table and prizes, and `CheckPartyFullAfterContest`. The contest state saves. The start menu's balls and catch status shows as a text page above the menu.
|
||||
- The Game Corner: `SlotMachine` (reels with their spin rates and scrolling, the normal and lucky bias tables, the seven streak, Golem and Chansey with the egg, payouts, the 9999-coin cap) and `CardFlip` (deck, board, cursor and group navigation, paytable). They run on a realtime tile screen, which advances two frames per world tick with the buffered button. The Card Flip board and slot frame are checked tile by tile against the ROM's own tilemaps.
|
||||
- Engine fixes found on the way: `getnum`, a plain `warp` that continues its script on the new map, `PokemonNames`' EGG entry, and a map trainer whose script is `ObjectEvent` (the Burned Tower rival) is no longer sighted once its own flag is set. In the original, its "beaten" check reads `ObjectEvent`'s bytes as flag $f251, bit 1 of `wNumKeyItems` through echo RAM, which is set with the usual two or three key items; the other case has not been checked against the original.
|
||||
- Engine fixes found on the way: `getnum`, a plain `warp` that continues its script on the new map, `PokemonNames`' EGG entry, and a map trainer whose script is `ObjectEvent` (the Burned Tower rival) is no longer sighted once its own flag is set. In the original, its "beaten" check reads `ObjectEvent`'s bytes as flag $f251, bit 1 of `wNumKeyItems` through echo RAM, which is set with the usual two or three key items; reference checks now confirm that zero, one, four or five key items make the original sight the rival with a garbage trainer header. Its seen text runs an out-of-range text command into home code before any battle, so the native port does not reproduce it (see NATIVE_PORT.md).
|
||||
- Tests: `tests/roaming.test.ts`, `tests/bug-contest.test.ts`, `tests/game-corner.test.ts` and `tests/ecruteak.test.ts`. The playthrough continues through the National Park to Floria, Sudowoodo, the Kimono Girls, the Burned Tower and Morty; its driver now chooses moves by type matchup, buys at Marts, and uses Super Potions and Awakenings in trainer battles.
|
||||
- Remaining: Tin Tower and the Clear Bell, Suicune's appearances, the Game Corner's Golem walk cycle (two ROM frames with flips), the contest status box layout, radio and rematches (#30), breeding (#29), routes beyond Ecruteak and the gyms after Morty, mobile and link features, and audio.
|
||||
- Remaining: Tin Tower and the Clear Bell, Suicune's appearances, the contest status box layout, radio and rematches (#30), breeding (#29), routes beyond Ecruteak and the gyms after Morty, mobile and link features, and audio.
|
||||
|
||||
## Implemented: trainer items and Game Corner reference follow-up (board #32)
|
||||
|
||||
- Trainer item AI now handles all 13 supported healing, status and X items, the highest-level gate, item consumption, and the original random and state quirks. Mean Look and Wrap stop switching but still allow item use. This removes the item refusal for Chuck, Jasmine and later leaders; Chuck and Jasmine are reachable since increment D.
|
||||
- Game Corner sprites now use the original screen coordinates, reel symbol window and Golem flips. Reference tests cover all cursor positions and selected reel states. The Golem, Chansey and egg are compared frame by frame against the original's `PlaySpriteAnimations`: a three-Golem run and a Chansey round with two eggs, 385 frames of shadow OAM, `wSlotsDelay` and `hSCY`. That covers the whole-frame flips, frame timing, the extra frame a removed sprite is still drawn, and the Golem's roll shake, which the slot screen now shows.
|
||||
- The Burned Tower sight check is verified for zero through six key items. With 0, 1, 4 or 5 key items the original's garbage header would battle SwimmerF's ELAINE, but its seen text first jumps into home code through text command $7f. The native suppression stays, as a documented boundary.
|
||||
- Trainer-item reference coverage compares 5,632 controlled states plus four dispatch cases. The unit suite also runs a battle using Clair's Kingdra and real class items. These comparisons bypass text/audio/HUD routines and do not establish physical-device acceptance.
|
||||
|
||||
## Implemented: Routes 38–41, Olivine and Cianwood to the Mineral Badge (board #32, increment D)
|
||||
|
||||
- Route 38 with its Ecruteak gate, Route 39 with the MooMoo farm's barn and farmhouse, Olivine City with its Pokémon Center, Mart, Café, gym and houses, the Lighthouse's six floors, Routes 40 and 41, and Cianwood City with its gym, Pokémon Center, pharmacy, Photo Studio, Mania's house, the Poké Seer and the Lugia speech house are open. Olivine's port, the Battle Tower gate and Whirl Islands stay closed.
|
||||
- The story runs on existing primitives: the rival's Olivine scene, Jasmine and Amphy, the SecretPotion (the pharmacist then runs `MARTTYPE_PHARMACY`), Suicune and Eusine at Cianwood, Chuck's boulder-throwing intro and Strength boulders, the STORMBADGE, TM01 and HM02, Amphy's cure, Jasmine's return to her gym, the MINERALBADGE and TM23.
|
||||
- New specials (`game/scripts/cianwood.ts`): `GiveShuckle` and `ReturnShuckie`, with Mania's OT, ID, nickname, Berry, gift caught data, the daily flag, every return code and the original's wrong speech when Shuckie is the last Pokémon that can fight; `PokeSeer`, with the action table, the OT check that compares only the ID's high byte, gift and event landmarks, the advice thresholds and the caught level reused from the last reading; `PhotoStudio`, which takes the no-printer path; `PlaySlowCry` (audio only). `wMooMooBerries` joins the saved script bytes.
|
||||
- `PhotoStudio` without a Game Boy Printer: `PrintPartymon`'s handshake reads $ff, `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and B cancels with no photo. The port shows that status box's text on one page; the Pokémon's print page behind it and the box alternating with "CHECKING LINK" on each retry are not drawn.
|
||||
- The Seer's reused caught level is `wSeerCaughtLevel`, a byte in the "Miscellaneous WRAM 1" union. The port keeps the Seer's own last reading until a load; in the original, other users of that union (such as `wBufferMon`) may have changed it in between.
|
||||
- The playthrough driver gained `sail` (Surf across open water, answering `AskSurfScript`), water-aware `cross` and `walk`, breadth-first `travel` through floors whose holes land in cut-off corners, Great Balls and pharmacy purchases.
|
||||
- Tests: `tests/olivine-cianwood.test.ts` covers MooMoo's seven Berries and the saved byte, every Shuckie outcome, the Seer's readings, its ID quirk and reused level, and the Photo Studio. The playthrough continues for six more stages: Routes 38 and 39 and the rival, Strength and the Good Rod, a fished Krabby taught Surf, the Lighthouse, Routes 40 and 41, Eusine, the SecretPotion, Chuck (a loss retries from Cianwood's Pokémon Center), Fly, Amphy and Jasmine.
|
||||
- Local validation (2026-10-13): 790 unit tests passed, 3 skipped (scratch tests excluded), including the 32-stage playthrough; without a ROM, 168 passed and 625 skipped. Both builds, reference/profile checks, vendor integrity and release isolation passed. Browser checks: 181 passed, 21 skipped, 12 known WebKit menu failures (#70). CI and physical-device acceptance were not run.
|
||||
- Remaining: Olivine's port and the S.S. Aqua, the Battle Tower, Whirl Islands, the Game Boy Printer, routes beyond Olivine and Cianwood (Route 42 onward), radio and rematches (#30), breeding (#29), mobile and link features, and audio.
|
||||
|
||||
## Native battles: current state
|
||||
|
||||
|
||||
+230
-9
@@ -197,8 +197,15 @@ export interface BattleTrainer {
|
||||
* heals, a faint or a withdrawal); damage alone leaves them stale. */
|
||||
party: PartyMon[];
|
||||
aiFlags: number;
|
||||
/** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */
|
||||
/** TrainerClassAttributes' AI item/switch word (SWITCH_*, ALWAYS_USE and
|
||||
* CONTEXT_USE in the low byte). */
|
||||
switchFlags: number;
|
||||
/** wEnemyTrainerItem1/2: the trainer's two held items, consumed (set to 0)
|
||||
* as AI_TryItem uses them. */
|
||||
items: [number, number];
|
||||
/** wEnemyTrainerBaseReward: the final reward uses this, and AI_TryItem's
|
||||
* item-pointer bug can misread it as a third item slot. */
|
||||
baseReward: number;
|
||||
reward: number;
|
||||
winText?: number;
|
||||
lossText?: number;
|
||||
@@ -297,7 +304,10 @@ const percent = (n: number) => Math.floor((n * 0xff) / 100);
|
||||
/** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */
|
||||
const SWITCH_OFTEN = 1 << 0,
|
||||
SWITCH_RARELY = 1 << 1,
|
||||
SWITCH_SOMETIMES = 1 << 2;
|
||||
SWITCH_SOMETIMES = 1 << 2,
|
||||
ALWAYS_USE = 1 << 4,
|
||||
UNKNOWN_USE = 1 << 5,
|
||||
CONTEXT_USE = 1 << 6;
|
||||
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
|
||||
* tier ($10, $20, $30), a Random threshold and whether a roll below it
|
||||
* switches (true) or keeps the Pokémon in (false). */
|
||||
@@ -5642,7 +5652,7 @@ export class Battle {
|
||||
yield* this.enemyPartyMonEntrance(true);
|
||||
this.battlePlayerAction = 0;
|
||||
}
|
||||
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
|
||||
/** ResetEnemyBattleVars. wEnemyItemState's HUD redraw counter is presentation-only. */
|
||||
private resetEnemyBattleVars() {
|
||||
this.player.lastCounterMove = 0;
|
||||
this.enemy.lastCounterMove = 0;
|
||||
@@ -5753,21 +5763,25 @@ export class Battle {
|
||||
}
|
||||
|
||||
/** AI_SwitchOrTryItem (engine/battle/ai/items.asm). Returns true when the
|
||||
* enemy spent its turn. Trainers holding items are refused before battle,
|
||||
* so AI_TryItem finds none. */
|
||||
* enemy spent its turn, either switching or using an item. Whenever the
|
||||
* switch decision falls through to DontSwitch (no switch style, no
|
||||
* candidate tier, or a failed roll), AI_TryItem gets a turn instead; a
|
||||
* decided switch that AI_TrySwitch then refuses (too few Pokémon left)
|
||||
* is a `jp`, not a `call`, so it never reaches DontSwitch either. */
|
||||
private *aiSwitchOrTryItem(): Gen<boolean> {
|
||||
const trainer = this.options.trainer;
|
||||
if (!trainer || this.checkEnemyLockedIn()) return false;
|
||||
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false;
|
||||
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount)
|
||||
return yield* this.aiTryItem();
|
||||
const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex(
|
||||
(bit) => trainer.switchFlags & bit,
|
||||
);
|
||||
if (style < 0) return false;
|
||||
if (style < 0) return yield* this.aiTryItem();
|
||||
const param = this.checkAbleToSwitch(),
|
||||
tier = param & 0xf0;
|
||||
if (!tier) return false;
|
||||
if (!tier) return yield* this.aiTryItem();
|
||||
const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1];
|
||||
if (this.random() < threshold !== below) return false;
|
||||
if (this.random() < threshold !== below) return yield* this.aiTryItem();
|
||||
this.enemySwitchMonIndex = (param & 0x0f) + 1;
|
||||
// AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts.
|
||||
if (trainer.party.filter((mon) => mon.hp).length < 2) return false;
|
||||
@@ -5796,6 +5810,213 @@ export class Battle {
|
||||
this.battleHasJustStarted = 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- trainer items
|
||||
|
||||
/** AI_Items (engine/battle/ai/items.asm): the trainer's items, tried in
|
||||
* this fixed order against whichever of its two slots AI_TryItem's scan
|
||||
* currently points at. */
|
||||
private aiItemHandlers(): { item: number; use: () => Gen<boolean> }[] {
|
||||
return [
|
||||
{ item: C.FULL_RESTORE, use: () => this.aiFullRestore() },
|
||||
{ item: C.MAX_POTION, use: () => this.aiMaxPotion() },
|
||||
{ item: C.HYPER_POTION, use: () => this.aiPotion(C.HYPER_POTION, 200) },
|
||||
{ item: C.SUPER_POTION, use: () => this.aiPotion(C.SUPER_POTION, 50) },
|
||||
{ item: C.POTION, use: () => this.aiPotion(C.POTION, 20) },
|
||||
{ item: C.X_ACCURACY, use: () => this.aiXSubstatus(C.X_ACCURACY, C.SUBSTATUS_X_ACCURACY) },
|
||||
{ item: C.FULL_HEAL, use: () => this.aiFullHeal() },
|
||||
{ item: C.GUARD_SPEC, use: () => this.aiXSubstatus(C.GUARD_SPEC, C.SUBSTATUS_MIST) },
|
||||
{ item: C.DIRE_HIT, use: () => this.aiXSubstatus(C.DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY) },
|
||||
{ item: C.X_ATTACK, use: () => this.aiXStat(C.X_ATTACK, C.ATTACK) },
|
||||
{ item: C.X_DEFEND, use: () => this.aiXStat(C.X_DEFEND, C.DEFENSE) },
|
||||
{ item: C.X_SPEED, use: () => this.aiXStat(C.X_SPEED, C.SPEED) },
|
||||
{ item: C.X_SPECIAL, use: () => this.aiXStat(C.X_SPECIAL, C.SP_ATTACK) },
|
||||
];
|
||||
}
|
||||
/** AI_TryItem: items are refused in the Battle Tower (not reachable in
|
||||
* this port, so skipped), with none held, or when the Pokémon out is not
|
||||
* the trainer's highest level. Otherwise AI_Items is scanned in order for
|
||||
* whichever slot (`slot`, wEnemyTrainerItem1/2 via `de`) currently holds a
|
||||
* listed item; its handler decides whether to use it.
|
||||
* BUG: `de` only starts at wEnemyTrainerItem1 once, before the scan, not
|
||||
* at each entry. Once the second slot matches and its handler declines,
|
||||
* the first slot is unreachable for the rest of THIS scan (it is tried
|
||||
* again, fresh, on the trainer's next turn); the pointer can drift once
|
||||
* more into wEnemyTrainerBaseReward (`slot` 2), which could then be
|
||||
* misread as a third item if it happened to alias one of AI_Items' ids
|
||||
* (docs/bugs_and_glitches.md "AI might use its base reward value as an
|
||||
* item"). No real two-item trainer's base reward matches an item in this
|
||||
* ROM; further drift into AI flag bytes is not modeled. */
|
||||
private *aiTryItem(): Gen<boolean> {
|
||||
const trainer = this.options.trainer!;
|
||||
if (!trainer.items[0] && !trainer.items[1]) return false;
|
||||
if (!this.aiIsHighestLevel()) return false;
|
||||
const slotValue = (index: number) => (index < 2 ? trainer.items[index] : trainer.baseReward);
|
||||
let slot = 0;
|
||||
for (const { item, use } of this.aiItemHandlers()) {
|
||||
let matched = -1;
|
||||
if (slotValue(slot) === item) matched = slot;
|
||||
else if (slot < 2 && slotValue(slot + 1) === item) matched = slot + 1;
|
||||
if (matched < 0) continue;
|
||||
slot = matched;
|
||||
if (!(yield* use())) continue;
|
||||
if (slot < 2) trainer.items[slot] = 0;
|
||||
else trainer.baseReward = 0;
|
||||
// "used item": the enemy spent its turn, and several substatuses
|
||||
// that only a real move would otherwise reset are cleared here too.
|
||||
this.enemyGoesFirst = 1;
|
||||
this.enemy.reset(SUB3, C.SUBSTATUS_BIDE);
|
||||
this.enemy.furyCutterCount = 0;
|
||||
this.enemy.protectCount = 0;
|
||||
this.enemy.rageCounter = 0;
|
||||
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||||
this.enemy.lastCounterMove = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** AI_TryItem's .IsHighestLevel: the Pokémon out must be at least as
|
||||
* high level as every other party member (ties count). */
|
||||
private aiIsHighestLevel(): boolean {
|
||||
const party = this.options.trainer!.party;
|
||||
const highest = party.reduce((level, mon) => Math.max(level, mon.level), 0);
|
||||
return party[this.curOTMon].level >= highest;
|
||||
}
|
||||
/** AICheckEnemyHalfHP: true (the asm's carry) when the enemy's HP is
|
||||
* strictly above half its maximum. */
|
||||
private aiAboveHalfHP(): boolean {
|
||||
return this.enemy.mon.hp * 2 > this.enemy.mon.maxHp;
|
||||
}
|
||||
/** AICheckEnemyQuarterHP: strictly above a quarter. */
|
||||
private aiAboveQuarterHP(): boolean {
|
||||
return this.enemy.mon.hp * 4 > this.enemy.mon.maxHp;
|
||||
}
|
||||
/** AI_Items' .HealItem, shared by Full Restore, Max/Hyper/Super Potion
|
||||
* and Potion: whether this turn should heal, gated by HP and a few
|
||||
* random rolls that CONTEXT_USE and UNKNOWN_USE change. */
|
||||
private aiHealItem(): boolean {
|
||||
const flags = this.options.trainer!.switchFlags;
|
||||
if (flags & CONTEXT_USE) {
|
||||
if (this.aiAboveHalfHP()) return false;
|
||||
if (!this.aiAboveQuarterHP()) return true;
|
||||
return this.random() < percent(20) - 1;
|
||||
}
|
||||
if (this.aiAboveHalfHP()) return false;
|
||||
if (flags & UNKNOWN_USE) {
|
||||
if (this.aiAboveQuarterHP()) return false;
|
||||
return this.random() >= percent(20) - 1;
|
||||
}
|
||||
if (!this.aiAboveQuarterHP()) return true;
|
||||
return this.random() < percent(50) + 1;
|
||||
}
|
||||
/** AI_Items' .Status, shared by Full Heal and Full Restore's
|
||||
* CONTEXT_USE path: whether the enemy's status is worth curing. */
|
||||
private aiStatus(): boolean {
|
||||
if (!this.enemy.mon.status) return false;
|
||||
const flags = this.options.trainer!.switchFlags;
|
||||
if (flags & CONTEXT_USE) {
|
||||
if (
|
||||
this.enemy.has(SUB5, C.SUBSTATUS_TOXIC) &&
|
||||
this.enemy.toxicCount >= 4 &&
|
||||
this.random() < percent(50) + 1
|
||||
)
|
||||
return true;
|
||||
return Boolean(this.enemy.mon.status & ((1 << C.FRZ) | C.SLP_MASK));
|
||||
}
|
||||
if (flags & ALWAYS_USE) return true;
|
||||
return this.random() < percent(20) - 1;
|
||||
}
|
||||
/** AI_Items' .XItem, shared by the X items, X Accuracy, Guard Spec and
|
||||
* Dire Hit: more readily used on the enemy's first turn out. */
|
||||
private aiXItem(): boolean {
|
||||
const flags = this.options.trainer!.switchFlags;
|
||||
if (!this.enemy.turnsTaken) {
|
||||
if (flags & ALWAYS_USE) return true;
|
||||
if (this.random() < percent(50) + 1) return false;
|
||||
if (flags & CONTEXT_USE) return true;
|
||||
return !(this.random() < percent(50) + 1);
|
||||
}
|
||||
if (!(flags & ALWAYS_USE)) return false;
|
||||
return this.random() < percent(20) - 1;
|
||||
}
|
||||
/** AI_HealStatus: the party struct's and the enemy's status clear, and
|
||||
* Toxic stops. Nightmare, confusion and the stat drops from burn or
|
||||
* paralysis are kept bugs (docs/bugs_and_glitches.md); CalcEnemyStats is
|
||||
* never called here. */
|
||||
private aiHealStatus() {
|
||||
this.options.trainer!.party[this.curOTMon].status = 0;
|
||||
this.enemy.mon.status = 0;
|
||||
this.enemy.reset(SUB5, C.SUBSTATUS_TOXIC);
|
||||
}
|
||||
/** PrintText_UsedItemOn then AIUpdateHUD: "<ENEMY> used ITEM on mon!",
|
||||
* the party struct following the enemy's new state, and the HUD. */
|
||||
private *enemyUsedOn(item: number): Gen {
|
||||
this.strings[RAM.monOrItemNameBuffer] = this.options.data.itemName(item);
|
||||
yield* this.say(BATTLE.EnemyUsedOnText);
|
||||
this.updateEnemyMonInParty();
|
||||
yield this.hud();
|
||||
}
|
||||
/** .FullHeal: EnemyUsedFullHeal if .Status allows it. */
|
||||
private *aiFullHeal(): Gen<boolean> {
|
||||
if (!this.aiStatus()) return false;
|
||||
this.aiHealStatus();
|
||||
yield* this.enemyUsedOn(C.FULL_HEAL);
|
||||
return true;
|
||||
}
|
||||
/** .FullRestore: .HealItem's HP gate, or (with CONTEXT_USE) .Status's
|
||||
* verdict instead; AI_HealStatus, then confusion also clears (unlike
|
||||
* Full Heal, a second kept bug), and the enemy's HP fills. */
|
||||
private *aiFullRestore(): Gen<boolean> {
|
||||
const flags = this.options.trainer!.switchFlags;
|
||||
if (!this.aiHealItem() && (!(flags & CONTEXT_USE) || !this.aiStatus())) return false;
|
||||
this.aiHealStatus();
|
||||
this.enemy.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||||
this.enemy.confuseCount = 0;
|
||||
this.enemy.mon.hp = this.enemy.mon.maxHp;
|
||||
yield* this.enemyUsedOn(C.FULL_RESTORE);
|
||||
return true;
|
||||
}
|
||||
/** .MaxPotion: .HealItem's HP gate, then a full heal without curing
|
||||
* status. */
|
||||
private *aiMaxPotion(): Gen<boolean> {
|
||||
if (!this.aiHealItem()) return false;
|
||||
this.enemy.mon.hp = this.enemy.mon.maxHp;
|
||||
yield* this.enemyUsedOn(C.MAX_POTION);
|
||||
return true;
|
||||
}
|
||||
/** .HyperPotion/.SuperPotion/.Potion: .HealItem's HP gate, then
|
||||
* RestoreHealth (EnemyPotionContinue's own inlined equivalent of the
|
||||
* same clamped add). */
|
||||
private *aiPotion(item: number, amount: number): Gen<boolean> {
|
||||
if (!this.aiHealItem()) return false;
|
||||
this.enemy.mon.hp = restoreHealth(this.enemy.mon.hp, this.enemy.mon.maxHp, amount);
|
||||
yield* this.enemyUsedOn(item);
|
||||
return true;
|
||||
}
|
||||
/** EnemyUsedXAccuracy/GuardSpec/DireHit: .XItem's gate, then the
|
||||
* SubStatus4 flag (set, never toggled off, so a repeat has no further
|
||||
* effect but still spends the item and the turn). */
|
||||
private *aiXSubstatus(item: number, bit: number): Gen<boolean> {
|
||||
if (!this.aiXItem()) return false;
|
||||
this.enemy.set(SUB4, bit);
|
||||
yield* this.enemyUsedOn(item);
|
||||
return true;
|
||||
}
|
||||
/** EnemyUsedXAttack/XDefend/XSpeed/XSpecial: .XItem's gate, the message,
|
||||
* then RaiseStat on the enemy even if the stat cannot rise. Unlike the
|
||||
* player's own X items (XItemEffect), this never clears wAttackMissed or
|
||||
* wEffectFailed first, so a stale miss or failed effect left over from
|
||||
* the last move either side used can make RaiseStat silently no-op; no
|
||||
* stat-rose or failed text is shown either way. */
|
||||
private *aiXStat(item: number, stat: number): Gen<boolean> {
|
||||
if (!this.aiXItem()) return false;
|
||||
this.strings[RAM.monOrItemNameBuffer] = this.options.data.itemName(item);
|
||||
yield* this.say(BATTLE.EnemyUsedOnText);
|
||||
this.raiseStat(stat);
|
||||
this.updateEnemyMonInParty();
|
||||
yield this.hud();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
|
||||
* a chance tier in the high nibble and a party index in the low one.
|
||||
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its
|
||||
|
||||
@@ -188,7 +188,19 @@ export const C = {
|
||||
ULTRA_BALL: 2,
|
||||
GREAT_BALL: 4,
|
||||
POKE_BALL: 5,
|
||||
FULL_RESTORE: 0x0e,
|
||||
MAX_POTION: 0x0f,
|
||||
HYPER_POTION: 0x10,
|
||||
SUPER_POTION: 0x11,
|
||||
POTION: 0x12,
|
||||
X_ACCURACY: 0x21,
|
||||
FULL_HEAL: 0x26,
|
||||
GUARD_SPEC: 0x29,
|
||||
DIRE_HIT: 0x2c,
|
||||
X_ATTACK: 0x31,
|
||||
X_DEFEND: 0x33,
|
||||
X_SPEED: 0x34,
|
||||
X_SPECIAL: 0x35,
|
||||
AWAKENING: 0x0c,
|
||||
HEAVY_BALL: 0x9d,
|
||||
LEVEL_BALL: 0x9f,
|
||||
|
||||
@@ -22,19 +22,22 @@ export function drawTileScreen(
|
||||
// background's own color index to clip against; a screen with none
|
||||
// pays nothing beyond the allocation.
|
||||
const indices = new Uint8Array(width * height);
|
||||
for (let row = 0; row < SCREEN_HEIGHT; row++)
|
||||
const scrollY = screen.scroll?.y ?? 0;
|
||||
for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++)
|
||||
for (let col = 0; col < SCREEN_WIDTH; col++) {
|
||||
const cell = row * SCREEN_WIDTH + col;
|
||||
if (screen.map[cell] < 0) continue;
|
||||
const inside = row >= 0 && row < SCREEN_HEIGHT;
|
||||
const tile = inside ? screen.map[cell] : screen.scroll!.fill;
|
||||
if (tile < 0) continue;
|
||||
drawTile(
|
||||
image.data,
|
||||
width,
|
||||
height,
|
||||
screen.tiles,
|
||||
screen.map[cell],
|
||||
tile,
|
||||
col * 8,
|
||||
row * 8,
|
||||
screen.palettes[screen.attrs[cell]] ?? screen.palettes[0],
|
||||
row * 8 - scrollY,
|
||||
(inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0],
|
||||
{ indices },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -829,6 +829,7 @@ export const BATTLE = {
|
||||
DisabledNoMoreText: 0x80d39,
|
||||
DoesntAffectText: 0x81071,
|
||||
EnemyText: 0x1295,
|
||||
EnemyUsedOnText: 0x3858c,
|
||||
EnemyWithdrewText: 0x384d0,
|
||||
ExpPointsText: 0x3f131,
|
||||
FastAsleepText: 0x80ce7,
|
||||
@@ -1063,6 +1064,30 @@ export const OPTIONS = {
|
||||
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
|
||||
* PRET labels for `npm run check:reference`. */
|
||||
export const JOHTO = {
|
||||
"GiveShuckle": 0x7305,
|
||||
"ReturnShuckie": 0x737e,
|
||||
"PokeSeer": 0x4f0bc,
|
||||
"PhotoStudio": 0x16dc7,
|
||||
"PlaySlowCry": 0xfb841,
|
||||
"SpecialShuckleOT": 0x7370,
|
||||
"SpecialShuckleNickname": 0x7376,
|
||||
"SeerSeeAllText": 0x4f28c,
|
||||
"SeerCantTellAThingText": 0x4f291,
|
||||
"SeerNameLocationText": 0x4f296,
|
||||
"SeerTimeLevelText": 0x4f29b,
|
||||
"SeerTradeText": 0x4f2a0,
|
||||
"SeerNoLocationText": 0x4f2a5,
|
||||
"SeerEggText": 0x4f2aa,
|
||||
"SeerDoNothingText": 0x4f2af,
|
||||
"SeerAdviceTexts": 0x4f2d6,
|
||||
"GetCaughtTime.times": 0x4f1e6,
|
||||
"UnknownCaughtData.unknown": 0x4f202,
|
||||
"PhotoStudio.WhichMonPhotoText": 0x16e04,
|
||||
"PhotoStudio.HoldStillText": 0x16e09,
|
||||
"PhotoStudio.NoPhotoText": 0x16e13,
|
||||
"PhotoStudio.EggPhotoText": 0x16e18,
|
||||
"GBPrinterString_PrinterError2": 0x1dc2e2,
|
||||
"String_PressBToCancel": 0x847f5,
|
||||
UnlockedUnownLetterSets: 0x3eba1,
|
||||
UnownPicPointers: 0x124000,
|
||||
"InitUnownPuzzlePiecePositions.PuzzlePieceInitialPositions": 0xe126d,
|
||||
@@ -1235,6 +1260,7 @@ export const JOHTO = {
|
||||
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
||||
* save keeps them by label in `memory`. */
|
||||
export const SAVED_WRAM = {
|
||||
wMooMooBerries: 0xd962,
|
||||
wFarfetchdPosition: 0xd964,
|
||||
wKurtApricornQuantity: 0xdca4,
|
||||
wUndergroundSwitchPositions: 0xd963,
|
||||
@@ -1243,6 +1269,11 @@ export type SavedByte = keyof typeof SAVED_WRAM;
|
||||
/** Work RAM the native port reads by address but does not save, keyed by
|
||||
* PRET labels. */
|
||||
export const WRAM = {
|
||||
"wSeerNickname": 0xd003,
|
||||
"wSeerCaughtLocation": 0xd00e,
|
||||
"wSeerTimeOfDay": 0xd01f,
|
||||
"wSeerCaughtLevelString": 0xd036,
|
||||
"wSeerOT": 0xd02a,
|
||||
wTilemap: 0xc4a0,
|
||||
wBugContestWinnerName: 0xd016,
|
||||
wBugContestFirstPlaceScore: 0xd004,
|
||||
@@ -1264,6 +1295,36 @@ export const LAST_MAP_GROUP_SIZE = 11;
|
||||
* maps remain unavailable; the label only distinguishes interiors that share
|
||||
* a ROM landmark name. */
|
||||
export const NATIVE_MAPS: Record<string, string> = {
|
||||
"1:1": "Olivine Pokecenter 1F",
|
||||
"1:2": "Olivine Gym",
|
||||
"1:3": "Olivine Tims House",
|
||||
"1:4": "Olivine House Beta",
|
||||
"1:5": "Olivine Punishment Speech House",
|
||||
"1:6": "Olivine Good Rod House",
|
||||
"1:7": "Olivine Cafe",
|
||||
"1:8": "Olivine Mart",
|
||||
"1:9": "Route 38Ecruteak Gate",
|
||||
"1:10": "Route 39Barn",
|
||||
"1:11": "Route 39Farmhouse",
|
||||
"1:12": "Route 38",
|
||||
"1:13": "Route 39",
|
||||
"1:14": "Olivine City",
|
||||
"3:42": "Olivine Lighthouse 1F",
|
||||
"3:43": "Olivine Lighthouse 2F",
|
||||
"3:44": "Olivine Lighthouse 3F",
|
||||
"3:45": "Olivine Lighthouse 4F",
|
||||
"3:46": "Olivine Lighthouse 5F",
|
||||
"3:47": "Olivine Lighthouse 6F",
|
||||
"22:1": "Route 40",
|
||||
"22:2": "Route 41",
|
||||
"22:3": "Cianwood City",
|
||||
"22:4": "Manias House",
|
||||
"22:5": "Cianwood Gym",
|
||||
"22:6": "Cianwood Pokecenter 1F",
|
||||
"22:7": "Cianwood Pharmacy",
|
||||
"22:8": "Cianwood Photo Studio",
|
||||
"22:9": "Cianwood Lugia Speech House",
|
||||
"22:10": "Poke Seers House",
|
||||
"24:3": "Route 29",
|
||||
"24:13": "Route 29 gate",
|
||||
"24:4": "New Bark Town",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ScreenSprite,
|
||||
type TileScreen,
|
||||
} from "./screen";
|
||||
import { checkCoinsAndCoinCase, signed8 } from "./slot-machine";
|
||||
import { checkCoinsAndCoinCase } from "./slot-machine";
|
||||
|
||||
/** CARDFLIP_DECK_SIZE (constants/misc_constants.asm): 4 species x 6 levels. */
|
||||
const DECK_SIZE = 24;
|
||||
@@ -208,8 +208,12 @@ function drawMessage(screen: TileScreen, pages: string[]) {
|
||||
* the slot machine's own sprite frames. The pointer is resolved and read
|
||||
* directly, rather than this port naming which named table each of the 48
|
||||
* cells uses. CGB draws these every tick regardless of hVBlankCounter
|
||||
* (DMG's own "and $4" blink only applies without hCGB set). */
|
||||
function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): ScreenSprite[] {
|
||||
* (DMG's own "and $4" blink only applies without hCGB set). The anchor and
|
||||
* each style entry are raw hardware OAM bytes (CardFlip_CopyOAM just adds
|
||||
* them with 8-bit wraparound into wShadowOAM); ScreenSprite.x/y are plain
|
||||
* screen pixels, 8 (x) and 16 (y) below that, as unown-puzzle.ts's own OAM
|
||||
* reader converts. */
|
||||
export function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): ScreenSprite[] {
|
||||
const table = JOHTO["CardFlip_UpdateCursorOAM.OAMData"];
|
||||
const cell = cursorY * 6 + cursorX;
|
||||
const entry = table + cell * 4;
|
||||
@@ -221,8 +225,8 @@ function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): Scr
|
||||
const p = style + 1 + i * 4;
|
||||
const attr = reader.byte(p + 3);
|
||||
return {
|
||||
x: anchorX + signed8(reader.byte(p + 1)),
|
||||
y: anchorY + signed8(reader.byte(p)),
|
||||
x: ((anchorX + reader.byte(p + 1)) & 0xff) - 8,
|
||||
y: ((anchorY + reader.byte(p)) & 0xff) - 16,
|
||||
tile: OBJ_BASE + reader.byte(p + 2),
|
||||
palette: attr & 7, // OAM_PALETTE (CGB)
|
||||
flipX: Boolean(attr & 0x20), // OAM_XFLIP
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { JOHTO, WRAM } from "../rom/profile";
|
||||
import { generatePartyMon } from "../party/pokemon";
|
||||
import { readString } from "./text";
|
||||
import { landmarkName } from "./interpreter";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
|
||||
const SHUCKLE = 213, BERRY = 0xad, MANIA_ID = 518;
|
||||
// ENGINE_GOT_SHUCKIE_TODAY (constants/engine_flags.asm).
|
||||
const GOT_SHUCKIE_TODAY = 85;
|
||||
|
||||
/** GiveShuckle: a party-only gift; no nickname prompt or box fallback. */
|
||||
export function giveShuckle(c: SpecialContext): number {
|
||||
if (c.state.party.length >= 6) return 0;
|
||||
const mon = generatePartyMon(c.data, {
|
||||
species: SHUCKLE, level: 15, item: BERRY,
|
||||
otId: MANIA_ID, ot: readString(c.reader, JOHTO.SpecialShuckleOT, c.text()),
|
||||
dvs: (c.random() << 8) | c.random(), caughtData: 0x7e,
|
||||
});
|
||||
mon.nickname = readString(c.reader, JOHTO.SpecialShuckleNickname, c.text());
|
||||
c.state.party.push(mon);
|
||||
c.state.pokedex.seen.add(SHUCKLE);
|
||||
c.state.pokedex.caught.add(SHUCKLE);
|
||||
c.state.flags.add(GOT_SHUCKIE_TODAY);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** ReturnShuckie, including CheckCurPartyMonFainted's check of the OTHER
|
||||
* party members and Mania's original last-Pokémon dialogue bug. */
|
||||
export function* returnShuckie(c: SpecialContext): Gen<number> {
|
||||
const slot = yield* c.selectMon();
|
||||
if (slot < 0) return 1;
|
||||
const mon = c.state.party[slot];
|
||||
if (mon.egg || mon.species !== SHUCKLE || mon.otId !== MANIA_ID ||
|
||||
mon.ot !== readString(c.reader, JOHTO.SpecialShuckleOT, c.text())) return 0;
|
||||
if (!c.state.party.some((other, i) => i !== slot && other.hp > 0)) return 4;
|
||||
if (mon.happiness >= 150) return 3;
|
||||
c.state.party.splice(slot, 1);
|
||||
return 2;
|
||||
}
|
||||
|
||||
/** ReadCaughtData's action and level, including its missing low-ID-byte
|
||||
* comparison. A zero caught level leaves the scratch level unchanged. */
|
||||
export function seerReading(caught: number, otId: number, playerId: number, previousLevel: number) {
|
||||
const level = (caught >> 8) & 63, location = caught & 0x7f;
|
||||
const caughtLevel = level ? (level === 63 ? 5 : level) : previousLevel;
|
||||
const action = !caught ? 2 : location === 0x7e ? 3 : location === 0x7f ? 4 :
|
||||
(otId >> 8) === (playerId >> 8) ? 0 : 1;
|
||||
return { action, caughtLevel, level, location, time: caught >> 14 };
|
||||
}
|
||||
|
||||
export function* pokeSeer(c: SpecialContext, scratch: { caughtLevel: number }): Gen {
|
||||
const say = function* (address: number): Gen { c.write(address); yield* c.flush(); };
|
||||
yield* say(JOHTO.SeerSeeAllText);
|
||||
const slot = yield* c.selectMon();
|
||||
if (slot < 0) return yield* say(JOHTO.SeerDoNothingText);
|
||||
const mon = c.state.party[slot];
|
||||
if (mon.egg) return yield* say(JOHTO.SeerEggText);
|
||||
const reading = seerReading(mon.caughtData, mon.otId, c.player().id, scratch.caughtLevel);
|
||||
if (reading.action === 2) return yield* say(JOHTO.SeerCantTellAThingText);
|
||||
scratch.caughtLevel = reading.caughtLevel;
|
||||
const unknown = readString(c.reader, JOHTO["UnknownCaughtData.unknown"], c.text());
|
||||
c.strings[WRAM.wSeerNickname] = mon.nickname;
|
||||
c.strings[WRAM.wSeerOT] = mon.ot;
|
||||
c.strings[WRAM.wSeerCaughtLevelString] = reading.level ? String(reading.caughtLevel) : "???";
|
||||
c.strings[WRAM.wSeerCaughtLocation] = reading.location < 0x7e && reading.location
|
||||
? landmarkName(c.reader, reading.location, c.text()) : unknown;
|
||||
let time = JOHTO["GetCaughtTime.times"];
|
||||
for (let i = 1; i < reading.time; i++) while (c.reader.byte(time++) !== 0x50);
|
||||
c.strings[WRAM.wSeerTimeOfDay] = reading.time ? readString(c.reader, time, c.text()) : unknown;
|
||||
if (reading.action === 3) return yield* say(JOHTO.SeerCantTellAThingText);
|
||||
if (reading.action === 4) yield* say(JOHTO.SeerNoLocationText);
|
||||
else {
|
||||
yield* say(reading.action === 0 ? JOHTO.SeerNameLocationText : JOHTO.SeerTradeText);
|
||||
yield* say(JOHTO.SeerTimeLevelText);
|
||||
}
|
||||
let row = JOHTO.SeerAdviceTexts;
|
||||
while (((mon.level - reading.caughtLevel) & 255) > c.reader.byte(row)) row += 3;
|
||||
yield* say(c.reader.near(row + 1, c.reader.bank(row)));
|
||||
}
|
||||
|
||||
/** PhotoStudio's selection and absent-printer cancellation path. The native
|
||||
* game has no printer transport; never report a successful print. With no
|
||||
* printer, PrintPartymon's handshake reads $ff, CheckPrinterStatus reports
|
||||
* PRINTER_ERROR_2 and SendScreenToPrinter retries until B sets hPrinter.
|
||||
* The page shows that status box's text; the Pokémon's print page behind
|
||||
* it and the box's alternation with "CHECKING LINK" on each retry are not
|
||||
* drawn. */
|
||||
export function* photoStudio(c: SpecialContext): Gen {
|
||||
c.write(JOHTO["PhotoStudio.WhichMonPhotoText"]);
|
||||
const slot = yield* c.selectMon();
|
||||
if (slot >= 0 && c.state.party[slot].egg) c.write(JOHTO["PhotoStudio.EggPhotoText"]);
|
||||
else {
|
||||
if (slot >= 0) {
|
||||
c.write(JOHTO["PhotoStudio.HoldStillText"]);
|
||||
yield* c.flush();
|
||||
const status = readString(c.reader, JOHTO.GBPrinterString_PrinterError2, c.text(), 64);
|
||||
const cancel = readString(c.reader, JOHTO.String_PressBToCancel, c.text());
|
||||
yield { kind: "text", pages: [`${status}\n\n${cancel}`] };
|
||||
}
|
||||
c.write(JOHTO["PhotoStudio.NoPhotoText"]);
|
||||
}
|
||||
yield* c.flush();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Decorations,
|
||||
} from "../world/decorations";
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood";
|
||||
import {
|
||||
EMPTY_ROUTINES,
|
||||
ENGINE_FLAGS,
|
||||
@@ -673,6 +674,8 @@ export type ScriptEffect =
|
||||
|
||||
/** What the world supplies about the present moment. */
|
||||
export interface ScriptHost {
|
||||
/** Volatile Poké Seer scratch byte; not cartridge save data. */
|
||||
seerMemory?: { caughtLevel: number };
|
||||
mapId: string;
|
||||
lastTalked: number;
|
||||
/** A map's scene variable owner, or undefined when it has no scenes. */
|
||||
@@ -3375,6 +3378,19 @@ export class ScriptRun {
|
||||
case JOHTO.NameRater:
|
||||
yield* nameRater(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.GiveShuckle:
|
||||
return giveShuckle(this.specialContext(pages, take));
|
||||
case JOHTO.ReturnShuckie:
|
||||
return yield* returnShuckie(this.specialContext(pages, take));
|
||||
case JOHTO.PokeSeer:
|
||||
yield* pokeSeer(this.specialContext(pages, take), this.host.seerMemory ?? { caughtLevel: 0 });
|
||||
return value;
|
||||
case JOHTO.PhotoStudio:
|
||||
yield* photoStudio(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.PlaySlowCry:
|
||||
// Cry pitch/duration is audio, like the ordinary cry opcode.
|
||||
return value;
|
||||
case JOHTO.GameCornerPrizeMonCheckDex:
|
||||
if (this.state.pokedex.caught.has(value)) return value;
|
||||
this.state.pokedex.seen.add(value);
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface TileScreen {
|
||||
label: string;
|
||||
/** Drawn over the overworld, like a menu box, rather than replacing it. */
|
||||
overlay?: boolean;
|
||||
/** hSCY as a signed pixel offset: the background (not the sprites)
|
||||
* moves 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). */
|
||||
scroll?: { y: number; fill: number };
|
||||
}
|
||||
|
||||
/** A GBC color (RGB555, little-endian word) as RGB. */
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface SlotsSession {
|
||||
golem?: GolemState;
|
||||
chansey?: ChanseyState;
|
||||
egg?: EggState;
|
||||
/** hSCY: the background shake while a Golem rolls (0 or unset at rest). */
|
||||
scy?: number;
|
||||
}
|
||||
|
||||
export function readReelTilemap(reader: RomReader, address: number): number[] {
|
||||
@@ -288,28 +290,27 @@ const CREATURE_BASE_TILE = 0x100;
|
||||
const GOLEM_VTILE = [0x00, 0x08] as const; // SLOTS_GOLEM_1/_2 (walk frames)
|
||||
const CHANSEY_VTILE = 0x10; // every SLOTS_CHANSEY_* OAMSET shares this base
|
||||
const EGG_VTILE = 0x3a;
|
||||
/** Frameset_SlotsGolem/_SlotsChansey/_SlotsChansey2 (data/sprite_anims/
|
||||
* framesets.asm): each OAM frame shown for 7 ticks. Golem's own 3rd/4th
|
||||
* frames (the same art, Y/X-flipped) are left out, cycling between its
|
||||
* first two instead: flipping a multi-sprite frame as a whole is outside
|
||||
* this port's per-sprite flip model, and the visual difference is minor
|
||||
* for a 2-frame alternation. Chansey's two framesets have no flips and
|
||||
* are reproduced in full. */
|
||||
const GOLEM_FRAMES = [0, 1] as const;
|
||||
/** GetSpriteAnimFrame holds each duration-7 frame for its initial draw
|
||||
* plus seven decrements. Golem's third/fourth frames flip the whole OAM. */
|
||||
const GOLEM_FRAMES = [0, 1, 0, 1] as const;
|
||||
const CHANSEY_FRAMES_1 = [0, 1, 0, 2] as const;
|
||||
const CHANSEY_FRAMES_2 = [0, 3, 4, 3, 0] as const;
|
||||
const CREATURE_FRAME_TICKS = 7;
|
||||
|
||||
/** Slots_AnimateGolem's own state (SPRITEANIMSTRUCT_VAR1/VAR3/XOFFSET;
|
||||
* VAR2's hSCY screen shake while rolling is not presented). */
|
||||
/** Slots_AnimateGolem's own state (SPRITEANIMSTRUCT_VAR1/VAR2/VAR3/
|
||||
* XOFFSET; VAR2 is the hSCY shake while rolling). */
|
||||
interface GolemState {
|
||||
phase: "fall" | "roll";
|
||||
phase: "init" | "fall" | "roll";
|
||||
var1: number;
|
||||
var2?: number;
|
||||
var3: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
frame: number;
|
||||
frameTick: number;
|
||||
/** DeinitializeSprite ran this tick: UpdateAnimFrame still draws the
|
||||
* struct once, and DoNextFrameForAllSprites skips it from the next. */
|
||||
removed?: boolean;
|
||||
}
|
||||
/** Slots_AnimateChansey's own state, plus SpriteAnimFunc_SlotsChansey's
|
||||
* extra check that swaps it to the second frameset once wSlotsDelay
|
||||
@@ -318,32 +319,35 @@ interface GolemState {
|
||||
* WaitChansey, so the whole walk-free loop (wait, lay, spawn) repeats for
|
||||
* as long as the egg keeps rolling to the wrong result. */
|
||||
interface ChanseyState {
|
||||
phase: "walk" | "one" | "retain" | "two";
|
||||
phase: "walk" | "one" | "two";
|
||||
xCoord: number;
|
||||
var1: number;
|
||||
frameset: 1 | 2;
|
||||
frame: number;
|
||||
frameTick: number;
|
||||
removed?: boolean;
|
||||
}
|
||||
/** SpriteAnimFunc_SlotsChanseyEgg's own countdown/bounce state. */
|
||||
interface EggState {
|
||||
counter: number;
|
||||
xCoord: number;
|
||||
yOffset: number;
|
||||
removed?: boolean;
|
||||
}
|
||||
function advanceFrame(frames: readonly number[], frame: number, tick: number): [number, number] {
|
||||
if (++tick < CREATURE_FRAME_TICKS) return [frame, tick];
|
||||
return [(frame + 1) % frames.length, 0];
|
||||
function advanceFrame(frames: readonly number[], frame: number, tick: number, loop = true): [number, number] {
|
||||
if (tick > 0) return [frame, tick - 1];
|
||||
const next = frame + 1;
|
||||
return [next < frames.length ? next : loop ? 0 : frames.length - 1, CREATURE_FRAME_TICKS];
|
||||
}
|
||||
/** ReelAction_InitGolem's sprite init (depixel 12, 13). */
|
||||
function initGolem(session: SlotsSession, count: number) {
|
||||
export function initGolem(session: SlotsSession, count: number) {
|
||||
session.golem = {
|
||||
phase: "fall",
|
||||
var1: 0x30,
|
||||
var3: count - 1,
|
||||
phase: "init",
|
||||
var1: 0,
|
||||
var3: count,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
frame: 0,
|
||||
frame: -1,
|
||||
frameTick: 0,
|
||||
};
|
||||
}
|
||||
@@ -351,13 +355,33 @@ function initGolem(session: SlotsSession, count: number) {
|
||||
* DoNextFrameForFirst16Sprites runs after Slots_SpinReels): falls, then
|
||||
* rolls across until var3 (the probe's count) is exhausted, at which
|
||||
* point wSlotsDelay=2 tells ReelAction_WaitGolem to stop reel 3 for real. */
|
||||
function stepGolem(session: SlotsSession, reader: RomReader) {
|
||||
export function stepGolem(session: SlotsSession, reader: RomReader) {
|
||||
const g = session.golem;
|
||||
if (!g) return;
|
||||
if (g.removed) {
|
||||
session.golem = undefined;
|
||||
return;
|
||||
}
|
||||
animateGolem(session, g, reader);
|
||||
// UpdateAnimFrame, after the animation function.
|
||||
[g.frame, g.frameTick] = advanceFrame(GOLEM_FRAMES, g.frame, g.frameTick);
|
||||
}
|
||||
function animateGolem(session: SlotsSession, g: GolemState, reader: RomReader) {
|
||||
if (g.phase === "init") {
|
||||
if (!g.var3) {
|
||||
session.delay = 2;
|
||||
g.removed = true;
|
||||
return;
|
||||
}
|
||||
g.var3--;
|
||||
g.phase = "fall";
|
||||
g.var1 = 0x30;
|
||||
g.xOffset = 0;
|
||||
}
|
||||
if (g.phase === "fall") {
|
||||
if (g.var1 < 0x20) {
|
||||
g.phase = "roll";
|
||||
g.var2 = 2;
|
||||
session.delay = 1; // .play_sound: one golem has landed; hop reel 3.
|
||||
return;
|
||||
}
|
||||
@@ -370,34 +394,41 @@ function stepGolem(session: SlotsSession, reader: RomReader) {
|
||||
const old = g.xOffset;
|
||||
g.xOffset += 2;
|
||||
if (old >= 9 * 8) {
|
||||
if (g.var3 === 0) {
|
||||
session.delay = 2; // .init with VAR3 == 0: no golems left; stop for real.
|
||||
session.golem = undefined;
|
||||
return;
|
||||
}
|
||||
g.var3--;
|
||||
g.phase = "fall";
|
||||
g.var1 = 0x30;
|
||||
g.xOffset = 0;
|
||||
g.yOffset = 0;
|
||||
g.phase = "init"; // .restart
|
||||
session.scy = 0;
|
||||
} else if (!(old & 3)) {
|
||||
g.var2 = -(g.var2 ?? 0) & 0xff;
|
||||
session.scy = g.var2;
|
||||
}
|
||||
}
|
||||
function golemSprites(reader: RomReader, g: GolemState): ScreenSprite[] {
|
||||
export function golemSprites(reader: RomReader, g: GolemState): ScreenSprite[] {
|
||||
const oam = readOamFrame(reader, JOHTO["SpriteAnimOAMData.OAMData_SlotsGolem"]);
|
||||
const base = CREATURE_BASE_TILE + GOLEM_VTILE[g.frame];
|
||||
const x = 12 * 8 + g.xOffset,
|
||||
y = 13 * 8 + g.yOffset;
|
||||
const base = CREATURE_BASE_TILE + GOLEM_VTILE[GOLEM_FRAMES[g.frame]];
|
||||
const frameFlip = g.frame === 2 ? 0x40 : g.frame === 3 ? 0x20 : 0;
|
||||
// `depixel 12, 13` loads D=12*8, E=13*8, but _InitSpriteAnimStruct
|
||||
// stores SPRITEANIMSTRUCT_XCOORD from E and YCOORD from D (confirmed
|
||||
// against the reference emulator): the pair ends up swapped from
|
||||
// depixel's own argument order. Hardware OAM bytes are also 8 (x) and 16
|
||||
// (y) beyond the screen's (see cursorSprites' own doc comment).
|
||||
const x = 13 * 8 + g.xOffset,
|
||||
y = 12 * 8 + g.yOffset;
|
||||
// Golem's body is left-right symmetric: the OAM table reuses the left
|
||||
// column's two tiles for the right column, X-flipped (OAM_XFLIP).
|
||||
return oam.map(({ dy, dx, tile, attr }) => ({
|
||||
x: x + dx,
|
||||
y: y + dy,
|
||||
x: ((x + (frameFlip & 0x20 ? -8 - dx : dx)) & 0xff) - 8,
|
||||
y: ((y + (frameFlip & 0x40 ? -8 - dy : dy)) & 0xff) - 16,
|
||||
tile: base + tile,
|
||||
palette: attr & 7,
|
||||
flipX: Boolean((attr ^ frameFlip) & 0x20),
|
||||
flipY: Boolean((attr ^ frameFlip) & 0x40),
|
||||
}));
|
||||
}
|
||||
|
||||
/** ReelAction_InitChansey's sprite init (depixel 12, 0). */
|
||||
function initChansey(session: SlotsSession) {
|
||||
session.chansey = { phase: "walk", xCoord: 12 * 8, var1: 0, frameset: 1, frame: 0, frameTick: 0 };
|
||||
/** ReelAction_InitChansey's sprite init (depixel 12, 0): XCOORD (which
|
||||
* `.walk` below moves) comes out as E, i.e. 0, not D's 12*8 (see
|
||||
* golemSprites' doc comment on the swap). */
|
||||
export function initChansey(session: SlotsSession) {
|
||||
session.chansey = { phase: "walk", xCoord: 0, var1: 0, frameset: 1, frame: -1, frameTick: 0 };
|
||||
}
|
||||
/** Slots_AnimateChansey plus SpriteAnimFunc_SlotsChansey's own check,
|
||||
* run once per tick after the reels spin. Walks in, then waits for
|
||||
@@ -406,48 +437,41 @@ function initChansey(session: SlotsSession) {
|
||||
* spawns the egg and goes back to waiting: EggAgain (`reelActionStep`'s
|
||||
* DropReel case) sends the reel back to WaitChansey each time the egg
|
||||
* rolls to the wrong result, repeating this whole cycle. */
|
||||
function stepChansey(session: SlotsSession) {
|
||||
export function stepChansey(session: SlotsSession) {
|
||||
const c = session.chansey;
|
||||
if (!c) return;
|
||||
const frames = c.frameset === 1 ? CHANSEY_FRAMES_1 : CHANSEY_FRAMES_2;
|
||||
[c.frame, c.frameTick] = advanceFrame(frames, c.frame, c.frameTick);
|
||||
if (c.phase === "walk") {
|
||||
c.xCoord++;
|
||||
if (c.xCoord !== 13 * 8) return;
|
||||
c.phase = "one";
|
||||
session.delay = 1; // .limit: ReelAction_WaitChansey can now proceed.
|
||||
// Falls through to "one", as .limit does.
|
||||
}
|
||||
if (c.phase === "one") {
|
||||
if (session.delay === 5) {
|
||||
// .one's other check: the whole sequence is over; reset the pose.
|
||||
c.frame = 0;
|
||||
c.frameTick = 0;
|
||||
return;
|
||||
}
|
||||
if (session.delay !== 2) return;
|
||||
c.phase = "retain";
|
||||
// Falls through to "retain", as .retain does.
|
||||
}
|
||||
if (c.phase === "retain") {
|
||||
c.phase = "two";
|
||||
c.var1 = 8;
|
||||
c.frameset = 2;
|
||||
c.frame = 0;
|
||||
c.frameTick = 0;
|
||||
session.delay = 3; // SpriteAnimFunc_SlotsChansey's own extra write.
|
||||
// Falls through to "two", as .retain does.
|
||||
}
|
||||
// .two
|
||||
if (c.var1 > 0) {
|
||||
c.var1--;
|
||||
if (c.removed) {
|
||||
session.chansey = undefined;
|
||||
return;
|
||||
}
|
||||
// .spawn_egg
|
||||
c.phase = "one";
|
||||
session.egg = { counter: 0, xCoord: 12 * 8, yOffset: 0 };
|
||||
if (c.phase === "walk") {
|
||||
const old = c.xCoord++;
|
||||
if (old === 13 * 8) { c.phase = "one"; session.delay = 1; }
|
||||
}
|
||||
if (c.phase === "one") {
|
||||
if (session.delay === 5) c.removed = true;
|
||||
if (session.delay === 2) { c.phase = "two"; c.var1 = 8; }
|
||||
}
|
||||
if (c.phase === "two" && !c.removed) {
|
||||
if (c.var1) c.var1--;
|
||||
else {
|
||||
c.phase = "one";
|
||||
session.egg = { counter: 0, xCoord: 13 * 8 + 4, yOffset: 0 };
|
||||
}
|
||||
}
|
||||
// SpriteAnimFunc_SlotsChansey runs this after Slots_AnimateChansey.
|
||||
if (session.delay === 2) {
|
||||
session.delay = 3;
|
||||
c.frameset = 2;
|
||||
c.frame = -1;
|
||||
c.frameTick = 0;
|
||||
}
|
||||
[c.frame, c.frameTick] = advanceFrame(
|
||||
c.frameset === 1 ? CHANSEY_FRAMES_1 : CHANSEY_FRAMES_2,
|
||||
c.frame, c.frameTick, c.frameset === 1,
|
||||
);
|
||||
}
|
||||
function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
|
||||
export function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
|
||||
const address =
|
||||
c.frameset === 1
|
||||
? [
|
||||
@@ -466,10 +490,10 @@ function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
|
||||
const oam = readOamFrame(reader, address);
|
||||
const base = CREATURE_BASE_TILE + CHANSEY_VTILE;
|
||||
const x = c.xCoord,
|
||||
y = 0;
|
||||
y = 12 * 8; // YCOORD (depixel 12, 0's D): fixed for Chansey's whole walk.
|
||||
return oam.map(({ dy, dx, tile, attr }) => ({
|
||||
x: x + dx,
|
||||
y: y + dy,
|
||||
x: ((x + dx) & 0xff) - 8,
|
||||
y: ((y + dy) & 0xff) - 16,
|
||||
tile: base + tile,
|
||||
palette: attr & 7,
|
||||
}));
|
||||
@@ -479,15 +503,19 @@ function chanseySprites(reader: RomReader, c: ChanseyState): ScreenSprite[] {
|
||||
* update: bounces in place, rolling right every other tick, until it
|
||||
* reaches x = 15 tiles and sets wSlotsDelay = 4 (ReelAction_WaitEgg's own
|
||||
* signal to continue into DropReel). */
|
||||
function stepEgg(session: SlotsSession, reader: RomReader) {
|
||||
export function stepEgg(session: SlotsSession, reader: RomReader) {
|
||||
const e = session.egg;
|
||||
if (!e) return;
|
||||
if (e.removed) {
|
||||
session.egg = undefined;
|
||||
return;
|
||||
}
|
||||
const current = e.counter; // SPRITEANIMSTRUCT_JUMPTABLE_INDEX before its own `dec`.
|
||||
e.counter--;
|
||||
e.counter = (e.counter - 1) & 0xff;
|
||||
if (current & 1) {
|
||||
// .move_right/.move_vertical: odd ticks also advance (or finish) the roll.
|
||||
if (e.xCoord >= 15 * 8) {
|
||||
session.egg = undefined;
|
||||
e.removed = true;
|
||||
session.delay = 4; // ReelAction_WaitEgg can now continue into DropReel.
|
||||
return;
|
||||
}
|
||||
@@ -496,14 +524,14 @@ function stepEgg(session: SlotsSession, reader: RomReader) {
|
||||
// .move_vertical: every tick but the finishing one bounces vertically.
|
||||
e.yOffset = sine(reader, 32, current);
|
||||
}
|
||||
function eggSprites(reader: RomReader, e: EggState): ScreenSprite[] {
|
||||
export function eggSprites(reader: RomReader, e: EggState): ScreenSprite[] {
|
||||
const oam = readOamFrame(reader, JOHTO["SpriteAnimOAMData.OAMData_1x1_Palette0"]);
|
||||
const base = CREATURE_BASE_TILE + EGG_VTILE;
|
||||
const x = e.xCoord,
|
||||
y = 13 * 8 + 4;
|
||||
y = 12 * 8 + e.yOffset; // YCOORD (depixel 12, 13, 0, 4's D) plus the sine bounce.
|
||||
return oam.map(({ dy, dx, tile, attr }) => ({
|
||||
x: x + dx,
|
||||
y: y + dy,
|
||||
x: ((x + dx) & 0xff) - 8,
|
||||
y: ((y + dy) & 0xff) - 16,
|
||||
tile: base + tile,
|
||||
palette: attr & 7,
|
||||
}));
|
||||
@@ -989,8 +1017,13 @@ function drawMessage(screen: TileScreen, pages: string[]) {
|
||||
* redraws, giving the same smooth per-pixel scroll the original draws;
|
||||
* B_OAM_PRIO clips them to the reel window against the surrounding frame
|
||||
* (see `behind` on ScreenSprite and tile-screen.ts). */
|
||||
function reelSprites(reel: Reel, xTile: number): ScreenSprite[] {
|
||||
const i = reel.position === 0 ? 14 : reel.position - 1;
|
||||
export function reelSprites(reel: Reel, xTile: number): ScreenSprite[] {
|
||||
// Slots_UpdateReelPositionAndOAM reads tilemap[position..position+3]
|
||||
// directly, not position-1 like the win-check window above
|
||||
// (Slots_GetCurrentReelState/windowAt): confirmed against the reference
|
||||
// emulator. The tilemap's three repeated trailing entries exist for this
|
||||
// 4-wide window, up to position 14.
|
||||
const i = reel.position;
|
||||
const symbols = [
|
||||
reel.tilemap[i],
|
||||
reel.tilemap[i + 1],
|
||||
@@ -1006,7 +1039,11 @@ function reelSprites(reel: Reel, xTile: number): ScreenSprite[] {
|
||||
for (let dx = 0; dx < 2; dx++)
|
||||
for (let dy = 0; dy < 2; dy++)
|
||||
sprites.push({
|
||||
x: xTile * 8 + dx * 8,
|
||||
// REEL_X_COORD is a raw hardware OAM byte, 8 beyond the screen's
|
||||
// (see cursorSprites' own doc comment in card-flip.ts); the rows
|
||||
// above are already screen pixels (REEL_Y's own starting raw
|
||||
// byte, 80, less 16).
|
||||
x: xTile * 8 - 8 + dx * 8,
|
||||
y: y + dy * 8,
|
||||
tile: SYMBOL_BASE_TILE + symbol + dx * 2 + dy,
|
||||
palette,
|
||||
@@ -1072,6 +1109,9 @@ function renderSlots(context: SpecialContext, session: SlotsSession, tiles: Uint
|
||||
if (session.chansey) sprites.push(...chanseySprites(r, session.chansey));
|
||||
if (session.egg) sprites.push(...eggSprites(r, session.egg));
|
||||
screen.sprites = sprites;
|
||||
// _SlotMachine fills the whole BG map with spaces, so the shake shows
|
||||
// spaces beyond the 18 rows it draws.
|
||||
if (session.scy) screen.scroll = { y: signed8(session.scy), fill: SPACE_TILE };
|
||||
return screen;
|
||||
}
|
||||
|
||||
|
||||
@@ -735,6 +735,7 @@ export class GameWorld {
|
||||
private turnWait = 0;
|
||||
/** Start menu, party menu and pack cursors (work RAM, not saved). */
|
||||
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
|
||||
private seerMemory = { caughtLevel: 0 };
|
||||
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
|
||||
private bumping = false;
|
||||
/** A screen shake object (earthquake, step_shake) in progress. */
|
||||
@@ -2139,6 +2140,7 @@ export class GameWorld {
|
||||
|
||||
private host(lastTalked: number, mapId = this.mapId): ScriptHost {
|
||||
return {
|
||||
seerMemory: this.seerMemory,
|
||||
mapId,
|
||||
lastTalked,
|
||||
sceneOwner: (id) => this.assets.sceneOwner(id),
|
||||
@@ -2615,8 +2617,6 @@ export class GameWorld {
|
||||
const opponent =
|
||||
effect.trainer &&
|
||||
trainers.trainer(effect.trainer.classId, effect.trainer.id);
|
||||
if (opponent?.attributes.items.some((item) => item !== 0))
|
||||
throw new Error("Trainer item AI is not ported.");
|
||||
const trainer = opponent && {
|
||||
name: opponent.name,
|
||||
className: opponent.className,
|
||||
@@ -2626,6 +2626,8 @@ export class GameWorld {
|
||||
}),
|
||||
aiFlags: opponent.attributes.aiMoveFlags,
|
||||
switchFlags: opponent.attributes.aiItemSwitchFlags,
|
||||
items: [...opponent.attributes.items] as [number, number],
|
||||
baseReward: opponent.attributes.baseReward,
|
||||
reward: opponent.reward,
|
||||
winText: effect.trainer?.winText,
|
||||
lossText: effect.trainer?.lossText,
|
||||
@@ -3746,7 +3748,10 @@ export class GameWorld {
|
||||
// reads ObjectEvent's bytes as flag $f251: through echo RAM, bit 1 of
|
||||
// wNumKeyItems, which is set with the two or three key items usual
|
||||
// here (BICYCLE, SQUIRTBOTTLE, COIN CASE). The port skips it as that
|
||||
// case does; what the original does otherwise has not been checked.
|
||||
// case does. With other counts the original sights it and prints
|
||||
// ObjectEvent's bytes as seen text, whose first byte runs text
|
||||
// command $7f beyond TextCommands: a jump into home code that the
|
||||
// port cannot reproduce (tests/reference-trainer-sight.spec.ts).
|
||||
if (
|
||||
object.script === OVERWORLD.ObjectEvent &&
|
||||
object.eventFlag !== 0xffff &&
|
||||
@@ -4874,6 +4879,7 @@ export class GameWorld {
|
||||
this.battleRun = undefined;
|
||||
this.memScript = undefined;
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
|
||||
this.seerMemory = { caughtLevel: 0 };
|
||||
this.clearNotice();
|
||||
this.introPending = false;
|
||||
// A save made during the introduction starts it again.
|
||||
|
||||
@@ -60,6 +60,8 @@ export function battleOptions(
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
||||
items: [...trainer.attributes.items] as [number, number],
|
||||
baseReward: trainer.attributes.baseReward,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ describe.skipIf(!existsSync(romPath))("held items and obedience with the local R
|
||||
const duel = (party: PartyMon[], enemy = [mon()], extra: Partial<BattleOptions> = {}) => new Battle({
|
||||
...battleOptions(assets, trainers, party, { wild: { species: 129, level: 10 } }),
|
||||
wild: undefined,
|
||||
trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, reward: 40 },
|
||||
trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, items: [0, 0], baseReward: 10, reward: 40 },
|
||||
...extra,
|
||||
});
|
||||
const drain = (run: Generator<BattleEffect, unknown, unknown>) => {
|
||||
|
||||
@@ -274,7 +274,7 @@ describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
|
||||
});
|
||||
const { battle, bag, party } = setup({
|
||||
trainer: {
|
||||
name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, reward: 10,
|
||||
name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, items: [0, 0], baseReward: 10, reward: 10,
|
||||
},
|
||||
});
|
||||
const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4);
|
||||
|
||||
@@ -270,7 +270,8 @@ describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", ()
|
||||
// by exactly that one hop for real.
|
||||
expect(reel3.position).toBe((startPosition + 1) % 15);
|
||||
expect(session.matched).toBe(SLOTS_SEVEN);
|
||||
expect(session.golem).toBeUndefined();
|
||||
// Removed (wSlotsDelay 2); a removed struct is still drawn that tick.
|
||||
expect(session.golem?.removed ?? true).toBe(true);
|
||||
});
|
||||
|
||||
it("Chansey's replay walks in, lays the egg and lets DropReel stop reel 3 on seven", () => {
|
||||
@@ -282,7 +283,7 @@ describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", ()
|
||||
// The egg always finishes its bounce (and clears itself) before
|
||||
// DropReel's own match check runs, whether that check succeeds or
|
||||
// (EggAgain) loops back through Chansey for another egg.
|
||||
expect(session.egg).toBeUndefined();
|
||||
expect(session.egg?.removed ?? true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -203,6 +203,8 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
|
||||
party: enemy,
|
||||
aiFlags: 0,
|
||||
switchFlags: 0,
|
||||
items: [0, 0],
|
||||
baseReward: 10,
|
||||
reward: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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 { caughtData, generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
|
||||
const ROUTE_39_BARN = "1:10",
|
||||
MANIAS_HOUSE = "22:4",
|
||||
PHOTO_STUDIO = "22:8",
|
||||
SEERS_HOUSE = "22:10";
|
||||
const SHUCKLE = 213,
|
||||
BERRY = 0xad,
|
||||
MANIA_OT_ID = 518,
|
||||
LANDMARK_NEW_BARK_TOWN = 1,
|
||||
LANDMARK_GIFT = 0x7e,
|
||||
LANDMARK_EVENT = 0x7f,
|
||||
DAY = 1; // DAY_F
|
||||
const EVENT_GOT_SHUCKIE = 0x45,
|
||||
EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 0x46,
|
||||
ENGINE_GOT_SHUCKIE_TODAY = 0x55,
|
||||
EVENT_HEALED_MOOMOO = 0x3d,
|
||||
EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 0x3f;
|
||||
|
||||
/** A world whose clock `now` moves. */
|
||||
const world = (date = "2026-09-22T12:00:00") => {
|
||||
let seed = 13;
|
||||
const clock = { now: new Date(date) };
|
||||
const game = new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => clock.now,
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
return { game, clock };
|
||||
};
|
||||
const mon = (
|
||||
game: GameWorld,
|
||||
species: number,
|
||||
level: number,
|
||||
extra: Partial<ReturnType<typeof generatePartyMon>> = {},
|
||||
) =>
|
||||
Object.assign(
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: game.playerId,
|
||||
ot: "KAWA",
|
||||
dvs: 0x8888,
|
||||
caughtData: 0,
|
||||
}),
|
||||
extra,
|
||||
);
|
||||
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
|
||||
const talk = (
|
||||
game: GameWorld,
|
||||
map: string,
|
||||
x: number,
|
||||
y: number,
|
||||
face: "up" | "down" | "left" | "right",
|
||||
answers: number[] = [],
|
||||
) => {
|
||||
game.warpTo(map, x, y);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x, y, direction: face });
|
||||
game.interact();
|
||||
const shown = settle(game, answers).join(" ");
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
return shown;
|
||||
};
|
||||
/** Mania stands at (2, 4); talk to him from below. */
|
||||
const mania = (game: GameWorld, answers: number[] = []) =>
|
||||
talk(game, MANIAS_HOUSE, 2, 5, "up", answers);
|
||||
|
||||
it("nurses Route 39's Miltank back to health with seven Berries", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
// MooMoo (3, 3) only asks for Berries once the farmer has explained.
|
||||
const moomoo = (answers: number[] = []) => talk(game, ROUTE_39_BARN, 3, 4, "up", answers);
|
||||
const before = moomoo();
|
||||
expect(before).toContain("MILTANK: …Moo…");
|
||||
expect(before).not.toContain("Give a BERRY");
|
||||
game.state.events.add(EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO);
|
||||
expect(moomoo()).toContain("has no\nBERRIES…");
|
||||
game.state.bag.items = [{ item: BERRY, quantity: 7 }];
|
||||
expect(moomoo([0])).toContain("wouldn't\ngive a BERRY.");
|
||||
const fed = Array.from({ length: 7 }, () => moomoo([1]));
|
||||
expect(fed[2]).toContain("a\nlittle healthier!");
|
||||
expect(fed[4]).toContain("quite healthy!");
|
||||
expect(fed[6]).toContain("totally healthy!");
|
||||
expect(fed[0]).not.toContain("healthier");
|
||||
// wMooMooBerries is saved with the other script bytes.
|
||||
expect(game.save().memory).toMatchObject({ wMooMooBerries: 7 });
|
||||
expect(game.state.bag.items).toEqual([]);
|
||||
expect(game.state.events.has(EVENT_HEALED_MOOMOO)).toBe(true);
|
||||
expect(moomoo()).toContain("MILTANK: Mooo!");
|
||||
});
|
||||
|
||||
describe("Mania's Shuckie (GiveShuckle, ReturnShuckie)", () => {
|
||||
it("lends Shuckie with its own OT, ID, nickname, Berry and gift caught data", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
const shown = mania(game);
|
||||
expect(shown).toContain("after my #MON".replace("#", "POKé"));
|
||||
expect(shown).toContain("received a\nPOKéMON.");
|
||||
const shuckie = game.state.party[1];
|
||||
expect(shuckie).toMatchObject({
|
||||
species: SHUCKLE,
|
||||
level: 15,
|
||||
item: BERRY,
|
||||
otId: MANIA_OT_ID,
|
||||
ot: "MANIA",
|
||||
nickname: "SHUCKIE",
|
||||
caughtData: LANDMARK_GIFT,
|
||||
});
|
||||
expect(game.state.pokedex.caught.has(SHUCKLE)).toBe(true);
|
||||
expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(true);
|
||||
expect(game.state.flags.has(ENGINE_GOT_SHUCKIE_TODAY)).toBe(true);
|
||||
// The same day he only asks the player to look after it.
|
||||
expect(mania(game)).toContain("Take good care of");
|
||||
expect(game.state.party).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps Shuckie when the party is full, or when the player says no", () => {
|
||||
const { game } = world();
|
||||
game.state.party = Array.from({ length: 6 }, () => mon(game, 155, 20));
|
||||
expect(mania(game)).toContain("Your POKéMON party\nis full.");
|
||||
expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(false);
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
expect(mania(game, [0])).toContain("What'll\nI do if he comes");
|
||||
expect(game.state.party).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** Shuckie lent yesterday; today Mania asks for it back. */
|
||||
const lent = () => {
|
||||
const { game, clock } = world();
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
mania(game);
|
||||
clock.now = new Date("2026-09-23T12:00:00");
|
||||
return game;
|
||||
};
|
||||
|
||||
it("takes Shuckie back the next day", () => {
|
||||
const game = lent();
|
||||
const shown = mania(game, [1, 1]);
|
||||
expect(shown).toContain("may I have\nit back?");
|
||||
expect(shown).toContain("Thank you!");
|
||||
expect(game.state.party.map((m) => m.species)).toEqual([155]);
|
||||
expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true);
|
||||
expect(mania(game)).toContain("piness is being");
|
||||
});
|
||||
|
||||
it("lets a happy Shuckie (150 or more) stay, and refuses another Pokémon", () => {
|
||||
const wrong = lent();
|
||||
expect(mania(wrong, [1, 0])).toContain("you don't\nhave my POKéMON");
|
||||
expect(wrong.state.party).toHaveLength(2);
|
||||
// Declining his question, or backing out of the party menu, is "robbed".
|
||||
expect(mania(wrong, [0])).toContain("same as being\nrobbed.");
|
||||
expect(mania(wrong, [1, -1])).toContain("same as being\nrobbed.");
|
||||
const happy = lent();
|
||||
happy.state.party[1].happiness = 150;
|
||||
expect(mania(happy, [1, 1])).toContain("come to like you.");
|
||||
expect(happy.state.party).toHaveLength(2);
|
||||
expect(happy.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Shuckie when every other Pokémon has fainted, with the wrong speech", () => {
|
||||
// CheckCurPartyMonFainted checks the party apart from Shuckie;
|
||||
// Mania then answers SHUCKIE_FAINTED with his happiness speech
|
||||
// (docs/bugs_and_glitches.md: the "last Pokémon" text is unused).
|
||||
const game = lent();
|
||||
game.state.party[0].hp = 0;
|
||||
const shown = mania(game, [1, 1]);
|
||||
expect(shown).toContain("piness is being");
|
||||
expect(shown).not.toContain("If I take my");
|
||||
expect(game.state.party).toHaveLength(2);
|
||||
expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Poké Seer (PokeSeer)", () => {
|
||||
/** The Seer stands at (2, 3); choose party slot `slot`. */
|
||||
const seer = (game: GameWorld, slot: number) =>
|
||||
talk(game, SEERS_HOUSE, 2, 4, "up", [slot]);
|
||||
|
||||
it("reads where, when and at what level the player met a Pokémon", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [
|
||||
mon(game, 155, 40, {
|
||||
nickname: "EMBER",
|
||||
caughtData: caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN),
|
||||
}),
|
||||
];
|
||||
const shown = seer(game, 0);
|
||||
expect(shown).toContain("I see all.");
|
||||
expect(shown).toMatch(/I see you met\nEMBER here:\nNEW BARK/);
|
||||
expect(shown).toContain("The time was\nDay!");
|
||||
expect(shown).toContain("Its level was 10!");
|
||||
// Level 40 now, 30 levels on: SeerAdviceTexts' 59 row.
|
||||
expect(shown).toContain("EMBER has\ngrown. It's gained\nmuch strength.");
|
||||
});
|
||||
|
||||
it("compares only the high byte of the OT ID, so some trades read as met", () => {
|
||||
const { game } = world();
|
||||
const caught = caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN);
|
||||
game.state.party = [
|
||||
mon(game, 155, 12, { otId: game.playerId ^ 0x0100, ot: "RED", caughtData: caught }),
|
||||
mon(game, 155, 12, { otId: game.playerId ^ 0x0001, ot: "RED", caughtData: caught }),
|
||||
];
|
||||
expect(seer(game, 0)).toContain("came from RED\nin a trade?");
|
||||
expect(seer(game, 1)).toContain("I see you met");
|
||||
});
|
||||
|
||||
it("cannot read gifts or blank data, reads only levels for events, and refuses Eggs", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [
|
||||
mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_GIFT }),
|
||||
mon(game, 155, 20, { caughtData: 0 }),
|
||||
mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_EVENT }),
|
||||
mon(game, 175, 5, { egg: true }),
|
||||
];
|
||||
expect(seer(game, 0)).toContain("I can't\ntell a thing!");
|
||||
expect(seer(game, 1)).toContain("I can't\ntell a thing!");
|
||||
const event = seer(game, 2);
|
||||
expect(event).toContain("What!? Incredible!");
|
||||
expect(event).not.toContain("The time was");
|
||||
expect(seer(game, 3)).toContain("That's an EGG!");
|
||||
expect(seer(game, -1)).toContain("you'd do nothing!");
|
||||
});
|
||||
|
||||
it("keeps the last caught level when a Pokémon's is unknown", () => {
|
||||
// GetCaughtLevel prints ??? and leaves wSeerCaughtLevel as it was,
|
||||
// which SeerAdvice still subtracts from the level. The port keeps the
|
||||
// Seer's own last reading until the game is loaded again.
|
||||
const { game } = world();
|
||||
game.state.party = [
|
||||
mon(game, 155, 40, { caughtData: caughtData(DAY, 30, 0, LANDMARK_NEW_BARK_TOWN) }),
|
||||
mon(game, 155, 35, { nickname: "SMOKE", caughtData: LANDMARK_NEW_BARK_TOWN }),
|
||||
];
|
||||
seer(game, 0);
|
||||
game.interact(); // still facing the Seer
|
||||
const shown = settle(game, [1]).join(" ");
|
||||
expect(shown).toContain("Its level was ???!");
|
||||
expect(shown).toContain("The time was\nUnknown!");
|
||||
// 35 - 30 = 5: the first row (9), not 35 - 0.
|
||||
expect(shown).toContain("little more care.");
|
||||
// After a load the scratch byte starts at 0 again: 35 - 0 = 35.
|
||||
expect(seer(game, 1)).toContain("much strength.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Photo Studio (PhotoStudio)", () => {
|
||||
/** The photographer stands at (2, 3). */
|
||||
const photo = (game: GameWorld, answers: number[]) =>
|
||||
talk(game, PHOTO_STUDIO, 2, 4, "up", answers);
|
||||
|
||||
it("finds no printer and takes no photo", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })];
|
||||
const shown = photo(game, [1, 0]);
|
||||
expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?");
|
||||
expect(shown).toContain("Hold\nstill for a bit.");
|
||||
// PrintPartymon's handshake reads $ff: PRINTER_ERROR_2.
|
||||
expect(shown).toContain(
|
||||
" Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\n\nPress B to Cancel",
|
||||
);
|
||||
expect(shown).toContain("Oh, no picture?\nCome again, OK?");
|
||||
expect(shown).not.toContain("Presto");
|
||||
expect(photo(game, [1, 1])).toContain("An EGG? My talent\nis worth more…");
|
||||
expect(photo(game, [1, -1])).toContain("Oh, no picture?");
|
||||
});
|
||||
});
|
||||
});
|
||||
+91
-12
@@ -25,6 +25,9 @@ const WARP_EXITS: Partial<Record<number, Direction>> = {
|
||||
0x7e: "right",
|
||||
};
|
||||
|
||||
/** PLAYER_SURF (constants/wram_constants.asm). */
|
||||
const PLAYER_SURF = 4;
|
||||
|
||||
/** Plays the game through the world's own inputs, as tests/playthrough
|
||||
* does: scripts, battles and walking, with a seeded Random. A stage's save
|
||||
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
|
||||
@@ -107,7 +110,7 @@ export class Playthrough {
|
||||
const { engine } = battle,
|
||||
mon = engine.player.mon;
|
||||
const ball = world.liveState.bag.balls.findIndex(
|
||||
(slot) => slot.item === C.POKE_BALL,
|
||||
(slot) => slot.item === C.POKE_BALL || slot.item === C.GREAT_BALL,
|
||||
);
|
||||
const potion = world.liveState.bag.items.findIndex(
|
||||
(slot) => slot.item === C.SUPER_POTION,
|
||||
@@ -121,7 +124,11 @@ export class Playthrough {
|
||||
!world.liveState.party.some((member) => member.species === this.wanted) &&
|
||||
ball >= 0
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
||||
world.battleAction({
|
||||
action: "item",
|
||||
item: world.liveState.bag.balls[ball].item,
|
||||
slot: ball,
|
||||
});
|
||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
world.battleAction({ action: "run" });
|
||||
else if (
|
||||
@@ -213,7 +220,9 @@ export class Playthrough {
|
||||
if (warp && world.mapId !== map) return;
|
||||
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
for (const direction of findPath(world, x, y)) {
|
||||
// A surfer crosses water until it steps ashore.
|
||||
const water = world.state.playerState === PLAYER_SURF;
|
||||
for (const direction of findPath(world, x, y, { water })) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
if (!world.move(direction)) {
|
||||
@@ -233,8 +242,41 @@ export class Playthrough {
|
||||
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
|
||||
);
|
||||
};
|
||||
/** Step across a map connection from the nearest usable edge tile. */
|
||||
cross = (direction: Direction, target: string) => {
|
||||
/** Walk and Surf to a tile: where a step leaves the shore, face the
|
||||
* water and answer AskSurfScript's question with YES. */
|
||||
sail = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
this.play();
|
||||
expect(world.mapId, `sailing to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
for (const direction of findPath(world, x, y, { water: true })) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
|
||||
if (world.state.playerState !== PLAYER_SURF && world.assets.permission(ahead) === 1) {
|
||||
world.move(direction); // only turns to face the water
|
||||
world.interact();
|
||||
this.play([1]);
|
||||
expect(world.state.playerState, "surfing").toBe(PLAYER_SURF);
|
||||
} else if (!world.move(direction)) {
|
||||
for (let tick = 0; tick < 64; tick++) world.tick();
|
||||
break;
|
||||
} else this.play();
|
||||
if (
|
||||
world.mapId !== map ||
|
||||
world.x !== from[0] + dx ||
|
||||
world.y !== from[1] + dy
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not sail to ${x},${y} on ${map} from ${world.x},${world.y}`);
|
||||
};
|
||||
/** Step across a map connection from the nearest usable edge tile;
|
||||
* `water` lets a player who can Surf reach it over the sea. */
|
||||
cross = (direction: Direction, target: string, { water = false } = {}) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
const [dx, dy] = STEP[direction];
|
||||
@@ -246,14 +288,15 @@ export class Playthrough {
|
||||
: [i, dy < 0 ? 0 : world.map.height - 1],
|
||||
).flatMap(([x, y]) => {
|
||||
try {
|
||||
return [{ x, y, length: findPath(world, x, y).length }];
|
||||
return [{ x, y, length: findPath(world, x, y, { water }).length }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
edge.sort((a, b) => a.length - b.length);
|
||||
for (const { x, y } of edge) {
|
||||
this.walk(x, y);
|
||||
if (water) this.sail(x, y);
|
||||
else this.walk(x, y);
|
||||
if (world.move(direction)) {
|
||||
this.play();
|
||||
if (world.mapId === target) return;
|
||||
@@ -416,12 +459,45 @@ export class Playthrough {
|
||||
}
|
||||
const from = world.mapId;
|
||||
this.walk(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 && this.route(target, maps, depth - 1, goal))
|
||||
return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** The fewest warps (stairs, holes, doors) among `maps` to `target`:
|
||||
* breadth first over the places each warp lands, so a maze of floors
|
||||
* whose holes drop into cut-off corners is solved without looping. */
|
||||
travel = (target: string, maps: string[]) => {
|
||||
const world = this.world;
|
||||
const queue = [world.save()];
|
||||
const seen = new Set([`${world.mapId} ${world.x},${world.y}`]);
|
||||
for (let i = 0; i < queue.length && i < 200; i++) {
|
||||
world.restore(queue[i]);
|
||||
const from = world.mapId;
|
||||
for (const warp of world.map.warps) {
|
||||
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
||||
world.restore(queue[i]);
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
this.walk(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) continue;
|
||||
if (world.mapId === target) return;
|
||||
const key = `${world.mapId} ${world.x},${world.y}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
queue.push(world.save());
|
||||
}
|
||||
}
|
||||
throw new Error(`No way to ${target} through ${maps.join(", ")}`);
|
||||
};
|
||||
/** Advance to the next menu choice, keeping its pages in the log. */
|
||||
toChoice = () => {
|
||||
const world = this.world,
|
||||
@@ -496,12 +572,13 @@ export class Playthrough {
|
||||
* the world's own BuyMenu (BuyMenuLoop, SelectQuantityToBuy): BUY, the
|
||||
* item in the stock list, the quantity, YES, back out of the list,
|
||||
* then QUIT, as tests/mart.test.ts and tests/goldenrod.test.ts drive the
|
||||
* martmenu/martlist/martquantity choices directly. */
|
||||
* martmenu/martlist/martquantity choices directly. A pharmacy
|
||||
* (MARTTYPE_PHARMACY) opens straight onto its list, with no menu. */
|
||||
buy = (clerk: number, item: number, quantity: number) => {
|
||||
const world = this.world;
|
||||
this.talkTo(clerk);
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(0); // BUY
|
||||
const menu = this.toChoice().kind === "martmenu";
|
||||
if (menu) world.choose(0); // BUY
|
||||
const list = this.toChoice();
|
||||
if (list.kind !== "martlist")
|
||||
throw new Error(`Expected a mart list, got ${list.kind}`);
|
||||
@@ -515,8 +592,10 @@ export class Playthrough {
|
||||
// "Here you are!", then the stock list again (BuyMenuLoop repeats).
|
||||
expect(this.toChoice().kind).toBe("martlist");
|
||||
world.choose(-1); // back to the mart's own menu
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(2); // QUIT
|
||||
if (menu) {
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(2); // QUIT
|
||||
}
|
||||
this.play();
|
||||
};
|
||||
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
||||
|
||||
@@ -105,6 +105,44 @@ const EVENT_RELEASED_THE_BEASTS = 0x7b,
|
||||
RAIKOU = 0xf3,
|
||||
ENTEI = 0xf4,
|
||||
ROUTE_42 = "2:5";
|
||||
const ROUTE_38_GATE = "1:9",
|
||||
ROUTE_38 = "1:12",
|
||||
ROUTE_39 = "1:13",
|
||||
OLIVINE = "1:14",
|
||||
OLIVINE_CENTER = "1:1",
|
||||
OLIVINE_GYM = "1:2",
|
||||
OLIVINE_CAFE = "1:7",
|
||||
GOOD_ROD_HOUSE = "1:6",
|
||||
OLIVINE_MART = "1:8",
|
||||
LIGHTHOUSE = ["3:42", "3:43", "3:44", "3:45", "3:46", "3:47"],
|
||||
ROUTE_40 = "22:1",
|
||||
ROUTE_41 = "22:2",
|
||||
CIANWOOD = "22:3",
|
||||
CIANWOOD_GYM = "22:5",
|
||||
CIANWOOD_CENTER = "22:6",
|
||||
CIANWOOD_PHARMACY = "22:7";
|
||||
const GOOD_ROD = 0x3b,
|
||||
SECRETPOTION = 0x43,
|
||||
HM02 = 0xf4,
|
||||
HM03 = 0xf5,
|
||||
HM04 = 0xf6,
|
||||
KRABBY = 98,
|
||||
SURF = 0x39,
|
||||
STRENGTH = 0x46,
|
||||
EVENT_GOT_HM04_STRENGTH = 0x13,
|
||||
EVENT_GOT_GOOD_ROD = 0x18,
|
||||
EVENT_RIVAL_OLIVINE_CITY = 0x6c3,
|
||||
EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS = 0x37,
|
||||
EVENT_FOUGHT_EUSINE = 0x333,
|
||||
EVENT_GOT_SECRETPOTION_FROM_PHARMACY = 0x23,
|
||||
EVENT_BEAT_CHUCK = 0x4c2,
|
||||
EVENT_GOT_TM01_DYNAMICPUNCH = 0xa,
|
||||
EVENT_GOT_HM02_FLY = 0x11,
|
||||
EVENT_JASMINE_RETURNED_TO_GYM = 0x20,
|
||||
EVENT_BEAT_JASMINE = 0x4c1,
|
||||
EVENT_GOT_TM23_IRON_TAIL = 0xd,
|
||||
ENGINE_MINERALBADGE = 31,
|
||||
ENGINE_STORMBADGE = 32;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -131,6 +169,8 @@ describe.skipIf(!existsSync(romPath))(
|
||||
train,
|
||||
stairs,
|
||||
route,
|
||||
travel,
|
||||
sail,
|
||||
toChoice,
|
||||
choose,
|
||||
choosePack,
|
||||
@@ -798,5 +838,191 @@ describe.skipIf(!existsSync(romPath))(
|
||||
reload();
|
||||
writeTestSave("ecruteak-city");
|
||||
}, 240_000);
|
||||
|
||||
it("crosses Routes 38 and 39 to Olivine City, where the rival is waiting", () => {
|
||||
traceFailures();
|
||||
// Ecruteak's west gate and the gate's own west side are edge warps.
|
||||
exit(ROUTE_38_GATE, "left");
|
||||
exit(ROUTE_38, "left");
|
||||
cross("left", ROUTE_39);
|
||||
cross("down", OLIVINE);
|
||||
// OlivineCityRivalSceneTop/Bottom: the way from Route 39 to the
|
||||
// Pokémon Center crosses one of the two coordinate events.
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
expect(p.log.join(" ")).toContain("GYM LEADER isn't\nhere.");
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_OLIVINE_CITY)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("gets HM04 and the Good Rod, fishes up a Krabby and teaches it Surf", () => {
|
||||
traceFailures();
|
||||
enter(OLIVINE_CAFE, "up");
|
||||
talkTo(p.world.map.objects[0].id); // OLIVINECAFE_SAILOR1
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM04_STRENGTH)).toBe(true);
|
||||
exit(OLIVINE);
|
||||
enter(GOOD_ROD_HOUSE, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_GOT_GOOD_ROD)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(GOOD_ROD);
|
||||
exit(OLIVINE);
|
||||
// Olivine's Mart has Great Balls, not Poké Balls.
|
||||
enter(OLIVINE_MART, "up");
|
||||
buy(p.world.map.objects[0].id, C.GREAT_BALL, 5);
|
||||
exit(OLIVINE);
|
||||
// Typhlosion cannot learn Surf: fish a Krabby (FISHGROUP_SHORE's
|
||||
// Good Rod table) from the nearest shore tile facing open water.
|
||||
const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] } as const;
|
||||
const map = p.world.map;
|
||||
const shore = (Object.keys(STEP) as (keyof typeof STEP)[])
|
||||
.flatMap((face) =>
|
||||
Array.from({ length: map.width * map.height }, (_, i) => {
|
||||
const x = i % map.width,
|
||||
y = Math.floor(i / map.width);
|
||||
const [dx, dy] = STEP[face];
|
||||
if (p.world.assets.collision(map, x + dx, y + dy) !== 0x29) return [];
|
||||
try {
|
||||
return [{ x, y, face, length: findPath(p.world, x, y).length }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}).flat(),
|
||||
)
|
||||
.sort((a, b) => a.length - b.length)[0];
|
||||
walk(shore.x, shore.y);
|
||||
p.wanted = KRABBY;
|
||||
for (let cast = 0; cast < 40; cast++) {
|
||||
if (p.world.state.party.some((mon) => mon.species === KRABBY)) break;
|
||||
p.world.move(shore.face);
|
||||
expect(p.world.openStartMenu()).toBe(true);
|
||||
choose("PACK");
|
||||
choosePack(GOOD_ROD);
|
||||
choose("USE");
|
||||
play();
|
||||
}
|
||||
p.wanted = 0;
|
||||
const krabby = p.world.state.party.findIndex((mon) => mon.species === KRABBY);
|
||||
expect(krabby).toBeGreaterThan(0);
|
||||
p.teach(HM03, krabby);
|
||||
// Strength replaces Tackle (the first move) on the lead.
|
||||
p.teach(HM04, 0);
|
||||
expect(p.world.state.party[krabby].moves).toContain(SURF);
|
||||
expect(lead().moves).toContain(STRENGTH);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("climbs the Lighthouse to Jasmine and her sick Ampharos", () => {
|
||||
traceFailures();
|
||||
enter(LIGHTHOUSE[0], "up");
|
||||
// Two stairways and the outside ledges lead up; Jasmine stands by
|
||||
// Amphy in 6F's far corner.
|
||||
expect(route(LIGHTHOUSE[5], LIGHTHOUSE, 12, { x: 8, y: 9 })).toBe(true);
|
||||
talkTo(p.world.map.objects[0].id); // OLIVINELIGHTHOUSE6F_JASMINE
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("wonderful PHARMACY\nin CIANWOOD");
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("drops back down the Lighthouse and surfs Routes 40 and 41 to Cianwood City", () => {
|
||||
traceFailures();
|
||||
// The holes by the windows drop to the floor below; some land in
|
||||
// corners cut off from the next hole, so search the floors.
|
||||
travel(OLIVINE, LIGHTHOUSE);
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
cross("left", ROUTE_40);
|
||||
cross("down", ROUTE_41, { water: true });
|
||||
cross("left", CIANWOOD, { water: true });
|
||||
expect(p.world.mapId).toBe(CIANWOOD);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("meets Suicune and Eusine, gets the SecretPotion and beats Chuck for the STORMBADGE and HM02", () => {
|
||||
traceFailures();
|
||||
sail(23, 44); // ashore by the Pokémon Center
|
||||
heal(CIANWOOD_CENTER, CIANWOOD);
|
||||
// ReleaseTheBeasts armed SCENE_CIANWOODCITY_SUICUNE_AND_EUSINE; its
|
||||
// coordinate event is on the way north.
|
||||
walk(11, 16);
|
||||
expect(p.world.state.events.has(EVENT_FOUGHT_EUSINE)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("SUICUNE running on\nthe waves.");
|
||||
heal(CIANWOOD_CENTER, CIANWOOD);
|
||||
enter(CIANWOOD_PHARMACY, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_SECRETPOTION_FROM_PHARMACY)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(SECRETPOTION);
|
||||
// Afterwards the pharmacist runs MARTTYPE_PHARMACY.
|
||||
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
|
||||
exit(CIANWOOD);
|
||||
// The four black belts stand on the way to the boulders.
|
||||
enter(CIANWOOD_GYM, "up");
|
||||
walk(3, 8);
|
||||
exit(CIANWOOD);
|
||||
// Chuck's Poliwrath can win with critical hits; a loss whites out to
|
||||
// Cianwood's Pokémon Center and the boulders go back in place.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (p.world.state.events.has(EVENT_BEAT_CHUCK)) break;
|
||||
heal(CIANWOOD_CENTER, CIANWOOD);
|
||||
enter(CIANWOOD_GYM, "up");
|
||||
walk(3, 8);
|
||||
// AskStrengthScript from the left boulder, then push it up, the
|
||||
// right one up and the middle one left into the gap.
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play([1]);
|
||||
p.world.move("up");
|
||||
play();
|
||||
walk(5, 8);
|
||||
p.world.move("up");
|
||||
play();
|
||||
walk(5, 7);
|
||||
p.world.move("left");
|
||||
play();
|
||||
walk(4, 2);
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play([1]);
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_BEAT_CHUCK)).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_STORMBADGE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM01_DYNAMICPUNCH)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[0]).toBe(1); // TM01
|
||||
exit(CIANWOOD);
|
||||
talkTo(p.world.map.objects[9].id); // CIANWOODCITY_POKEFAN_F
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM02_FLY)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[51]).toBe(1); // HM02
|
||||
reload();
|
||||
writeTestSave("cianwood-city");
|
||||
}, 240_000);
|
||||
|
||||
it("brings the SecretPotion to Amphy and beats Jasmine for the MINERALBADGE and TM23", () => {
|
||||
traceFailures();
|
||||
// Nobody in the party learns Fly; surf back.
|
||||
cross("right", ROUTE_41, { water: true });
|
||||
cross("up", ROUTE_40, { water: true });
|
||||
cross("right", OLIVINE, { water: true });
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
enter(LIGHTHOUSE[0], "up");
|
||||
expect(route(LIGHTHOUSE[5], LIGHTHOUSE, 12, { x: 8, y: 9 })).toBe(true);
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_JASMINE_RETURNED_TO_GYM)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).not.toContain(SECRETPOTION);
|
||||
travel(OLIVINE, LIGHTHOUSE);
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
enter(OLIVINE_GYM, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_JASMINE)).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_MINERALBADGE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM23_IRON_TAIL)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[22]).toBe(1); // TM23
|
||||
exit(OLIVINE);
|
||||
reload();
|
||||
writeTestSave("olivine-city");
|
||||
}, 240_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,480 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
test("CardFlip_UpdateCursorOAM matches cursorSprites for every cursor position", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
cgb: at("hCGB"),
|
||||
cursorX: at("wCardFlipCursorX"),
|
||||
cursorY: at("wCardFlipCursorY"),
|
||||
shadowOAM: at("wShadowOAMSprite00"),
|
||||
};
|
||||
const updateCursorOAM = routine("CardFlip_UpdateCursorOAM");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, updateCursorOAM }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { cursorSprites } = await import("/src/game/scripts/card-flip.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"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
// DI; SP=$c7fe; CGB mode so the blink check is skipped, as the port's
|
||||
// own comment assumes; CALL CardFlip_UpdateCursorOAM; sentinel.
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, updateCursorOAM.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, updateCursorOAM.address & 255, updateCursorOAM.address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
const OBJ_BASE = 0x100;
|
||||
// ScreenSprite.x/y are plain screen pixels (tile-screen.ts draws them
|
||||
// with the same col*8/row*8 convention as background tiles); hardware
|
||||
// OAM bytes are 8 (x) and 16 (y) beyond that (see unown-puzzle.ts's own
|
||||
// "OAM coordinates are 16 (y) and 8 (x) beyond the screen's").
|
||||
const OAM_Y_OFFSET = 16,
|
||||
OAM_X_OFFSET = 8;
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
// Every cell ChooseCard_HandleJoypad's grid can address (single tiles,
|
||||
// number/Pokémon rows, columns and the paired groups), all 48 of them.
|
||||
for (let cursorY = 0; cursorY < 8; cursorY++)
|
||||
for (let cursorX = 0; cursorX < 6; cursorX++) {
|
||||
write(a.cursorX, cursorX);
|
||||
write(a.cursorY, cursorY);
|
||||
write(a.cgb, 1);
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("CardFlip_UpdateCursorOAM did not return");
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 40 * 4; i++) original.push(read(a.shadowOAM + i));
|
||||
const sprites = cursorSprites(assets.reader, cursorX, cursorY);
|
||||
const native = new Array(160).fill(0);
|
||||
sprites.forEach((sprite, i) => {
|
||||
native[i * 4 + 0] = (sprite.y + OAM_Y_OFFSET) & 0xff;
|
||||
native[i * 4 + 1] = (sprite.x + OAM_X_OFFSET) & 0xff;
|
||||
native[i * 4 + 2] = (sprite.tile - OBJ_BASE) & 0xff;
|
||||
native[i * 4 + 3] = (sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0) | (sprite.behind ? 0x80 : 0);
|
||||
});
|
||||
if (original.some((v, i) => v !== native[i]))
|
||||
failures.push({ cursorX, cursorY, count: sprites.length, original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures: failures.slice(0, 3), count: failures.length };
|
||||
}, { a, updateCursorOAM });
|
||||
expect(result.compared).toBe(48);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
test("Slots_UpdateReelPositionAndOAM/.SpinReel match reelSprites for reel positions and spin distances", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
// RomReader addresses (as JOHTO.Reel1Tilemap etc. are generated) are flat
|
||||
// ROM-file offsets, bank * $4000 + (address - $4000); CPU addresses
|
||||
// written into the reel struct for the ROM's own code to dereference are
|
||||
// the plain 16-bit address instead.
|
||||
const flat = ({ bank, address }: { bank: number; address: number }) => bank * 0x4000 + (address - 0x4000);
|
||||
const reel1Tilemap = routine("Reel1Tilemap"),
|
||||
reel2Tilemap = routine("Reel2Tilemap"),
|
||||
reel3Tilemap = routine("Reel3Tilemap");
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
wReel1: at("wReel1"),
|
||||
wReel2: at("wReel2"),
|
||||
wReel3: at("wReel3"),
|
||||
reel1Tilemap: reel1Tilemap.address,
|
||||
reel2Tilemap: reel2Tilemap.address,
|
||||
reel3Tilemap: reel3Tilemap.address,
|
||||
reel1TilemapFlat: flat(reel1Tilemap),
|
||||
reel2TilemapFlat: flat(reel2Tilemap),
|
||||
reel3TilemapFlat: flat(reel3Tilemap),
|
||||
shadow16: at("wShadowOAMSprite16"),
|
||||
shadow24: at("wShadowOAMSprite24"),
|
||||
shadow32: at("wShadowOAMSprite32"),
|
||||
};
|
||||
const updateReel = routine("Slots_UpdateReelPositionAndOAM");
|
||||
const spinReel = routine("Slots_SpinReels.SpinReel");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, updateReel, spinReel }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { readReelTilemap, makeReel, reelSprites } = await import("/src/game/scripts/slot-machine.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"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
// struct fields (engine/games/slot_machine.asm): REEL_ACTION=0,
|
||||
// REEL_TILEMAP_ADDR=1(w), REEL_POSITION=3, REEL_SPIN_DISTANCE=4,
|
||||
// REEL_SPIN_RATE=5, REEL_OAM_ADDR=6(w), REEL_X_COORD=8.
|
||||
const call = (bank: number, address: number, bc: number) => {
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0x01, bc & 255, bc >> 8,
|
||||
0xcd, address & 255, address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("reel routine did not return");
|
||||
};
|
||||
const SYMBOL_BASE_TILE = 0x25;
|
||||
// .LoadOAM writes one hardware 8x16 sprite per column (2 per row, tile
|
||||
// auto-pairing its own bottom half); reelSprites instead draws each as
|
||||
// two separate 8x8 ScreenSprites (dy 0 and 1). Keep only the dy=0
|
||||
// (top) halves, at the even positions of each row's four pushes, to
|
||||
// compare against the ROM's own 8 real OAM entries.
|
||||
const toNative = (sprites: { x: number; y: number; tile: number; palette: number }[]) => {
|
||||
const tops = sprites.filter((_, i) => i % 2 === 0);
|
||||
const oam = new Array(32).fill(0);
|
||||
tops.forEach((sprite, i) => {
|
||||
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
|
||||
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
|
||||
oam[i * 4 + 2] = (sprite.tile - SYMBOL_BASE_TILE) & 0xff;
|
||||
oam[i * 4 + 3] = (sprite.palette & 7) | 0x80; // B_OAM_PRIO, always set
|
||||
});
|
||||
return oam;
|
||||
};
|
||||
const defs = [
|
||||
{ wReel: a.wReel1, tilemapAddr: a.reel1Tilemap, tilemapFlat: a.reel1TilemapFlat, shadow: a.shadow16, xTile: 6 },
|
||||
{ wReel: a.wReel2, tilemapAddr: a.reel2Tilemap, tilemapFlat: a.reel2TilemapFlat, shadow: a.shadow24, xTile: 10 },
|
||||
{ wReel: a.wReel3, tilemapAddr: a.reel3Tilemap, tilemapFlat: a.reel3TilemapFlat, shadow: a.shadow32, xTile: 14 },
|
||||
];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
for (const def of defs) {
|
||||
const tilemap = readReelTilemap(assets.reader, def.tilemapFlat);
|
||||
for (const position of [0, 5, 14]) {
|
||||
write(def.wReel + 1, def.tilemapAddr & 255);
|
||||
write(def.wReel + 2, def.tilemapAddr >> 8);
|
||||
write(def.wReel + 3, position);
|
||||
write(def.wReel + 6, def.shadow & 255);
|
||||
write(def.wReel + 7, def.shadow >> 8);
|
||||
write(def.wReel + 8, def.xTile * 8);
|
||||
call(updateReel.bank, updateReel.address, def.wReel);
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 32; i++) original.push(read(def.shadow + i));
|
||||
const reel = makeReel(tilemap);
|
||||
reel.position = position;
|
||||
const native = toNative(reelSprites(reel, def.xTile));
|
||||
if (original.some((v, i) => v !== native[i]))
|
||||
failures.push({ reel: def.xTile, position, original, native });
|
||||
compared++;
|
||||
}
|
||||
}
|
||||
// Spin distance: one real .SpinReel tick from a freshly redrawn (subY=0)
|
||||
// reel 1, with the low nibble of the resulting distance left nonzero so
|
||||
// it takes the partial, Y-bytes-only shift path instead of a full
|
||||
// redraw.
|
||||
for (const rate of [5, 11]) {
|
||||
write(a.wReel1 + 0, 0); // REEL_ACTION_DO_NOTHING (so .SpinReel's own ReelActionJumptable call is a no-op)
|
||||
write(a.wReel1 + 1, a.reel1Tilemap & 255);
|
||||
write(a.wReel1 + 2, a.reel1Tilemap >> 8);
|
||||
write(a.wReel1 + 3, 5);
|
||||
write(a.wReel1 + 6, a.shadow16 & 255);
|
||||
write(a.wReel1 + 7, a.shadow16 >> 8);
|
||||
write(a.wReel1 + 8, 6 * 8);
|
||||
call(updateReel.bank, updateReel.address, a.wReel1);
|
||||
write(a.wReel1 + 4, 0); // REEL_SPIN_DISTANCE
|
||||
write(a.wReel1 + 5, rate); // REEL_SPIN_RATE
|
||||
call(spinReel.bank, spinReel.address, a.wReel1);
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 32; i++) original.push(read(a.shadow16 + i));
|
||||
const tilemap = readReelTilemap(assets.reader, a.reel1TilemapFlat);
|
||||
const reel = makeReel(tilemap);
|
||||
reel.position = 5;
|
||||
reel.spinDistance = rate;
|
||||
const native = toNative(reelSprites(reel, 6));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ rate, original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures: failures.slice(0, 3), count: failures.length };
|
||||
}, { a, updateReel, spinReel });
|
||||
expect(result.compared).toBe(11);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
test("the sprite-animation engine's Golem, Chansey and Egg OAM match golemSprites/chanseySprites/eggSprites", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
structs: at("wSpriteAnimationStructs"),
|
||||
count: at("wSpriteAnimCount"),
|
||||
oamAddr: at("wCurSpriteOAMAddr"),
|
||||
shadow: at("wShadowOAM"),
|
||||
globalX: at("wGlobalAnimXOffset"),
|
||||
globalY: at("wGlobalAnimYOffset"),
|
||||
};
|
||||
const initStruct = routine("_InitSpriteAnimStruct");
|
||||
const updateFrame = routine("UpdateAnimFrame");
|
||||
const animateGolem = routine("Slots_AnimateGolem");
|
||||
const animateChansey = routine("Slots_AnimateChansey");
|
||||
const animateEgg = routine("SpriteAnimFunc_SlotsChanseyEgg");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { initGolem, stepGolem, golemSprites, initChansey, stepChansey, chanseySprites, stepEgg, eggSprites } = await import(
|
||||
"/src/game/scripts/slot-machine.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"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (bank: number, address: number, program: number[]) => {
|
||||
const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
write(0xc0f0, 0);
|
||||
full.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return");
|
||||
};
|
||||
// SPRITEANIMSTRUCT field offsets (constants/sprite_anim_constants.asm).
|
||||
const VAR3 = 14;
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
|
||||
function run(animId: number, d: number, e: number, animate: { bank: number; address: number }, extra?: () => void) {
|
||||
for (let i = 0; i < 160; i++) write(a.structs + i, 0);
|
||||
write(a.count, 0);
|
||||
write(a.oamAddr, 0);
|
||||
for (let i = 0; i < 32; i++) write(a.shadow + i, 0);
|
||||
// wGlobalAnimXOffset/YOffset are an untracked engine-global leftover,
|
||||
// like the OBJ palette applySlotsAttrs' own doc comment describes;
|
||||
// UpdateAnimFrame adds them into every OAM byte it writes, so pin
|
||||
// them at 0 here to compare the engine's own per-sprite math cleanly.
|
||||
write(a.globalX, 0);
|
||||
write(a.globalY, 0);
|
||||
// LD DE, de; LD A, animId; CALL _InitSpriteAnimStruct.
|
||||
call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]);
|
||||
if (extra) extra();
|
||||
// The struct lands at wSpriteAnimationStructs (the only free slot);
|
||||
// CALL animate(bc=struct); CALL UpdateAnimFrame(bc=struct) to draw it.
|
||||
call(animate.bank, animate.address, [0x01, a.structs & 255, a.structs >> 8]);
|
||||
call(updateFrame.bank, updateFrame.address, [0x01, a.structs & 255, a.structs >> 8]);
|
||||
const oam: number[] = [];
|
||||
for (let i = 0; i < 32; i++) oam.push(read(a.shadow + i));
|
||||
return oam;
|
||||
}
|
||||
// Bit 4 of the attribute byte is set in several of the ROM's own OAM
|
||||
// tables (e.g. $15, $16) but is not a real CGB OBJ attribute bit
|
||||
// (palette 0-2, bank 3, XFLIP 5, YFLIP 6, PRIO 7): it has no effect on
|
||||
// hardware, and golemSprites/chanseySprites/eggSprites do not keep it.
|
||||
// Masked out here rather than reproduced.
|
||||
const ATTR_MASK = 0xe7;
|
||||
const toNative = (sprites: { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean }[]) => {
|
||||
const oam = new Array(32).fill(0);
|
||||
sprites.forEach((sprite, i) => {
|
||||
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
|
||||
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
|
||||
// Only CREATURE_BASE_TILE (this port's own merged-tile-layout
|
||||
// rebasing) comes off; GOLEM_VTILE/CHANSEY_VTILE/EGG_VTILE already
|
||||
// equal the ROM's own per-animal TILE_ID (GetSpriteAnimVTile).
|
||||
oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff;
|
||||
oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK;
|
||||
});
|
||||
return oam;
|
||||
};
|
||||
const maskAttrs = (oam: number[]) => oam.map((v, i) => (i % 4 === 3 ? v & ATTR_MASK : v));
|
||||
|
||||
// Golem: depixel 12, 13 -> D=$60, E=$68; VAR3=1 so .init falls through
|
||||
// .retain into .fall with VAR1=$30 (matching initGolem(session, 1)),
|
||||
// computing the first real sine step (one stepGolem tick).
|
||||
{
|
||||
const session: { golem?: never } = {};
|
||||
initGolem(session as never, 1);
|
||||
const original = maskAttrs(run(6, 0x60, 0x68, animateGolem, () => write(a.structs + VAR3, 1)));
|
||||
stepGolem(session as never, assets.reader);
|
||||
const native = toNative(golemSprites(assets.reader, session.golem!));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ which: "golem", original, native });
|
||||
compared++;
|
||||
}
|
||||
// Chansey: depixel 12, 0 -> D=$60, E=$00; a single `.walk` tick (the
|
||||
// state Slots_AnimateChansey's own jumptable index 0 starts at).
|
||||
{
|
||||
const session: { chansey?: never } = {};
|
||||
initChansey(session as never);
|
||||
const original = maskAttrs(run(7, 0x60, 0x00, animateChansey));
|
||||
stepChansey(session as never);
|
||||
const native = toNative(chanseySprites(assets.reader, session.chansey!));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ which: "chansey", original, native });
|
||||
compared++;
|
||||
}
|
||||
// Egg: depixel 12, 13, 0, 4 -> D=$60, E=$6c; SpriteAnimFunc_SlotsChanseyEgg
|
||||
// dispatches on JUMPTABLE_INDEX's own parity (not a jumptable), so it
|
||||
// is directly callable the same way. There is no ReelAction_InitChansey-
|
||||
// style helper for just the egg, so its state is built by hand from the
|
||||
// same depixel values (`.spawn_egg`'s own `session.egg = {...}`).
|
||||
{
|
||||
const egg = { counter: 0, xCoord: 0x6c, yOffset: 0 };
|
||||
const original = maskAttrs(run(8, 0x60, 0x6c, animateEgg));
|
||||
stepEgg({ egg } as never, assets.reader);
|
||||
const native = toNative(eggSprites(assets.reader, egg));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ which: "egg", original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures, count: failures.length };
|
||||
}, { a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg });
|
||||
expect(result.compared).toBe(3);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
test("PlaySpriteAnimations' Golem, Chansey and Egg sequences match the native steps frame by frame", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
structs: at("wSpriteAnimationStructs"),
|
||||
count: at("wSpriteAnimCount"),
|
||||
shadow: at("wShadowOAM"),
|
||||
globalX: at("wGlobalAnimXOffset"),
|
||||
globalY: at("wGlobalAnimYOffset"),
|
||||
delay: at("wSlotsDelay"),
|
||||
scy: at("hSCY"),
|
||||
};
|
||||
const initStruct = routine("_InitSpriteAnimStruct");
|
||||
const playAnimations = routine("PlaySpriteAnimations");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, initStruct, playAnimations }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const slots = await import("/src/game/scripts/slot-machine.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"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (bank: number, address: number, program: number[] = []) => {
|
||||
const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
write(0xc0f0, 0);
|
||||
full.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return");
|
||||
};
|
||||
// See the first-frame test above: attribute bit 4 is not a CGB OBJ bit.
|
||||
const ATTR_MASK = 0xe7;
|
||||
type Sprite = { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean };
|
||||
const toNative = (sprites: Sprite[]) => {
|
||||
const oam = new Array(160).fill(0);
|
||||
sprites.forEach((sprite, i) => {
|
||||
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
|
||||
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
|
||||
oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff;
|
||||
oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK;
|
||||
});
|
||||
return oam;
|
||||
};
|
||||
type Session = Parameters<typeof slots.stepGolem>[0];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
|
||||
/** Runs both sides tick by tick. `drive` applies the reel actions'
|
||||
* wSlotsDelay writes to both, between ticks; it returns false to stop. */
|
||||
function sequence(
|
||||
which: string,
|
||||
animId: number,
|
||||
d: number,
|
||||
e: number,
|
||||
setup: (session: Session) => void,
|
||||
var3: number | undefined,
|
||||
drive: (tick: number, delay: number, session: Session) => number | false,
|
||||
) {
|
||||
for (let i = 0; i < 160; i++) write(a.structs + i, 0);
|
||||
write(a.count, 0);
|
||||
write(a.globalX, 0);
|
||||
write(a.globalY, 0);
|
||||
write(a.delay, 0);
|
||||
write(a.scy, 0);
|
||||
call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]);
|
||||
if (var3 !== undefined) write(a.structs + 14, var3); // SPRITEANIMSTRUCT_VAR3
|
||||
const session = { delay: 0 } as Session;
|
||||
setup(session);
|
||||
for (let tick = 0; tick < 1000; tick++) {
|
||||
call(playAnimations.bank, playAnimations.address);
|
||||
slots.stepGolem(session, assets.reader);
|
||||
slots.stepChansey(session);
|
||||
slots.stepEgg(session, assets.reader);
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 160; i++) original.push(i % 4 === 3 ? read(a.shadow + i) & ATTR_MASK : read(a.shadow + i));
|
||||
const sprites: Sprite[] = [];
|
||||
if (session.golem) sprites.push(...slots.golemSprites(assets.reader, session.golem));
|
||||
if (session.chansey) sprites.push(...slots.chanseySprites(assets.reader, session.chansey));
|
||||
if (session.egg) sprites.push(...slots.eggSprites(assets.reader, session.egg));
|
||||
const native = toNative(sprites);
|
||||
const delay = read(a.delay);
|
||||
if (delay !== session.delay) failures.push({ which, tick, delay, native: session.delay });
|
||||
else if (read(a.scy) !== (session.scy ?? 0)) failures.push({ which, tick, scy: read(a.scy), native: session.scy });
|
||||
else if (original.some((v, i) => v !== native[i]))
|
||||
failures.push({ which, tick, original: original.slice(0, 32), native: native.slice(0, 32) });
|
||||
compared++;
|
||||
if (failures.length) return tick;
|
||||
const next = drive(tick, delay, session);
|
||||
if (next === false) return tick;
|
||||
write(a.delay, next);
|
||||
session.delay = next;
|
||||
}
|
||||
throw Error(`${which} did not finish`);
|
||||
}
|
||||
|
||||
// Golem: three throws. ReelAction_WaitGolem/EndGolem answer each landing
|
||||
// (wSlotsDelay 1) by clearing it again a few ticks later; the sequence
|
||||
// ends two ticks after .init's last-Golem branch (wSlotsDelay 2).
|
||||
let landed = -1, finished = -1;
|
||||
const golemTicks = sequence("golem", 6, 0x60, 0x68, (s) => slots.initGolem(s, 3), 3, (tick, delay) => {
|
||||
if (delay === 1 && landed < 0) landed = tick;
|
||||
if (landed >= 0 && tick === landed + 4) { landed = -1; return 0; }
|
||||
if (delay === 2 && finished < 0) finished = tick;
|
||||
if (finished >= 0 && tick === finished + 2) return false;
|
||||
return delay;
|
||||
});
|
||||
|
||||
// Chansey: walks in (wSlotsDelay 1), ReelAction_WaitChansey answers with
|
||||
// 2, the egg rolls out (4), DropReel's EggAgain asks for another (1, then
|
||||
// 2 again), and the second egg ends the round (5, Chansey removed).
|
||||
let eggs = 0, at = -1, done = -1;
|
||||
const chanseyTicks = sequence("chansey", 7, 0x60, 0x00, (s) => slots.initChansey(s), undefined, (tick, delay) => {
|
||||
if (delay === 1 && at < 0) at = tick;
|
||||
if (delay === 1 && tick === at + 3) { at = -1; return 2; }
|
||||
if (delay === 4) {
|
||||
if (at < 0) at = tick;
|
||||
if (tick === at + 17) {
|
||||
at = -1;
|
||||
return ++eggs === 1 ? 1 : 5;
|
||||
}
|
||||
}
|
||||
if (delay === 5 && done < 0) done = tick;
|
||||
if (done >= 0 && tick === done + 2) return false;
|
||||
return delay;
|
||||
});
|
||||
reference.dispose();
|
||||
return { compared, golemTicks, chanseyTicks, eggs, failures: failures.slice(0, 3), count: failures.length };
|
||||
}, { a, initStruct, playAnimations });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0, eggs: 2 });
|
||||
// Three throws of 17 falling and 37 rolling ticks each, plus the last .init.
|
||||
expect(result.golemTicks).toBe(167);
|
||||
expect(result.chanseyTicks).toBe(216);
|
||||
expect(result.compared).toBe(168 + 217);
|
||||
});
|
||||
@@ -232,6 +232,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
party: Array.from({ length: input.otPartyCount }, () => ({})) as never,
|
||||
aiFlags: 0,
|
||||
switchFlags: 0,
|
||||
items: [0, 0],
|
||||
baseReward: 0,
|
||||
reward: 0,
|
||||
},
|
||||
random: () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ test("all Smart AI move handlers match original scores and Random consumption",
|
||||
const party = [{ species: 143, dvs: 0x8888, hp: 100, status: 0 }, { species: 16, hp: next(2) ? 100 : 0, status: next(256) }];
|
||||
const enemyParty = structuredClone(party);
|
||||
enemyParty[1].hp = next(2) ? 100 : 0;
|
||||
const battle = new Battle({ data: assets.pokemon, party: party as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer: { name: "", className: "", party: enemyParty as never, aiFlags: 16, switchFlags: 0, reward: 0 } });
|
||||
const battle = new Battle({ data: assets.pokemon, party: party as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer: { name: "", className: "", party: enemyParty as never, aiFlags: 16, switchFlags: 0, items: [0, 0], baseReward: 0, reward: 0 } });
|
||||
const internal = battle as unknown as Record<string, any>;
|
||||
internal.battleMode = 2;
|
||||
internal.enemySpecies = 143;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
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.");
|
||||
|
||||
// TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm).
|
||||
const ALWAYS_USE = 1 << 4,
|
||||
UNKNOWN_USE = 1 << 5,
|
||||
CONTEXT_USE = 1 << 6;
|
||||
|
||||
/**
|
||||
* Compares the native AI_TryItem (engine/battle/ai/items.asm) against the
|
||||
* unmodified routine in the reference emulator, called directly (not
|
||||
* through AI_SwitchOrTryItem, whose switch-side dispatch predates this
|
||||
* port and is exercised by the existing switch tests/playthrough). Random
|
||||
* is redirected to a fixed byte stream shared by both runs, so the number
|
||||
* of draws is part of the comparison alongside the modeled state: the two item slots, the base reward (the misread-item bug), the
|
||||
* enemy's HP/status/substatuses, its stat levels, the turn-ending
|
||||
* substatus resets and wEnemyGoesFirst.
|
||||
*
|
||||
* The AI context/switch flags (CONTEXT_USE, ALWAYS_USE, UNKNOWN_USE) are
|
||||
* read live from TrainerClassAttributes by class, so eight reserved
|
||||
* classes (1-8) have that one byte patched into the ROM copy with every
|
||||
* combination; every other table this routine reads (AI_Items' item ids)
|
||||
* is exercised as native code, not re-read from ROM.
|
||||
*/
|
||||
test("AI_TryItem matches the original across items, flags, HP, status and turn state", async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
const { at, routine } = loadSymbols();
|
||||
const names = [
|
||||
"hROMBank", "wInBattleTowerBattle", "wEnemyTrainerItem1", "wEnemyTrainerItem2", "wEnemyTrainerBaseReward",
|
||||
"wTrainerClass", "wOTPartyCount", "wOTPartyMon1Level", "wOTPartyMon1Status", "wOTPartyMon1HP",
|
||||
"wCurOTMon", "wEnemyMonStatus", "wEnemyMonHP", "wEnemyMonMaxHP",
|
||||
"wEnemySubStatus3", "wEnemySubStatus4", "wEnemySubStatus5", "wEnemyToxicCount", "wEnemyTurnsTaken",
|
||||
"wEnemyGoesFirst", "wEnemyFuryCutterCount", "wEnemyProtectCount", "wEnemyRageCounter",
|
||||
"wLastEnemyCounterMove", "wEnemyStatLevels", "wEnemyStats", "hBattleTurn", "wLinkMode", "wBattleMode",
|
||||
"wAttackMissed", "wEffectFailed",
|
||||
];
|
||||
const addresses = Object.fromEntries(names.map((name) => [name, at(name)]));
|
||||
const commands = { tryItem: routine("AI_TryItem"), random: routine("Random") };
|
||||
const trainerClassAttributes = routine("TrainerClassAttributes");
|
||||
// bank*0x4000 + address-0x4000: the ROM file offset (scripts/check-reference.mjs's formula).
|
||||
const flat = ({ bank, address }: { bank: number; address: number }) =>
|
||||
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||
const attributesFlat = flat(trainerClassAttributes);
|
||||
// A used item prints text and plays a sound, and the heal items animate
|
||||
// the HP bar and redraw the HUD; all four wait on VBlank-driven input or
|
||||
// timing that this isolated, interrupt-disabled call never provides, so
|
||||
// they are neutered like Random. None of their own effects (the screen,
|
||||
// audio and the HP bar's visual fill) are part of this comparison; the
|
||||
// WRAM state AI_TryItem changes before and around them is.
|
||||
const presentationFlats = ["PrintText", "PlaySFX", "AnimateHPBar", "UpdateEnemyHUD"].map((name) => flat(routine(name)));
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, commands, attributesFlat, bits, presentationFlats }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { Battle } = await import("/src/game/battle/battle.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
// Patch Random (reference-smart-ai.spec.ts's stub and stream) and, for
|
||||
// eight reserved trainer classes (1-8), the AI_ITEM_SWITCH byte each
|
||||
// flag combination needs; AI_TryItem reads it live from ROM by class.
|
||||
const patched = rom.slice();
|
||||
patched.set([0xc3, 0x80, 0xc0], commands.random.address);
|
||||
for (const p of presentationFlats) patched[p] = 0xc9; // ret
|
||||
const NUM_TRAINER_ATTRIBUTES = 7, TRNATTR_AI_ITEM_SWITCH = 5;
|
||||
bits.forEach((flags: number, i: number) => {
|
||||
const p = attributesFlat + i * NUM_TRAINER_ATTRIBUTES + TRNATTR_AI_ITEM_SWITCH;
|
||||
patched[p] = flags;
|
||||
patched[p + 1] = 0;
|
||||
});
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(patched, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const readWord = (p: number) => (read(p) << 8) | read(p + 1);
|
||||
const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9];
|
||||
stub.forEach((v, i) => write(0xc080 + i, v));
|
||||
const { bank, address } = commands.tryItem;
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
|
||||
let seed = 0x9a7c3;
|
||||
const next = (limit = 256) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return (seed >>> 16) % limit; };
|
||||
|
||||
// Items in AI_Items' scan order, with 0 standing for NO_ITEM.
|
||||
const I = { FR: 0x0e, MP: 0x0f, HP: 0x10, SP: 0x11, PO: 0x12, XAC: 0x21, FH: 0x26, GS: 0x29, DH: 0x2c, XAT: 0x31, XDE: 0x33, XSP: 0x34, XSC: 0x35 };
|
||||
const itemPairs: [number, number][] = [
|
||||
[0, 0], [I.FR, 0], [I.MP, 0], [I.HP, I.SP], [I.PO, I.XAC], [I.FH, I.GS], [I.DH, I.XAT],
|
||||
[I.XDE, 0], [I.XSP, 0], [I.XSC, 0], [I.FH, I.HP], // last pair: the stuck-pointer scenario
|
||||
];
|
||||
const hpCases = [[100, 100], [30, 100], [10, 100], [25, 100], [26, 100], [50, 100], [51, 100], [25, 101]];
|
||||
const statusCases: { status: number; toxic?: number }[] = [
|
||||
{ status: 0 }, { status: 1 << 5 /* FRZ */ }, { status: 3 /* asleep */ },
|
||||
{ status: 1 << 3 /* PSN */, toxic: 5 },
|
||||
];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0, maxDraws = 0;
|
||||
for (let flagIndex = 0; flagIndex < bits.length; flagIndex++)
|
||||
for (const [item1, item2] of itemPairs)
|
||||
for (const [hp, maxHp] of hpCases)
|
||||
for (const { status, toxic } of statusCases)
|
||||
for (const turnsTaken of [0, 1]) {
|
||||
const classId = flagIndex + 1;
|
||||
const [attackMissed, effectFailed] = [[0, 0], [1, 0], [0, 1]][compared % 3];
|
||||
const bytes = Array.from({ length: 64 }, () => next());
|
||||
let draws = 0;
|
||||
const party = [{ level: 30, status: 0, hp: 50 }];
|
||||
const trainer = {
|
||||
name: "", className: "", party: party as never, aiFlags: 0, switchFlags: bits[flagIndex],
|
||||
items: [item1, item2] as [number, number], baseReward: 25, reward: 0,
|
||||
};
|
||||
const battle = new Battle({
|
||||
data: assets.pokemon, party: [] as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1,
|
||||
random: () => bytes[draws++], text: () => ({}) as never,
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
trainer,
|
||||
});
|
||||
battle.turn = 1;
|
||||
battle.attackMissed = attackMissed;
|
||||
battle.effectFailed = effectFailed;
|
||||
battle.curOTMon = 0;
|
||||
battle.enemy.mon = {
|
||||
species: 19, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70,
|
||||
level: 30, status, hp, maxHp, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA",
|
||||
} as never;
|
||||
battle.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||
battle.enemy.sub = [0, 0, 0, (1 << 0) | (1 << 7) /* Bide, Confused */, 1 << 6 /* Rage */, toxic ? 1 : 0];
|
||||
battle.enemy.toxicCount = toxic ?? 0;
|
||||
battle.enemy.turnsTaken = turnsTaken;
|
||||
battle.enemy.furyCutterCount = 3;
|
||||
battle.enemy.protectCount = 2;
|
||||
battle.enemy.rageCounter = 4;
|
||||
battle.enemy.lastCounterMove = 33;
|
||||
battle.enemyGoesFirst = 0;
|
||||
const internal = battle as unknown as { aiTryItem(): Generator<unknown, boolean, unknown> };
|
||||
const gen = internal.aiTryItem();
|
||||
for (let step = gen.next(); !step.done; step = gen.next());
|
||||
const nativeDraws = draws;
|
||||
|
||||
write(a.wInBattleTowerBattle, 0);
|
||||
write(a.wTrainerClass, classId);
|
||||
write(a.wEnemyTrainerItem1, item1);
|
||||
write(a.wEnemyTrainerItem2, item2);
|
||||
write(a.wEnemyTrainerBaseReward, 25);
|
||||
write(a.wOTPartyCount, 1);
|
||||
write(a.wOTPartyMon1Level, 30);
|
||||
write(a.wOTPartyMon1Status, 0);
|
||||
word(a.wOTPartyMon1HP, 50);
|
||||
write(a.wCurOTMon, 0);
|
||||
write(a.wEnemyMonStatus, status);
|
||||
word(a.wEnemyMonHP, hp);
|
||||
word(a.wEnemyMonMaxHP, maxHp);
|
||||
write(a.wEnemySubStatus3, (1 << 0) | (1 << 7));
|
||||
write(a.wEnemySubStatus4, 1 << 6);
|
||||
write(a.wEnemySubStatus5, toxic ? 1 : 0);
|
||||
write(a.wEnemyToxicCount, toxic ?? 0);
|
||||
write(a.wEnemyTurnsTaken, turnsTaken);
|
||||
write(a.wEnemyGoesFirst, 0);
|
||||
write(a.wEnemyFuryCutterCount, 3);
|
||||
write(a.wEnemyProtectCount, 2);
|
||||
write(a.wEnemyRageCounter, 4);
|
||||
write(a.wLastEnemyCounterMove, 33);
|
||||
for (let i = 0; i < 8; i++) write(a.wEnemyStatLevels + i, 7);
|
||||
for (let i = 0; i < 5; i++) word(a.wEnemyStats + i * 2, 30);
|
||||
write(a.hBattleTurn, 1);
|
||||
write(a.wLinkMode, 0);
|
||||
write(a.wBattleMode, 2);
|
||||
// RaiseStat (not part of this port) gates on these without
|
||||
// AI_TryItem ever clearing them; both runs start a turn
|
||||
// with the same stale flags; successful, missed and failed
|
||||
// previous moves all occur in this sweep.
|
||||
write(a.wAttackMissed, attackMissed);
|
||||
write(a.wEffectFailed, effectFailed);
|
||||
bytes.forEach((v, i) => write(0xc100 + i, v));
|
||||
write(0xc0e0, 0);
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw new Error("AI_TryItem did not return");
|
||||
|
||||
const original = {
|
||||
used: read(a.wEnemyGoesFirst) === 1,
|
||||
item1: read(a.wEnemyTrainerItem1), item2: read(a.wEnemyTrainerItem2),
|
||||
baseReward: read(a.wEnemyTrainerBaseReward),
|
||||
status: read(a.wEnemyMonStatus), hp: readWord(a.wEnemyMonHP),
|
||||
partyStatus: read(a.wOTPartyMon1Status), partyHP: readWord(a.wOTPartyMon1HP),
|
||||
sub3: read(a.wEnemySubStatus3), sub4: read(a.wEnemySubStatus4), sub5: read(a.wEnemySubStatus5),
|
||||
furyCutterCount: read(a.wEnemyFuryCutterCount), protectCount: read(a.wEnemyProtectCount),
|
||||
rageCounter: read(a.wEnemyRageCounter), lastCounterMove: read(a.wLastEnemyCounterMove),
|
||||
levels: Array.from({ length: 7 }, (_, i) => read(a.wEnemyStatLevels + i)),
|
||||
draws: read(0xc0e0),
|
||||
};
|
||||
const native = {
|
||||
used: battle.enemyGoesFirst === 1,
|
||||
item1: trainer.items[0], item2: trainer.items[1],
|
||||
baseReward: trainer.baseReward,
|
||||
status: battle.enemy.mon.status, hp: battle.enemy.mon.hp,
|
||||
partyStatus: party[0].status, partyHP: party[0].hp,
|
||||
sub3: battle.enemy.sub[3], sub4: battle.enemy.sub[4], sub5: battle.enemy.sub[5],
|
||||
furyCutterCount: battle.enemy.furyCutterCount, protectCount: battle.enemy.protectCount,
|
||||
rageCounter: battle.enemy.rageCounter, lastCounterMove: battle.enemy.lastCounterMove,
|
||||
levels: battle.enemy.levels.slice(),
|
||||
draws: nativeDraws,
|
||||
};
|
||||
maxDraws = Math.max(maxDraws, native.draws, original.draws);
|
||||
if (JSON.stringify(original) !== JSON.stringify(native))
|
||||
failures.push({ flagIndex, item1, item2, hp, maxHp, status, toxic, turnsTaken, original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, maxDraws, failures, count: failures.length };
|
||||
},
|
||||
{ a: addresses, commands, attributesFlat, presentationFlats, bits: [0, ALWAYS_USE, UNKNOWN_USE, CONTEXT_USE, ALWAYS_USE | UNKNOWN_USE, ALWAYS_USE | CONTEXT_USE, UNKNOWN_USE | CONTEXT_USE, ALWAYS_USE | UNKNOWN_USE | CONTEXT_USE] },
|
||||
);
|
||||
expect(result.compared).toBe(8 * 11 * 8 * 4 * 2);
|
||||
expect(result, JSON.stringify(result.failures.slice(0, 3))).toMatchObject({ count: 0, failures: [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* The base-reward-misread bug (docs/bugs_and_glitches.md "AI might use its
|
||||
* base reward value as an item"): no real trainer's base reward aliases an
|
||||
* AI_Items id (every two-item trainer's is 25), so this sets one up by
|
||||
* hand and checks both engines agree the AI "uses" Full Heal by zeroing
|
||||
* the reward rather than either real item slot.
|
||||
*/
|
||||
for (const entry of ["direct", "no-switch-style", "mean-look", "wrap"] as const)
|
||||
test(`Trainer item base-reward collision matches the original through ${entry}`, async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const names = ["hROMBank", "wInBattleTowerBattle", "wEnemyTrainerItem1", "wEnemyTrainerItem2", "wEnemyTrainerBaseReward", "wTrainerClass", "wOTPartyCount", "wOTPartyMon1Level", "wOTPartyMon1Status", "wCurOTMon", "wEnemyMonStatus", "wEnemyMonHP", "wEnemyMonMaxHP", "wEnemySubStatus3", "wEnemySubStatus4", "wEnemySubStatus5", "wEnemyToxicCount", "wEnemyTurnsTaken", "wEnemyGoesFirst", "hBattleTurn", "wLinkMode", "wBattleMode", "wPlayerSubStatus5", "wEnemyWrapCount", "wEnemySubStatus1"];
|
||||
const addresses = Object.fromEntries(names.map((name) => [name, at(name)]));
|
||||
const commands = { tryItem: routine(entry === "direct" ? "AI_TryItem" : "AI_SwitchOrTryItem"), random: routine("Random") };
|
||||
const trainerClassAttributes = routine("TrainerClassAttributes");
|
||||
const flat = ({ bank, address }: { bank: number; address: number }) => (address < 0x4000 ? address : bank * 0x4000 + address - 0x4000);
|
||||
const attributesFlat = flat(trainerClassAttributes);
|
||||
// See the main sweep above: these four are presentation-only and would
|
||||
// otherwise wait on input/timing this isolated call never provides.
|
||||
const presentationFlats = ["PrintText", "PlaySFX", "AnimateHPBar", "UpdateEnemyHUD"].map((name) => flat(routine(name)));
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, commands, attributesFlat, presentationFlats, entry }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { Battle } = await import("/src/game/battle/battle.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
const patched = rom.slice();
|
||||
patched.set([0xc3, 0x80, 0xc0], commands.random.address);
|
||||
for (const p of presentationFlats) patched[p] = 0xc9; // ret
|
||||
// Class 1's AI_ITEM_SWITCH byte: CONTEXT_USE.
|
||||
patched[attributesFlat + 5] = 0x40;
|
||||
patched[attributesFlat + 6] = 0;
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(patched, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const readWord = (p: number) => (read(p) << 8) | read(p + 1);
|
||||
const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9];
|
||||
stub.forEach((v, i) => write(0xc080 + i, v));
|
||||
const { bank, address } = commands.tryItem;
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
for (let i = 0; i < 16; i++) write(0xc100 + i, 0); // never drawn, but kept tidy
|
||||
const item1 = 0, item2 = 0x10 /* HYPER_POTION */, baseReward = 0x26; /* FULL_HEAL */
|
||||
const trainer = { name: "", className: "", party: [{ level: 30, status: 0, hp: 50 }] as never, aiFlags: 0, switchFlags: 0x40, items: [item1, item2] as [number, number], baseReward, reward: 0 };
|
||||
const battle = new Battle({
|
||||
data: assets.pokemon, party: [] as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1,
|
||||
random: () => 0, text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() },
|
||||
trainer,
|
||||
});
|
||||
battle.turn = 1;
|
||||
battle.curOTMon = 0;
|
||||
battle.enemy.mon = { species: 19, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70, level: 30, status: 1 << 5, hp: 100, maxHp: 100, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA" } as never;
|
||||
battle.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||
battle.enemyGoesFirst = 0;
|
||||
if (entry === "mean-look") battle.player.set(5, 7);
|
||||
if (entry === "wrap") battle.enemy.wrapCount = 2;
|
||||
const internal = battle as unknown as {
|
||||
aiTryItem(): Generator<unknown, boolean, unknown>;
|
||||
aiSwitchOrTryItem(): Generator<unknown, boolean, unknown>;
|
||||
};
|
||||
const gen = entry === "direct" ? internal.aiTryItem() : internal.aiSwitchOrTryItem();
|
||||
for (let step = gen.next(); !step.done; step = gen.next());
|
||||
|
||||
write(a.wInBattleTowerBattle, 0);
|
||||
write(a.wTrainerClass, 1);
|
||||
write(a.wEnemyTrainerItem1, item1);
|
||||
write(a.wEnemyTrainerItem2, item2);
|
||||
write(a.wEnemyTrainerBaseReward, baseReward);
|
||||
write(a.wOTPartyCount, 1);
|
||||
write(a.wOTPartyMon1Level, 30);
|
||||
write(a.wOTPartyMon1Status, 0);
|
||||
write(a.wCurOTMon, 0);
|
||||
write(a.wEnemyMonStatus, 1 << 5);
|
||||
word(a.wEnemyMonHP, 100);
|
||||
word(a.wEnemyMonMaxHP, 100);
|
||||
write(a.wEnemySubStatus3, 0);
|
||||
write(a.wEnemySubStatus4, 0);
|
||||
write(a.wEnemySubStatus5, 0);
|
||||
write(a.wEnemyToxicCount, 0);
|
||||
write(a.wEnemyTurnsTaken, 0);
|
||||
write(a.wEnemyGoesFirst, 0);
|
||||
write(a.hBattleTurn, 1);
|
||||
write(a.wLinkMode, 0);
|
||||
write(a.wBattleMode, 2);
|
||||
write(a.wPlayerSubStatus5, entry === "mean-look" ? 0x80 : 0);
|
||||
write(a.wEnemyWrapCount, entry === "wrap" ? 2 : 0);
|
||||
write(a.wEnemySubStatus1, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
write(0xc0f0, 0);
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw new Error("AI_TryItem did not return");
|
||||
const final = {
|
||||
original: { used: read(a.wEnemyGoesFirst) === 1, item1: read(a.wEnemyTrainerItem1), item2: read(a.wEnemyTrainerItem2), baseReward: read(a.wEnemyTrainerBaseReward), status: read(a.wEnemyMonStatus) },
|
||||
native: { used: battle.enemyGoesFirst === 1, item1: trainer.items[0], item2: trainer.items[1], baseReward: trainer.baseReward, status: battle.enemy.mon.status },
|
||||
};
|
||||
reference.dispose();
|
||||
return final;
|
||||
}, { a: addresses, commands, attributesFlat, presentationFlats, entry });
|
||||
expect(result.native).toEqual(result.original);
|
||||
expect(result.native).toEqual({ used: true, item1: 0, item2: 0x10, baseReward: 0, status: 0 });
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
// BurnedTower1F's rival (map object 3) is OBJECTTYPE_TRAINER with sight 3,
|
||||
// but its script pointer is the generic ObjectEvent (home/map.asm's
|
||||
// `jumptextfaceplayer ObjectEventText`), not a TrainerHeader: see
|
||||
// maps/BurnedTower1F.asm's `object_event 8, 9, SPRITE_RIVAL, ..., 0,
|
||||
// OBJECTTYPE_TRAINER, 3, ObjectEvent, EVENT_RIVAL_BURNED_TOWER`. After
|
||||
// BurnedTowerRivalBattleScript the rival stands at (9,9) facing right and
|
||||
// the player at (10,9). _CheckTrainerBattle reads the "beaten" flag from
|
||||
// the script pointer's first two bytes: jumptextfaceplayer_command ($51)
|
||||
// and ObjectEventText's low byte ($f2), i.e. flag $f251. EventFlagAction's
|
||||
// bit math (de >> 3 added to wEventFlags) overflows by $1e4a bytes into
|
||||
// echo RAM, landing on wNumKeyItems (bank 1, $d8bc) bit 1.
|
||||
test("CheckTrainerBattle reads BurnedTower1F's rival as beaten only when wNumKeyItems' bit 1 is set", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
lastTalked: at("hLastTalked"),
|
||||
mapScriptsBank: at("wMapScriptsBank"),
|
||||
playerMapX: at("wPlayerMapX"),
|
||||
playerMapY: at("wPlayerMapY"),
|
||||
map3Object: at("wMap3Object"),
|
||||
object1Struct: at("wObject1Struct"),
|
||||
numKeyItems: at("wNumKeyItems"),
|
||||
seenTrainerDistance: at("wSeenTrainerDistance"),
|
||||
seenTrainerDirection: at("wSeenTrainerDirection"),
|
||||
tempTrainerEventFlag: at("wTempTrainerEventFlag"),
|
||||
tempTrainerClass: at("wTempTrainerClass"),
|
||||
tempTrainerID: at("wTempTrainerID"),
|
||||
seenTextPointer: at("wSeenTextPointer"),
|
||||
winTextPointer: at("wWinTextPointer"),
|
||||
lossTextPointer: at("wLossTextPointer"),
|
||||
scriptAfterPointer: at("wScriptAfterPointer"),
|
||||
runningTrainerBattleScript: at("wRunningTrainerBattleScript"),
|
||||
};
|
||||
const checkTrainerBattle = routine("CheckTrainerBattle");
|
||||
const objectEvent = at("ObjectEvent");
|
||||
const mapScriptsBank = routine("BurnedTower1F_MapScripts").bank;
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, checkTrainerBattle, objectEvent, mapScriptsBank }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const ptr = (p: number, v: number) => { write(p, v); write(p + 1, v >> 8); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const readPtr = (p: number) => read(p) | (read(p + 1) << 8);
|
||||
// DI; SP=$c7fe; CheckTrainerBattle is home code, bank is irrelevant;
|
||||
// CALL CheckTrainerBattle; branch on carry into $c0f1, then the sentinel
|
||||
// at $c0f0, as the other reference fixtures do. Both live away from any
|
||||
// battle or trainer variable.
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, checkTrainerBattle.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, checkTrainerBattle.address & 255, checkTrainerBattle.address >> 8,
|
||||
0x38, 3, 0xaf, 0x18, 2, 0x3e, 1, 0xea, 0xf1, 0xc0,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
|
||||
const results: unknown[] = [];
|
||||
for (let numKeyItems = 0; numKeyItems <= 6; numKeyItems++) {
|
||||
// The rival: a trainer with sight 3 whose script pointer is the
|
||||
// generic ObjectEvent, standing at (9,9) facing right; the player at
|
||||
// (10,9), one tile to the right.
|
||||
write(a.mapScriptsBank, mapScriptsBank);
|
||||
write(a.playerMapX, 10);
|
||||
write(a.playerMapY, 9);
|
||||
write(a.map3Object + 0, 1); // MAPOBJECT_OBJECT_STRUCT_ID -> wObject1Struct
|
||||
write(a.map3Object + 1, 1); // MAPOBJECT_SPRITE (nonzero: has a sprite)
|
||||
write(a.map3Object + 8, 2); // MAPOBJECT_TYPE: OBJECTTYPE_TRAINER
|
||||
write(a.map3Object + 9, 3); // MAPOBJECT_SIGHT_RANGE
|
||||
ptr(a.map3Object + 0xa, objectEvent); // MAPOBJECT_SCRIPT_POINTER -> ObjectEvent
|
||||
write(a.object1Struct + 0x10, 9); // OBJECT_MAP_X
|
||||
write(a.object1Struct + 0x11, 9); // OBJECT_MAP_Y
|
||||
write(a.object1Struct + 0x08, 0x0c); // OBJECT_DIRECTION: facing right (OW_RIGHT)
|
||||
write(a.numKeyItems, numKeyItems);
|
||||
// Sentinels: only a reported sighting overwrites these.
|
||||
write(a.lastTalked, 0xff);
|
||||
write(a.seenTrainerDistance, 0xff);
|
||||
write(a.seenTrainerDirection, 0xff);
|
||||
ptr(a.tempTrainerEventFlag, 0xffff);
|
||||
write(a.tempTrainerClass, 0xff);
|
||||
write(a.tempTrainerID, 0xff);
|
||||
ptr(a.seenTextPointer, 0xffff);
|
||||
ptr(a.winTextPointer, 0xffff);
|
||||
ptr(a.lossTextPointer, 0xffff);
|
||||
ptr(a.scriptAfterPointer, 0xffff);
|
||||
write(a.runningTrainerBattleScript, 0xff);
|
||||
write(0xc0f0, 0);
|
||||
write(0xc0f1, 0xff);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("CheckTrainerBattle did not return");
|
||||
results.push({
|
||||
numKeyItems,
|
||||
carry: read(0xc0f1) === 1,
|
||||
lastTalked: read(a.lastTalked),
|
||||
seenTrainerDistance: read(a.seenTrainerDistance),
|
||||
seenTrainerDirection: read(a.seenTrainerDirection),
|
||||
tempTrainer: {
|
||||
eventFlag: readPtr(a.tempTrainerEventFlag),
|
||||
class: read(a.tempTrainerClass),
|
||||
id: read(a.tempTrainerID),
|
||||
seenText: readPtr(a.seenTextPointer),
|
||||
winText: readPtr(a.winTextPointer),
|
||||
lossText: readPtr(a.lossTextPointer),
|
||||
scriptAfter: readPtr(a.scriptAfterPointer),
|
||||
runningTrainerBattleScript: read(a.runningTrainerBattleScript),
|
||||
},
|
||||
});
|
||||
}
|
||||
reference.dispose();
|
||||
return results;
|
||||
}, { a, checkTrainerBattle, objectEvent, mapScriptsBank });
|
||||
|
||||
// Bit 1 of wNumKeyItems (2, 3, 6) makes the overflowed flag read set, so
|
||||
// the original treats the rival as already beaten and skips it; every
|
||||
// other count reports the sighting, copying ObjectEvent's bytes as a
|
||||
// (nonsensical) trainer header into wTempTrainer.
|
||||
const sentinelPtr = { eventFlag: 0xffff, class: 0xff, id: 0xff, seenText: 0xffff, winText: 0xffff, lossText: 0xffff, scriptAfter: 0xffff, runningTrainerBattleScript: 0xff };
|
||||
const decodedHeader = { eventFlag: 0xf251, class: 0x26, id: 0x16, seenText: 0x46ee, winText: 0x5071, lossText: 0xfa53, scriptAfter: 0x1626, runningTrainerBattleScript: 0 };
|
||||
const expected = [0, 1, 2, 3, 4, 5, 6].map((numKeyItems) => {
|
||||
const beaten = (numKeyItems & 2) !== 0;
|
||||
return {
|
||||
numKeyItems,
|
||||
carry: !beaten,
|
||||
lastTalked: beaten ? 0xff : 3,
|
||||
seenTrainerDistance: beaten ? 0xff : 1,
|
||||
seenTrainerDirection: beaten ? 0xff : 0x0c,
|
||||
tempTrainer: beaten ? sentinelPtr : decodedHeader,
|
||||
};
|
||||
});
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
// The battle CheckTrainerBattle reports when it does fire: LoadTrainer_continue
|
||||
// copies 13 bytes starting at ObjectEvent into wTempTrainer, reading its
|
||||
// command byte and ObjectEventText's pointer as a trainer class, ID and
|
||||
// text pointers. Class $26 is SWIMMERM (constants/trainer_constants.asm),
|
||||
// which has exactly 21 named trainers; ID $16 (22) is one past the last of
|
||||
// them. ReadTrainerParty's name-skipping loop (engine/battle/
|
||||
// read_trainer_party.asm) scans forward from SwimmerMGroup's start for 21
|
||||
// end-of-party ($ff) markers and, since the group tables in data/trainers/
|
||||
// parties.asm sit back-to-back with no padding, lands exactly on
|
||||
// SwimmerFGroup's first entry: ELAINE, a single level 21 STARYU. The
|
||||
// party itself is no garbage: SWIMMERM's sprite, cry and class text with
|
||||
// SwimmerF's ELAINE as the opponent's name and sole Pokemon, confirmed here
|
||||
// by running the unmodified ReadTrainerParty and Battle_GetTrainerName
|
||||
// routines. StartBattleWithMapTrainerScript prints the seen text first,
|
||||
// though, and the next test shows that is not text.
|
||||
test("the decoded trainer header resolves to SwimmerF's ELAINE, one past SwimmerM's last trainer", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
inBattleTowerBattle: at("wInBattleTowerBattle"),
|
||||
linkMode: at("wLinkMode"),
|
||||
otherTrainerClass: at("wOtherTrainerClass"),
|
||||
otherTrainerID: at("wOtherTrainerID"),
|
||||
otPartyCount: at("wOTPartyCount"),
|
||||
otPartyMon1Species: at("wOTPartyMon1Species"),
|
||||
otPartyMon1Level: at("wOTPartyMon1Level"),
|
||||
stringBuffer1: at("wStringBuffer1"),
|
||||
};
|
||||
const readTrainerParty = routine("ReadTrainerParty");
|
||||
const getTrainerName = routine("Battle_GetTrainerName");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, readTrainerParty, getTrainerName }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
write(a.inBattleTowerBattle, 0);
|
||||
write(a.linkMode, 0);
|
||||
write(a.otherTrainerClass, 0x26); // SWIMMERM
|
||||
write(a.otherTrainerID, 0x16); // 22: one past SWIMMERM's last trainer (21)
|
||||
// DI; SP=$c7fe; both routines share engine/battle/core.asm's bank;
|
||||
// CALL ReadTrainerParty (ends in a party of OT mons), then
|
||||
// CALL Battle_GetTrainerName (copies the name into wStringBuffer1).
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, readTrainerParty.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, readTrainerParty.address & 255, readTrainerParty.address >> 8,
|
||||
0xcd, getTrainerName.address & 255, getTrainerName.address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("ReadTrainerParty/Battle_GetTrainerName did not return");
|
||||
const outcome = {
|
||||
partyCount: read(a.otPartyCount),
|
||||
species: read(a.otPartyMon1Species),
|
||||
level: read(a.otPartyMon1Level),
|
||||
name: Array.from({ length: 11 }, (_, i) => read(a.stringBuffer1 + i)),
|
||||
};
|
||||
reference.dispose();
|
||||
return outcome;
|
||||
}, { a, readTrainerParty, getTrainerName });
|
||||
|
||||
const STARYU = 0x78;
|
||||
// "ELAINE@" in the game's charmap, followed by SwimmerFGroup's next three
|
||||
// bytes (TRAINERTYPE_NORMAL, level 21, STARYU) and the party's $ff end
|
||||
// marker: the exact 11 (NAME_LENGTH) bytes CopyTrainerName copies.
|
||||
expect(result).toEqual({
|
||||
partyCount: 1,
|
||||
species: STARYU,
|
||||
level: 21,
|
||||
name: [0x84, 0x8b, 0x80, 0x88, 0x8d, 0x84, 0x50, 0x00, 0x15, 0x78, 0xff],
|
||||
});
|
||||
});
|
||||
|
||||
// StartBattleWithMapTrainerScript's `trainertext TRAINERTEXT_SEEN` prints
|
||||
// the decoded seen text ($46ee in the map scripts' bank) before the battle.
|
||||
// It points into the middle of SproutTower1FStatueText: its first byte is
|
||||
// the space character $7f, which DoTextUntilTerminator dispatches as a text
|
||||
// command. TextCommands has NUM_TEXT_CMDS (23) entries, so command $7f reads
|
||||
// its pointer from home code beyond the table and jumps into the middle of
|
||||
// the VBlank handlers. What happens next depends on the whole CPU state, so
|
||||
// the native port keeps skipping the sighting (see checkTrainers).
|
||||
test("the decoded seen text dispatches text command $7f, beyond TextCommands", () => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const rom = readFileSync(romPath);
|
||||
const bank = routine("BurnedTower1F_MapScripts").bank;
|
||||
const seenText = 0x46ee;
|
||||
const first = rom[bank * 0x4000 + seenText - 0x4000];
|
||||
const NUM_TEXT_CMDS = 23;
|
||||
const entry = at("TextCommands") + first * 2;
|
||||
const target = rom[entry] | (rom[entry + 1] << 8);
|
||||
expect(first).toBe(0x7f);
|
||||
expect(first).toBeGreaterThanOrEqual(NUM_TEXT_CMDS);
|
||||
expect(entry).toBeGreaterThan(at("TextCommands") + NUM_TEXT_CMDS * 2);
|
||||
expect(target).toBe(0x0428);
|
||||
expect(target).toBeGreaterThan(at("VBlank_Credits"));
|
||||
expect(target).toBeLessThan(at("TextCommands"));
|
||||
});
|
||||
@@ -52,6 +52,8 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
||||
items: [...trainer.attributes.items] as [number, number],
|
||||
baseReward: trainer.attributes.baseReward,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
@@ -302,4 +304,55 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
expect(result.result).toBe(0);
|
||||
expect(result.texts).toContain("was defeated!");
|
||||
});
|
||||
it("Clair's AI_TryItem heals her Kingdra with the real Hyper Potion, stranding the Full Heal behind it", () => {
|
||||
// CLAIR's real class attributes (items.asm): FULL_HEAL then HYPER_POTION,
|
||||
// CONTEXT_USE and SWITCH_SOMETIMES. Her Kingdra (party index 3, level
|
||||
// 40) is the only one of her four Pokémon .IsHighestLevel ever lets use
|
||||
// an item; the other three are level 37. A single Kingdra, taken from
|
||||
// her real ROM party and class data, is enough to exercise that gate
|
||||
// through a real Battle.run(), without needing her map open yet.
|
||||
const clair = trainers.trainer(8, 1);
|
||||
expect(clair.name).toBe("CLAIR");
|
||||
expect(clair.attributes.items).toEqual([0x26, 0x10]); // FULL_HEAL, HYPER_POTION
|
||||
const kingdra = trainers.createParty(clair)[3];
|
||||
expect(kingdra.species).toBe(230); // KINGDRA
|
||||
// Well under a quarter of max HP: AI_TryItem heals on Clair's very
|
||||
// first turn, before any Random draw.
|
||||
kingdra.hp = Math.floor(kingdra.stats[0] / 10);
|
||||
const weak = generatePartyMon(assets.pokemon, {
|
||||
species: 155, level: 5, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
});
|
||||
weak.moves = [33, 0, 0, 0]; // Tackle: never one-shots a level 40 Kingdra
|
||||
weak.pp = [35, 0, 0, 0];
|
||||
let seed = 1;
|
||||
const options: BattleOptions = {
|
||||
data: assets.pokemon,
|
||||
party: [weak],
|
||||
playerId: 1234,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
random: () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
trainer: {
|
||||
name: clair.name,
|
||||
className: clair.className,
|
||||
party: [kingdra],
|
||||
aiFlags: clair.attributes.aiMoveFlags,
|
||||
switchFlags: clair.attributes.aiItemSwitchFlags,
|
||||
items: [...clair.attributes.items] as [number, number],
|
||||
baseReward: clair.attributes.baseReward,
|
||||
reward: clair.reward,
|
||||
},
|
||||
};
|
||||
const before = kingdra.hp;
|
||||
const result = finish(new Battle(options));
|
||||
expect(result.texts).toContain("LEADER CLAIR used HYPER POTION on KINGDRA!");
|
||||
// The Full Heal is still held; it is the Hyper Potion slot that empties.
|
||||
expect(options.trainer!.items).toEqual([0x26, 0]);
|
||||
expect(kingdra.hp).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { Battle, type BattleEffect, type BattleMon, type BattleTrainer } from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { partyMon } from "./battle-driver";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const RATTATA = 19,
|
||||
PIDGEY = 16;
|
||||
// TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm).
|
||||
const SWITCH_SOMETIMES = 1 << 2,
|
||||
ALWAYS_USE = 1 << 4,
|
||||
UNKNOWN_USE = 1 << 5,
|
||||
CONTEXT_USE = 1 << 6;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("trainer item AI (AI_TryItem)", () => {
|
||||
let assets: CrystalAssets;
|
||||
beforeAll(() => {
|
||||
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
});
|
||||
|
||||
const enemyMon = (
|
||||
level: number,
|
||||
hp: number,
|
||||
maxHp: number,
|
||||
status = 0,
|
||||
): BattleMon => ({
|
||||
species: RATTATA,
|
||||
item: 0,
|
||||
moves: [33, 0, 0, 0],
|
||||
pp: [35, 0, 0, 0],
|
||||
dvs: 0,
|
||||
happiness: 70,
|
||||
level,
|
||||
status,
|
||||
hp,
|
||||
maxHp,
|
||||
stats: [30, 30, 30, 30, 30],
|
||||
types: [0, 0],
|
||||
nickname: "RATTATA",
|
||||
});
|
||||
const makeTrainer = (
|
||||
overrides: Partial<BattleTrainer> = {},
|
||||
levels: number[] = [30],
|
||||
): BattleTrainer => ({
|
||||
name: "",
|
||||
className: "CLAIR",
|
||||
party: levels.map((level) => partyMon(assets, RATTATA, level)),
|
||||
aiFlags: 0,
|
||||
switchFlags: CONTEXT_USE | SWITCH_SOMETIMES,
|
||||
items: [0, 0],
|
||||
baseReward: 25,
|
||||
reward: 100,
|
||||
...overrides,
|
||||
});
|
||||
/** Builds a Battle with the enemy's battle Pokémon and curOTMon set
|
||||
* directly (as the reference specs do), skipping the opening send-out,
|
||||
* and exposes the private AI_TryItem for direct calls. */
|
||||
const rig = (
|
||||
trainer: BattleTrainer,
|
||||
enemy: BattleMon,
|
||||
{ curOTMon = 0, random = () => 0 }: { curOTMon?: number; random?: () => number } = {},
|
||||
) => {
|
||||
const battle = new Battle({
|
||||
data: assets.pokemon,
|
||||
party: [partyMon(assets, PIDGEY, 10)],
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
playerId: 1234,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
random,
|
||||
trainer,
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
});
|
||||
battle.turn = 1;
|
||||
battle.curOTMon = curOTMon;
|
||||
battle.enemy.mon = enemy;
|
||||
const internal = battle as unknown as {
|
||||
aiTryItem(): Generator<BattleEffect, boolean, unknown>;
|
||||
aiSwitchOrTryItem(): Generator<BattleEffect, boolean, unknown>;
|
||||
};
|
||||
return { battle, internal };
|
||||
};
|
||||
const run = (gen: Generator<BattleEffect, boolean, unknown>) => {
|
||||
const texts: string[] = [];
|
||||
let step = gen.next();
|
||||
while (!step.done) {
|
||||
if (step.value.kind === "text") texts.push(step.value.text.replace(/\n/g, " "));
|
||||
step = gen.next();
|
||||
}
|
||||
return { used: step.value, texts };
|
||||
};
|
||||
|
||||
it("does nothing with no items held", () => {
|
||||
const trainer = makeTrainer({ items: [0, 0] });
|
||||
const enemy = enemyMon(30, 10, 100, 1 << C.FRZ);
|
||||
const { internal } = rig(trainer, enemy);
|
||||
const { used, texts } = run(internal.aiTryItem());
|
||||
expect(used).toBe(false);
|
||||
expect(texts).toEqual([]);
|
||||
expect(enemy.status).toBe(1 << C.FRZ);
|
||||
});
|
||||
|
||||
it.each(["mean-look", "wrap"])("a trainer blocked from switching by %s can still heal", (trap) => {
|
||||
const trainer = makeTrainer({ items: [C.POTION, 0] });
|
||||
const enemy = enemyMon(30, 10, 100);
|
||||
const { battle, internal } = rig(trainer, enemy, {
|
||||
random: () => { throw new Error("Trapped trainers must skip the switch roll"); },
|
||||
});
|
||||
if (trap === "mean-look") battle.player.set(5, C.SUBSTATUS_CANT_RUN);
|
||||
else battle.enemy.wrapCount = 2;
|
||||
expect(run(internal.aiSwitchOrTryItem()).used).toBe(true);
|
||||
expect(enemy.hp).toBe(30);
|
||||
expect(trainer.items).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[C.POTION, 20],
|
||||
[C.SUPER_POTION, 50],
|
||||
[C.HYPER_POTION, 200],
|
||||
])("item %i heals %i HP, clamped to the maximum, and is consumed", (item, amount) => {
|
||||
const trainer = makeTrainer({ items: [item, 0] });
|
||||
const enemy = enemyMon(30, 10, 100);
|
||||
const { internal } = rig(trainer, enemy);
|
||||
const { used, texts } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(enemy.hp).toBe(Math.min(10 + amount, 100));
|
||||
expect(trainer.items).toEqual([0, 0]);
|
||||
expect(texts).toEqual([expect.stringMatching(/^CLAIR used .* on RATTATA!$/)]);
|
||||
});
|
||||
|
||||
it("Max Potion and Full Restore fill to the maximum HP", () => {
|
||||
for (const item of [C.MAX_POTION, C.FULL_RESTORE]) {
|
||||
const trainer = makeTrainer({ items: [item, 0] });
|
||||
const enemy = enemyMon(30, 1, 100);
|
||||
const { internal } = rig(trainer, enemy);
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(enemy.hp).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("Full Restore clears confusion, but Full Heal keeps it (a kept bug)", () => {
|
||||
for (const [item, staysConfused] of [[C.FULL_RESTORE, false], [C.FULL_HEAL, true]] as const) {
|
||||
const trainer = makeTrainer({ items: [item, 0] });
|
||||
const enemy = enemyMon(30, item === C.FULL_HEAL ? 100 : 1, 100, 1 << C.FRZ);
|
||||
const { battle, internal } = rig(trainer, enemy);
|
||||
battle.enemy.set(3, C.SUBSTATUS_CONFUSED);
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(enemy.status).toBe(0);
|
||||
expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(staysConfused);
|
||||
}
|
||||
});
|
||||
|
||||
it("Full Heal only cures Toxic, Freeze or Sleep under CONTEXT_USE, not plain status", () => {
|
||||
const trainer = makeTrainer({ items: [C.FULL_HEAL, 0] });
|
||||
const enemy = enemyMon(30, 100, 100, 1 << C.PAR);
|
||||
const { internal } = rig(trainer, enemy);
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(false);
|
||||
expect(enemy.status).toBe(1 << C.PAR);
|
||||
});
|
||||
|
||||
it("X Accuracy, Guard Spec and Dire Hit set their SubStatus4 flag", () => {
|
||||
const cases = [
|
||||
[C.X_ACCURACY, C.SUBSTATUS_X_ACCURACY],
|
||||
[C.GUARD_SPEC, C.SUBSTATUS_MIST],
|
||||
[C.DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY],
|
||||
] as const;
|
||||
for (const [item, bit] of cases) {
|
||||
const trainer = makeTrainer({ items: [item, 0], switchFlags: CONTEXT_USE });
|
||||
const enemy = enemyMon(30, 100, 100);
|
||||
// .XItem's first-turn-out gate (`cp 128; jp c, DontUse`) declines
|
||||
// below 128, so a high roll is the one that lets CONTEXT_USE through.
|
||||
const { battle, internal } = rig(trainer, enemy, { random: () => 254 });
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(battle.enemy.has(4, bit)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[C.X_ATTACK, C.ATTACK],
|
||||
[C.X_DEFEND, C.DEFENSE],
|
||||
[C.X_SPEED, C.SPEED],
|
||||
[C.X_SPECIAL, C.SP_ATTACK],
|
||||
])("item %i raises the matching stat", (item, stat) => {
|
||||
const trainer = makeTrainer({ items: [item, 0], switchFlags: CONTEXT_USE });
|
||||
const enemy = enemyMon(30, 100, 100);
|
||||
const { battle, internal } = rig(trainer, enemy, { random: () => 254 });
|
||||
const before = battle.enemy.levels[stat];
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(battle.enemy.levels[stat]).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("consumes whichever of the two slots the match came from", () => {
|
||||
// item2 (Hyper Potion) is tried before item1 (Potion) is ever reached,
|
||||
// since AI_Items lists Hyper Potion first.
|
||||
const trainer = makeTrainer({ items: [C.POTION, C.HYPER_POTION] });
|
||||
const enemy = enemyMon(30, 10, 100);
|
||||
const { internal } = rig(trainer, enemy);
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(enemy.hp).toBe(100); // Hyper Potion's 200 HP, clamped
|
||||
expect(trainer.items).toEqual([C.POTION, 0]);
|
||||
});
|
||||
|
||||
it("BUG: a declined second item strands the first for the rest of that scan", () => {
|
||||
// Clair: FULL_HEAL, HYPER_POTION. Full HP declines Hyper Potion, after
|
||||
// which the scan can never see item1 (Full Heal) again this call, even
|
||||
// though a frozen status would otherwise ask for it.
|
||||
const stuck = makeTrainer({ items: [C.FULL_HEAL, C.HYPER_POTION] });
|
||||
const frozenStuck = enemyMon(30, 100, 100, 1 << C.FRZ);
|
||||
const { internal: stuckInternal } = rig(stuck, frozenStuck);
|
||||
const { used: stuckUsed } = run(stuckInternal.aiTryItem());
|
||||
expect(stuckUsed).toBe(false);
|
||||
expect(frozenStuck.status).toBe(1 << C.FRZ);
|
||||
expect(stuck.items).toEqual([C.FULL_HEAL, C.HYPER_POTION]);
|
||||
|
||||
// The same Full Heal works fine once nothing else occupies the scan.
|
||||
const clear = makeTrainer({ items: [C.FULL_HEAL, 0] });
|
||||
const frozenClear = enemyMon(30, 100, 100, 1 << C.FRZ);
|
||||
const { internal: clearInternal } = rig(clear, frozenClear);
|
||||
const { used: clearUsed } = run(clearInternal.aiTryItem());
|
||||
expect(clearUsed).toBe(true);
|
||||
expect(frozenClear.status).toBe(0);
|
||||
});
|
||||
|
||||
it("BUG: the stuck pointer can drift into the base reward and misread it as a third item", () => {
|
||||
// No real item1; item2 (Hyper Potion) is declined at full HP, which
|
||||
// strands the scan at item2. It then reaches Full Heal's table entry
|
||||
// with the stuck pointer one past item2, i.e. wEnemyTrainerBaseReward:
|
||||
// a base reward that happens to equal FULL_HEAL's id is misread as
|
||||
// that item and "used", simply zeroing the reward (docs/bugs_and_glitches.md
|
||||
// "AI might use its base reward value as an item"). No real trainer's
|
||||
// base reward aliases an item id, so this needs an explicit setup.
|
||||
const trainer = makeTrainer({ items: [0, C.HYPER_POTION], baseReward: C.FULL_HEAL });
|
||||
const enemy = enemyMon(30, 100, 100, 1 << C.FRZ);
|
||||
const { internal } = rig(trainer, enemy);
|
||||
const { used } = run(internal.aiTryItem());
|
||||
expect(used).toBe(true);
|
||||
expect(enemy.status).toBe(0);
|
||||
expect(trainer.baseReward).toBe(0);
|
||||
expect(trainer.items).toEqual([0, C.HYPER_POTION]);
|
||||
});
|
||||
|
||||
it("only the trainer's highest-level Pokémon (ties included) may use an item", () => {
|
||||
const trainer = makeTrainer({ items: [C.FULL_HEAL, 0] }, [20, 30]);
|
||||
const enemy = enemyMon(20, 100, 100, 1 << C.FRZ);
|
||||
const { internal } = rig(trainer, enemy, { curOTMon: 0 });
|
||||
expect(run(internal.aiTryItem()).used).toBe(false);
|
||||
expect(enemy.status).toBe(1 << C.FRZ);
|
||||
|
||||
// A tie at the trainer's highest level still counts.
|
||||
trainer.party[0].level = 30;
|
||||
const tied = enemyMon(30, 100, 100, 1 << C.FRZ);
|
||||
const { internal: tiedInternal } = rig(trainer, tied, { curOTMon: 0 });
|
||||
expect(run(tiedInternal.aiTryItem()).used).toBe(true);
|
||||
expect(tied.status).toBe(0);
|
||||
});
|
||||
|
||||
it("HealItem's random gate: above a quarter but at or below half HP, under CONTEXT_USE", () => {
|
||||
const trainer = makeTrainer({ items: [C.POTION, 0] });
|
||||
const enemy = () => enemyMon(30, 30, 100); // 30% of max: below half, above a quarter
|
||||
for (const [roll, expected] of [[0, true], [254, false]] as const) {
|
||||
trainer.items = [C.POTION, 0];
|
||||
const mon = enemy();
|
||||
const { internal } = rig(trainer, mon, { random: () => roll });
|
||||
expect(run(internal.aiTryItem()).used).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("HealItem's UNKNOWN_USE path (dead in this game, but ported)", () => {
|
||||
const trainer = makeTrainer({ items: [C.POTION, 0], switchFlags: UNKNOWN_USE });
|
||||
// At or below a quarter: a roll below 50/255 declines; otherwise uses.
|
||||
for (const [roll, expected] of [[0, false], [254, true]] as const) {
|
||||
const mon = enemyMon(30, 10, 100);
|
||||
const { internal } = rig(trainer, mon, { random: () => roll });
|
||||
expect(run(internal.aiTryItem()).used).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("Status's ALWAYS_USE and plain-random paths (dead in this game, but ported)", () => {
|
||||
const always = makeTrainer({ items: [C.FULL_HEAL, 0], switchFlags: ALWAYS_USE });
|
||||
const alwaysMon = enemyMon(30, 100, 100, 1 << C.PAR);
|
||||
const { internal: alwaysInternal } = rig(always, alwaysMon);
|
||||
expect(run(alwaysInternal.aiTryItem()).used).toBe(true);
|
||||
|
||||
const plain = makeTrainer({ items: [C.FULL_HEAL, 0], switchFlags: 0 });
|
||||
for (const [roll, expected] of [[0, true], [254, false]] as const) {
|
||||
plain.items = [C.FULL_HEAL, 0];
|
||||
const mon = enemyMon(30, 100, 100, 1 << C.PAR);
|
||||
const { internal } = rig(plain, mon, { random: () => roll });
|
||||
expect(run(internal.aiTryItem()).used).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("XItem is more eager on the enemy's first turn out than later", () => {
|
||||
const trainer = makeTrainer({ items: [C.X_SPEED, 0], switchFlags: CONTEXT_USE });
|
||||
// First turn out (turnsTaken 0): the first random draw declines below
|
||||
// 128 (`cp 128; jp c, DontUse`); CONTEXT_USE then needs no second draw.
|
||||
{
|
||||
const mon = enemyMon(30, 100, 100);
|
||||
const { battle, internal } = rig(trainer, mon, { random: () => 254 });
|
||||
battle.enemy.turnsTaken = 0;
|
||||
expect(run(internal.aiTryItem()).used).toBe(true);
|
||||
}
|
||||
{
|
||||
trainer.items = [C.X_SPEED, 0];
|
||||
const mon = enemyMon(30, 100, 100);
|
||||
const { battle, internal } = rig(trainer, mon, { random: () => 0 });
|
||||
battle.enemy.turnsTaken = 0;
|
||||
expect(run(internal.aiTryItem()).used).toBe(false);
|
||||
}
|
||||
// Not the first turn out: ALWAYS_USE_F is required (CONTEXT_USE alone
|
||||
// is not enough), so this trainer never uses it again this way.
|
||||
{
|
||||
trainer.items = [C.X_SPEED, 0];
|
||||
const mon = enemyMon(30, 100, 100);
|
||||
const { battle, internal } = rig(trainer, mon, { random: () => 254 });
|
||||
battle.enemy.turnsTaken = 1;
|
||||
expect(run(internal.aiTryItem()).used).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears Bide, Fury Cutter, Protect, Rage and the last counter move only when an item is used", () => {
|
||||
const trainer = makeTrainer({ items: [C.POTION, 0] });
|
||||
const enemy = enemyMon(30, 10, 100);
|
||||
const { battle, internal } = rig(trainer, enemy);
|
||||
battle.enemy.set(3, C.SUBSTATUS_BIDE);
|
||||
battle.enemy.set(4, C.SUBSTATUS_RAGE);
|
||||
battle.enemy.furyCutterCount = 3;
|
||||
battle.enemy.protectCount = 2;
|
||||
battle.enemy.rageCounter = 4;
|
||||
battle.enemy.lastCounterMove = 33;
|
||||
expect(run(internal.aiTryItem()).used).toBe(true);
|
||||
expect(battle.enemyGoesFirst).toBe(1);
|
||||
expect(battle.enemy.has(3, C.SUBSTATUS_BIDE)).toBe(false);
|
||||
expect(battle.enemy.has(4, C.SUBSTATUS_RAGE)).toBe(false);
|
||||
expect(battle.enemy.furyCutterCount).toBe(0);
|
||||
expect(battle.enemy.protectCount).toBe(0);
|
||||
expect(battle.enemy.rageCounter).toBe(0);
|
||||
expect(battle.enemy.lastCounterMove).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves those substatuses untouched when nothing is used", () => {
|
||||
const trainer = makeTrainer({ items: [0, 0] });
|
||||
const enemy = enemyMon(30, 10, 100);
|
||||
const { battle, internal } = rig(trainer, enemy);
|
||||
battle.enemy.set(3, C.SUBSTATUS_BIDE);
|
||||
battle.enemy.furyCutterCount = 3;
|
||||
expect(run(internal.aiTryItem()).used).toBe(false);
|
||||
expect(battle.enemy.has(3, C.SUBSTATUS_BIDE)).toBe(true);
|
||||
expect(battle.enemy.furyCutterCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -88,11 +88,17 @@ const edges = (tile: number): readonly Direction[] =>
|
||||
const isWarpHazard = (tile: number) =>
|
||||
(tile & 0xf0) === 0x60 || (tile & 0xf0) === 0x70;
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps. */
|
||||
/** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses
|
||||
* freely, unlike currents, waterfalls and whirlpools. */
|
||||
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps.
|
||||
* `water` also crosses open water, for a player who can Surf. */
|
||||
export function findPath(
|
||||
world: GameWorld,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
{ water = false } = {},
|
||||
): Direction[] {
|
||||
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
||||
const visited = new Set<string>();
|
||||
@@ -108,7 +114,8 @@ export function findPath(
|
||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
|
||||
continue;
|
||||
const tile = world.assets.collision(world.map, x, y);
|
||||
if (world.assets.permission(tile) !== 0) continue;
|
||||
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile)))
|
||||
continue;
|
||||
if (
|
||||
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
|
||||
edges(tile).includes(OPPOSITE[direction])
|
||||
|
||||
Reference in new issue
Block a user