Open Kanto: the S.S. Aqua, the Kanto gyms, the Magnet Train, Mt. Silver and Red (Closes #33)
All 146 Kanto maps, Route 22, Route 28 and Silver Cave are open. The Magnet Train, Snorlax and the Poké Flute channel, the Diploma, the Trainer House, Bill's grandpa, Oak's Pokédex rating in Pallet and Goldenrod's postgame move tutor are ported; big objects cover 2x2 tiles. The Magnet Train and the Diploma are compared with the cartridge frame by frame. The playthrough continues from the Hall of Fame through the S.S. Aqua, the eight Kanto badges, Oak opening Mt. Silver and Red's defeat and credits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f05fd8ebee
commit
d3be732118
22 files changed
+1877
-31
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: all of Johto and the Pokémon League, from the opening to the Hall of Fame and the credits.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
**Current milestone: the whole story, from the opening through Johto, the Pokémon League and Kanto to the battle against Red on Mt. Silver.** 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 and Kanto maps with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
|
||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||
|
||||
@@ -32,11 +32,13 @@ Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the C
|
||||
|
||||
With the eight badges, surf east from New Bark Town along Route 27, climb Tohjo Falls with Waterfall and take Route 26 north to the Victory Road gate, where only a trainer with all eight badges may pass. Your rival waits at the end of Victory Road. At Indigo Plateau, the Elite Four's doors lock behind you: beat Will, Koga, Bruno, Karen and then Lance, and enter the Hall of Fame. Your team is recorded as the original does, with each Pokémon's entrance, your play time and Professor Oak's rating, then the credits roll; you then wake up in New Bark Town. The Hall of Fame's teams can be viewed from any Pokémon Center's PC.
|
||||
|
||||
After the League, Professor Elm gives you an S.S. Ticket: the S.S. Aqua sails from Olivine to Vermilion City, once you find a lost girl and send a lazy sailor back to work. All of Kanto is open, with its eight gyms. Saffron's Magnet Train rides to Goldenrod and back once the Power Plant's Machine Part is returned; Lavender's Radio Tower gives you the EXPN card, and its Poké Flute channel wakes the Snorlax blocking Diglett's Cave. Blue waits on Cinnabar and later in Viridian's gym, Bill's grandpa wants to see some Pokémon, Celadon's game designer gives a Diploma for a complete Pokédex, and Goldenrod's move tutor teaches Flamethrower, Thunderbolt or Ice Beam on Wednesdays and Saturdays. With all sixteen badges, Professor Oak opens Mt. Silver: Red waits at the top of Silver Cave, and the credits roll again after you beat him.
|
||||
|
||||
On Route 40, the Battle Tower takes a party of three different Pokémon holding different items into a Battle Room of the level you choose, against seven trainers in a row. Win them all for five of a random vitamin. Between battles you can save and quit, and the receptionist picks up where you left off.
|
||||
|
||||
The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported.
|
||||
|
||||
Kanto, Olivine's port and the S.S. Aqua, Mt. Silver, trade evolutions, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
Trade evolutions, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
+4
-2
@@ -51,7 +51,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| `game/scripts/screen.ts`, `game/rendering/tile-screen.ts`, `game/rom/font.ts` | `home/tilemap.asm`, `engine/gfx/load_font.asm` (`_LoadStandardFont`, `LoadFrame`) | Tile screens: a 20x18 tilemap with background palettes and sprites over it, built by a minigame or special from ROM graphics and answered with buttons. Blinking sprites follow bit 4 of the frame counter. Overlay screens (a menu box) leave the map visible around them. The standard font and the option's textbox frame load as 2bpp tiles. |
|
||||
| `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. |
|
||||
| `game/scripts/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. |
|
||||
| `game/scripts/goldenrod.ts`, `game/world/day-timers.ts` | `engine/events/name_rater.asm`, `haircut.asm`, `lucky_number.asm`, `move_tutor.asm` (`MoveTutor`, `CheckCanLearnMoveTutorMove`), `engine/items/tmhm.asm` (`ChooseMonToLearnTMHM`), `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. The move tutor's three moves come from TMHMMoves after the HMs; its party menu marks ABLE or NOT ABLE and returns to itself until a Pokémon learns the move or B. |
|
||||
| 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. |
|
||||
@@ -62,6 +62,8 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| `game/party/day-care.ts`, `game/scripts/day-care.ts`, Day-Care and Route 34 | `engine/events/daycare.asm`, `engine/pokemon/breeding.asm`, `engine/pokemon/move_mon.asm`, `engine/pokemon/breedmon_level_growth.asm`, `DayCareStep`, `_GiveOddEgg` | Deposit refusals, level growth/price, move learning on retrieval, compatibility and egg moves/DVs/species, the egg countdown/chance, collection and Odd Egg tables run from the user's ROM. Retrieval keeps the original experience truncation. The save holds both parents and the prepared egg, active/compatible bits, countdown and mother index, with flag consistency validation and absence migration. Day-Care map objects resolve their deposited species' ROM icons. Six controlled-RNG cartridge comparisons cover routine state and byte consumption; native tests cover map scripts and save/reload. Keeper UI/audio timing and fence OAM/palettes are not reference-validated. Hatching is the row above. |
|
||||
| `game/party/hall-of-fame.ts`, `game/scripts/hall-of-fame.ts`, `game/scripts/pc.ts` | `engine/events/halloffame.asm` (`GetHallOfFameParty`, `_HallOfFamePC`, `LoadHOFTeam`, `DisplayHOFMon`), `engine/menus/save.asm` (`AddHallOfFameEntry`), `engine/events/pokecenter_pc.asm` (`.ChooseWhichPCListToUse`, `HallOfFamePC`) | Up to 30 recorded teams, newest first, with the capped win count; saved and migrated by absence, separate from the League flag. Nothing records a team until the League is ported. The PC lists HALL OF FAME from the count; pages browse with A, Start and B as the original does, and keep its unreachable "HOF Master!" title. A cartridge comparison of 255 teams checks the tilemap, attributes, graphics and palette; the frontpic animation (`HOF_AnimateFrontpic`) is not presented. |
|
||||
| `game/scripts/league.ts`, the League, the Hall of Fame and the credits | `engine/events/halloffame.asm` (`HallOfFame`, `RedCredits`, `AnimateHallOfFame`, `AnimateHOFMonEntrance`, `HOF_SlideBackpic`, `HOF_SlideFrontpic`, `HOF_AnimatePlayerPic`), `engine/movie/credits.asm` (`Credits`, `ParseCredits`, `Credits_LoadBorderGFX`, `Credits_LYOverride`, `ConstructCreditsTilemap`, `GetCreditsPalette`), `engine/movie/init_hof_credits.asm`, `engine/events/prof_oaks_pc.asm` (`ProfOaksPCBoot`, `ProfOaksPCRating`, `Rate`, `FindOakRating`), `engine/gfx/player_gfx.asm` (`GetPlayerBackpic`, `HOF_LoadTrainerFrontpic`), `home/vblank.asm` (`VBlank_Credits`), `engine/overworld/scripting.asm` (`Script_halloffame`, `Script_credits`, `Script_wait`), `home/text.asm` (`PlaceEnemysName`) | The ceremony records the team and win, pauses the play time and shows each Pokémon's slides and page, the player's card and Oak's rating; the credits run frame by frame (text by thirds, banner tiles by halves, palette VBlanks, border scroll), with each command group's measured spill frames; after the Hall of Fame the world continues at New Bark as Continue would, after Red at Mt. Silver. Checked against the cartridge by `tests/reference-credits.spec.ts` and `tests/reference-hall-of-fame-ceremony.spec.ts`. |
|
||||
| `game/scripts/kanto.ts` | `engine/events/specials.asm` (`SnorlaxAwake`, `TrainerHouse`), `engine/events/haircut.asm` (`BillsGrandfather`), `engine/events/diploma.asm` (`PlaceDiplomaOnScreen`, `Diploma`, `PrintDiploma`), `engine/gfx/cgb_layouts.asm` (`_CGB_Diploma`) | Snorlax wakes to the Poké Flute channel (wMapMusic) from `.ProximityCoords`; the Trainer House always fights CAL, as without a Mystery Gift partner; Bill's grandpa reads the party Pokémon shown. The Diploma is drawn from DiplomaGFX and its tilemap with the player's name in `PalPacket_Diploma`'s palette; printing it finds no printer. Checked by `tests/reference-kanto.spec.ts`. |
|
||||
| `game/scripts/magnet-train.ts` | `engine/events/magnet_train.asm` (`MagnetTrain`, `MagnetTrain_Jumptable`, `MagnetTrain_UpdateLYOverrides`, `MagnetTrain_LoadGFX_PlayMusic`, `DrawMagnetTrain`, `MagnetTrainBGTiles`, `MagnetTrainTilemap`), `engine/overworld/scripting.asm` (`Script_newloadmap`'s `MAPSETUP_TRAIN`) | The ride frame by frame both ways: the player's sprite (Chris or Kris) running its animation with the last frame flipped, the bushes' and train's row scrolls, the station's palettes with an extra palette frame and eight frames of teardown. Checked against the cartridge by `tests/reference-magnet-train.spec.ts`. |
|
||||
| `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the thirteen other `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched, preserving the original's explicitly documented bug even now that deposits run. The original searches the current `sBox` and skips its numbered `sBoxN` copy; the port searches each logical box once. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. |
|
||||
| `game/scripts/battle-tower.ts`, the Battle Tower | `engine/events/battle_tower/battle_tower.asm` (`BattleTowerAction`, `RunBattleTowerTrainer`, `ReadBTTrainerParty`, `CopyBTTrainer_FromBT_OT_TowBT_OTTemp`, `BattleTower_GiveReward`, `BattleTower_RandomlyChooseReward`, `LoadOpponentTrainerAndPokemonWithOTSprite`, `CheckForBattleTowerRules`), `engine/events/battle_tower/load_trainer.asm`, `engine/events/battle_tower/rules.asm`, `engine/events/battle_tower/trainer_text.asm`, `mobile/mobile_46.asm` (`_BattleTowerRoomMenu`, `BattleTower_LevelCheck`, `BattleTower_UbersCheck`), `mobile/mobile_5f.asm` (`Menu_ChallengeExplanationCancel`), `engine/link/link.asm` (`TryQuickSave`), `engine/battle/core.asm`, `engine/battle/effect_commands.asm`, `engine/battle/misc.asm` (`DoBadgeTypeBoosts`) | The Battle Tower's SRAM and the work RAM its scripts share are saved as `battleTower`. The trainer sampler keeps its index as the running byte after a repeated trainer; the Pokémon sampler starts again from `hRandomAdd`; both fold `Random`'s `hRandomAdd` into a byte below a power-of-two mask, which the port draws from its random source. `BattleTowerText` reads the class's gender (1.1 behaviour) and picks the greeting from `hRandomAdd` (0-24 male, 0-14 female), keeping it for the end texts. Each battle start counts the battle and marks the challenge in progress, as `CopyBTTrainer_FromBT_OT_TowBT_OTTemp` does; `LoadPokemonData` then returns the party, Pokédex and Unown Dex to the quick save's. The level menu shows the levels as a list; its CANCEL/B question and the mobile code's YES/NO flow are modelled from their outcomes (YES leaves with $a, NO picks a level again). `TryQuickSave` asks the overwrite question for the player's own save and shows the saving texts; the port keeps the place and Pokémon data `LoadPokemonData` and `Reset` return to. The native game saves itself, so the original's "left without saving" state (a reset during a challenge) cannot arise: a reload replays the room's script. `Reset` returns the player to the quick save's place with the 1F scene set to check the challenge; the original's rollback of money gained by Pay Day is not reproduced. In battle, `wInBattleTowerBattle` turns off obedience, `DoBadgeTypeBoosts`, `BadgeStatBoosts`, experience, the pack (BattleMenu_Pack says items can't be used), AI items and prize money, uses Falkner's AI attributes, keeps Sleep to `%011` and skips the AI's random failure for Sleep, Poison, stat-lowering and Paralyze moves; a loss shows BattleTowerText and does not white out. `DoBadgeTypeBoosts` was added to every battle (it was missing). BATTLETOWERACTION_LEVEL_CHECK and _UBERS_CHECK and the mobile actions are reached only from unreferenced scripts and stay refused. `tests/battle-tower.test.ts`, `tests/battle-tower-engine.test.ts` and `tests/reference-badges.spec.ts` cover them. |
|
||||
| `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. |
|
||||
@@ -415,7 +417,7 @@ message placement and palettes against the cartridge. Start prints; A/B
|
||||
close the letter. `scripts/printer.ts` shares the absent-printer error with
|
||||
Bill's PC, Pokédex PRNT and Photo Studio; B cancels and A leaves it open.
|
||||
Print previews, printer music and alternating link-check status are omitted;
|
||||
Unown stamps and the Kanto diploma remain unreachable. A scripted
|
||||
Unown stamps remain unreachable; the Diploma prints to the same absent printer. A scripted
|
||||
`givepoke` with MAIL (`GivePokeMail`) is refused.
|
||||
|
||||
`Pack_InterpretJoypad`'s Select runs `SwitchItemsInBag` on the bag
|
||||
|
||||
+14
-4
@@ -481,7 +481,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- The radio (`game/scripts/radio.ts`): `PlayRadioShow` and every station frame by frame: Oak's Pokémon Talk (routes, Pokémon, adverbs and adjectives, the Pokémon Channel jingle), the Pokédex Show, Pokémon Music and Let's All Sing (march or lullaby by weekday), the Lucky Channel (with `CheckLuckyNumberShowFlag`), Places and People, Buena's Password (from 6 PM, the day's password; midnight and off-air texts), Team Rocket's broadcast during the takeover, and the Unown, Poké Flute and Lake of Rage stations' music. Lines show for 100 frames and scroll as `PrintRadioLine` and `RadioScroll` do. Station songs become the map music and keep playing after the radio is left, as in the original (`ExitPokegearRadio_HandleMusic`).
|
||||
- The Pokégear's radio card runs every frame: up and down turn the knob by two, `RadioChannels` picks the station the place can hear (Johto, the Ruins, the Lake of Rage area, Kanto with the EXPN card). House radios (`Radio1Script`, `Radio2Script`) run `MapRadio`: the station's name in quotes for 100 frames, then the station until A or B.
|
||||
- Buena's show at the Radio Tower: `AskRememberPassword`, `BuenasPassword` (the category's three words, B disabled) and `BuenaPrize` (prizes and points beside the Blue Card balance, confirm, pay, receive). Tests: `tests/phone.test.ts` (rematches, a random call, no call without a caller, the daily reset, swarms, each station's lines and music, the radio card, a house radio, Buena's password and prizes) and `tests/radio-browser.spec.ts` (the radio card in the browser).
|
||||
- Not ported: the radio card's tuning dial graphics (the frequency is shown as text), holding up or down to turn the knob continuously, the Poké Flute channel waking Snorlax (Kanto is closed), and calls that use the mobile adapter.
|
||||
- Not ported: the radio card's tuning dial graphics (the frequency is shown as text), holding up or down to turn the knob continuously, and calls that use the mobile adapter. The Poké Flute channel wakes Vermilion's Snorlax (board #33, increment B).
|
||||
|
||||
## Implemented: save slots (board #37)
|
||||
|
||||
@@ -495,7 +495,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
## Implemented: MAIL stationery and printer cancellation
|
||||
|
||||
- `ReadAnyMail` now draws all ten stationery types from the ROM, including their color planes, palettes, message/author positions and the mirrored Portrait MAIL picture (Unown A). A/B return; Start opens printing, and B cancels back to the unchanged letter. The accessible message and author remain available with the canvas.
|
||||
- Nonempty Bill's PC boxes, Pokédex PRNT, MAIL and the Photo Studio share `SendScreenToPrinter`'s absent-printer result (`PRINTER_ERROR_2`). A does not dismiss the error; B cancels. Empty boxes still show their original refusal. The print preview, printer music and alternating link-check status are not presented. Unown stamps and the diploma remain unreachable with the unported Unown mode and Kanto maps.
|
||||
- Nonempty Bill's PC boxes, Pokédex PRNT, MAIL and the Photo Studio share `SendScreenToPrinter`'s absent-printer result (`PRINTER_ERROR_2`). A does not dismiss the error; B cancels. Empty boxes still show their original refusal. The print preview, printer music and alternating link-check status are not presented. Unown stamps remain unreachable with the unported Unown mode; the Diploma prints to the same absent printer.
|
||||
- Controlled cartridge comparisons cover all ten stationery tilemaps, used graphics, message/author placement and palettes; the font is loaded separately from ROM. Chromium and mobile WebKit checks cover writing, reading, printing and returning to MAIL. Unit checks cover the four reachable print paths and unchanged stored data.
|
||||
|
||||
## Implemented: Day-Care and breeding (board #29, egg creation)
|
||||
@@ -539,11 +539,21 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- Tests: `tests/league.test.ts` (Route 27's first step into Kanto, the gate's badge check, the ceremony's records without Eggs, the paused play time, the 200-win cap and 30 teams, B on the credits, the text-box decoder, the renderer's wrap and row scroll). The playthrough gains five stages: back to Ice Path for HM07, home to New Bark, Route 27, Tohjo Falls' waterfall, Route 26 and Victory Road, Indigo Plateau and the Elite Four, Lance and the Hall of Fame (the driver now fights carefully from here: accuracy-weighed moves, Full Heals for poison, paralysis and burns, healing below two thirds). Test saves: `indigo-plateau`, `hall-of-fame-new-bark`, `hall-of-fame-ceremony`.
|
||||
- Not presented: `ANIM_MON_HOF` (pictures at rest, as in the PC), the credits' first 36 frames of setup are shown white without their exact frame count, the rating text appears whole rather than letter by letter. The Plateau's rival (after Mt. Moon) and Kanto are increments B and C.
|
||||
|
||||
## Implemented: Kanto, Mt. Silver and Red (board #33, increment B)
|
||||
|
||||
- Open: Olivine's and Vermilion's ports and the S.S. Aqua (Elm's S.S. Ticket, the lost girl, the lazy sailor and the captain), all 146 Kanto maps (Vermilion, Saffron and its stations, Cerulean, the Power Plant, Lavender and its Radio Tower, Rock Tunnel, Celadon, Fuchsia, Cinnabar and the Seafoam Gym, Pallet Town and Oak's lab, Viridian, Pewter, Diglett's Cave, Mt. Moon, Cycling Road and every route between them), Route 22, Route 28, Silver Cave and Red. Only the Pokémon Centers' upstairs (the link rooms and the unused beta floors) and the beta Safari Zone stay closed.
|
||||
- `MagnetTrain` (`game/scripts/magnet-train.ts`): the ride both ways, frame by frame (`MagnetTrain_Jumptable`, `MagnetTrain_UpdateLYOverrides`, the player's sprite animation flipped on its last frame, the scrolling bushes and train bands, the extra palette frame and eight frames of teardown); `newloadmap` with `MAPSETUP_TRAIN` arrives at the other station.
|
||||
- `SnorlaxAwake`: the Poké Flute channel left playing on the Pokégear's radio, beside Vermilion's Snorlax (`.ProximityCoords`). Big objects (`BIG_OBJECT`) cover 2×2 tiles for collisions and talking. `Diploma` and `PrintDiploma` (`PlaceDiplomaOnScreen`, with `PREDEFPAL_DIPLOMA`; no printer, B cancels). `TrainerHouse` fights its default trainer, CAL (no Mystery Gift partner). `BillsGrandfather` shows a party Pokémon. `ProfOaksPCBoot` lets Oak rate the Pokédex in Pallet before he opens Mt. Silver for sixteen badges.
|
||||
- `MoveTutor` (`game/scripts/goldenrod.ts`): Goldenrod's tutor on Wednesdays and Saturdays after the League, for 4000 coins, through `ChooseMonToLearnTMHM`'s ABLE/NOT ABLE party menu (an Egg plays SFX_WRONG, an incompatible Pokémon or one that knows the move returns to the menu) and `LearnMove`.
|
||||
- Reference: `tests/reference-magnet-train.spec.ts` runs the cartridge's `MagnetTrain` with interrupts after a boot and compares every frame (OAM, the hSCX bands, BG map and attributes, the icon's tiles and the palettes in CRAM) both ways, for Chris and Kris. `tests/reference-kanto.spec.ts` compares `PlaceDiplomaOnScreen` (tilemap, tiles and palette) for several names and frames.
|
||||
- Tests: `tests/kanto.test.ts` (Snorlax's coordinates, 2×2 block and flute battle; Bill's grandpa's Everstone; the Trainer House's CAL; the Magnet Train both ways; the Diploma and its printer; Oak opening Mt. Silver; the move tutor; the rival in Mt. Moon and at Indigo Plateau on Mondays but not Tuesdays). The playthrough continues with fourteen stages: the S.S. Aqua, the eight Kanto badges in the order Thunder, Marsh, Cascade (with the Power Plant's manager, the Machine Part and Misty's date), Boulder (after the power returns, Rock Tunnel, the EXPN card and Snorlax), Volcano, Soul (Blue on Cinnabar), Rainbow (Cycling Road) and Earth, Oak opening Mt. Silver, training outside Silver Cave, TM26 from Victory Road, and Red with the credits, ending at `SPAWN_MT_SILVER`. Test saves: `vermilion-city`, `saffron-city`, `celadon-city`, `mt-silver`, `red-beaten`.
|
||||
- Not presented: Mystery Gift's Trainer House opponents and printing; the Diploma's B prompt cursor (the page has no ▼).
|
||||
|
||||
## Native battles: current state
|
||||
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||
|
||||
This is still not a complete new game. The Pokédex's area view, search and other modes, Kanto and the postgame remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; Kanto's maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
||||
This is still not a complete new game. The Pokédex's area view, search and other modes remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations. Save slots, online Link, friends and real-world steps come later.
|
||||
|
||||
## 1. Complete the overworld foundation
|
||||
|
||||
@@ -635,7 +645,7 @@ Acceptance: finish the opening quest, including encounters and the rival battle,
|
||||
- [x] Breeding, eggs and the rest of PC storage (board #29)
|
||||
- [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame
|
||||
- [x] Johto beyond Route 30: every city, gym, Rocket event, puzzle and minigame to the Rising Badge, Tin Tower, Whirl Islands and the Battle Tower
|
||||
- [ ] The League, Kanto and the final battle
|
||||
- [x] The League, Kanto and the final battle (board #33)
|
||||
- [x] Music and sound through Web Audio (board #34; move animations and minigame sounds remain)
|
||||
- [ ] Acceptance: main-game and postgame playthroughs, battle regression vectors, long sessions and save recovery
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const BG_TILES = 32;
|
||||
* 20x18 tilemap, a row's own 32 tiles (`rowScroll`), or `scroll.fill`. */
|
||||
function backgroundCell(screen: TileScreen, row: number, col: number) {
|
||||
const own = screen.rowScroll?.find((entry) => entry.row === row);
|
||||
if (own?.tiles) return { tile: own.tiles[col], palette: screen.attrs[row * SCREEN_WIDTH] };
|
||||
if (own?.tiles) return { tile: own.tiles[col], palette: own.attrs?.[col] ?? screen.attrs[row * SCREEN_WIDTH] };
|
||||
if (row < SCREEN_HEIGHT && col < SCREEN_WIDTH) {
|
||||
const cell = row * SCREEN_WIDTH + col;
|
||||
return { tile: screen.map[cell], palette: screen.attrs[cell] };
|
||||
|
||||
@@ -1509,6 +1509,25 @@ export const KANTO = {
|
||||
"HOF_AnimatePlayerPic.PlayTime": 0x868ed,
|
||||
"InitDisplayForHallOfFame.SavingRecordText": 0x4e8bd,
|
||||
ProfOaksPCBoot: 0x265ee,
|
||||
SnorlaxAwake: 0xc43d,
|
||||
MoveTutor: 0x4925b,
|
||||
"SnorlaxAwake.ProximityCoords": 0xc467,
|
||||
TrainerHouse: 0xc4b9,
|
||||
BillsGrandfather: 0x73f7,
|
||||
MagnetTrain: 0x8cc04,
|
||||
MagnetTrainBGTiles: 0x8cd82,
|
||||
MagnetTrainTilemap: 0x8ceff,
|
||||
"SpriteAnimOAMData.OAMData_MagnetTrainRed": 0x8dd9b,
|
||||
"SpriteAnimOAMData.OAMData_MagnetTrainBlue": 0x8e213,
|
||||
ChrisSpriteGFX: 0xc0000,
|
||||
Diploma: 0xc49f,
|
||||
PrintDiploma: 0xc4ac,
|
||||
DiplomaGFX: 0x1dd805,
|
||||
DiplomaPage1Tilemap: 0x1ddc4b,
|
||||
PalPacket_Diploma: 0x9cb6,
|
||||
"PlaceDiplomaOnScreen.Player": 0x1dd760,
|
||||
"PlaceDiplomaOnScreen.Certification": 0x1dd768,
|
||||
KrisSpriteGFX: 0xc7a40,
|
||||
CreditsBorderGFX: 0x109c24,
|
||||
CopyrightGFX: 0xe4000,
|
||||
TheEndGFX: 0xcbd2e,
|
||||
@@ -1969,6 +1988,152 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"16:6": "Karen’s room",
|
||||
"16:7": "Lance’s room",
|
||||
"16:8": "Hall of Fame",
|
||||
"3:74": "Silver Cave",
|
||||
"3:75": "Silver Cave",
|
||||
"3:76": "Silver Cave",
|
||||
"3:77": "Silver Cave item rooms",
|
||||
"3:84": "Diglett’s Cave",
|
||||
"3:85": "Mt. Moon",
|
||||
"3:86": "Underground Path",
|
||||
"3:87": "Rock Tunnel 1F",
|
||||
"3:88": "Rock Tunnel B1F",
|
||||
"6:1": "Pokémon Center",
|
||||
"6:3": "Route 19 Fuchsia gate",
|
||||
"6:4": "Seafoam Gym",
|
||||
"6:5": "Route 19",
|
||||
"6:6": "Route 20",
|
||||
"6:7": "Route 21",
|
||||
"6:8": "Cinnabar Island",
|
||||
"7:1": "Gym badge speech house",
|
||||
"7:2": "Cerulean police station",
|
||||
"7:3": "Trade speech house",
|
||||
"7:4": "Pokémon Center",
|
||||
"7:6": "Cerulean Gym",
|
||||
"7:7": "Cerulean Mart",
|
||||
"7:8": "Pokémon Center",
|
||||
"7:10": "Power Plant",
|
||||
"7:11": "Bill’s house",
|
||||
"7:12": "Route 4",
|
||||
"7:13": "Route 9",
|
||||
"7:14": "Route 10",
|
||||
"7:15": "Route 24",
|
||||
"7:16": "Route 25",
|
||||
"7:17": "Cerulean City",
|
||||
"12:1": "Route 6",
|
||||
"12:2": "Route 11",
|
||||
"12:3": "Vermilion City",
|
||||
"12:4": "Fishing speech house",
|
||||
"12:5": "Pokémon Center",
|
||||
"12:7": "Pokémon Fan Club",
|
||||
"12:8": "Magnet Train speech house",
|
||||
"12:9": "Vermilion Mart",
|
||||
"12:10": "Diglett’s Cave speech house",
|
||||
"12:11": "Vermilion Gym",
|
||||
"12:12": "Route 6 Saffron gate",
|
||||
"12:13": "Underground Path entrance",
|
||||
"13:1": "Route 1",
|
||||
"13:2": "Pallet Town",
|
||||
"13:3": "Red’s house 1F",
|
||||
"13:4": "Red’s house 2F",
|
||||
"13:5": "Blue’s house",
|
||||
"13:6": "Oak’s lab",
|
||||
"14:1": "Route 3",
|
||||
"14:2": "Pewter City",
|
||||
"14:3": "Nidoran speech house",
|
||||
"14:4": "Pewter Gym",
|
||||
"14:5": "Pewter Mart",
|
||||
"14:6": "Pokémon Center",
|
||||
"14:8": "Snooze speech house",
|
||||
"15:1": "Olivine port",
|
||||
"15:2": "Vermilion port",
|
||||
"15:3": "Fast Ship 1F",
|
||||
"15:4": "Fast Ship cabins NNW, NNE and NE",
|
||||
"15:5": "Fast Ship cabins SW, SSW and NW",
|
||||
"15:6": "Fast Ship cabins SE, SSE and the captain’s",
|
||||
"15:7": "Fast Ship B1F",
|
||||
"15:8": "Olivine port passage",
|
||||
"15:9": "Vermilion port passage",
|
||||
"15:10": "Mt. Moon Square",
|
||||
"15:11": "Mt. Moon gift shop",
|
||||
"17:1": "Route 13",
|
||||
"17:2": "Route 14",
|
||||
"17:3": "Route 15",
|
||||
"17:4": "Route 18",
|
||||
"17:5": "Fuchsia City",
|
||||
"17:6": "Fuchsia Mart",
|
||||
"17:7": "Safari Zone office",
|
||||
"17:8": "Fuchsia Gym",
|
||||
"17:9": "Bill’s sister’s house",
|
||||
"17:10": "Pokémon Center",
|
||||
"17:12": "Safari Zone warden’s home",
|
||||
"17:13": "Route 15 Fuchsia gate",
|
||||
"18:1": "Route 8",
|
||||
"18:2": "Route 12",
|
||||
"18:3": "Route 10",
|
||||
"18:4": "Lavender Town",
|
||||
"18:5": "Pokémon Center",
|
||||
"18:7": "Mr. Fuji’s house",
|
||||
"18:8": "Lavender speech house",
|
||||
"18:9": "Name Rater’s house",
|
||||
"18:10": "Lavender Mart",
|
||||
"18:11": "Soul House",
|
||||
"18:12": "Lavender Radio Tower",
|
||||
"18:13": "Route 8 Saffron gate",
|
||||
"18:14": "Super Rod house",
|
||||
"19:1": "Route 28",
|
||||
"19:2": "Silver Cave outside",
|
||||
"19:3": "Pokémon Center",
|
||||
"19:4": "Steel Wing house",
|
||||
"21:1": "Route 7",
|
||||
"21:2": "Route 16",
|
||||
"21:3": "Route 17",
|
||||
"21:4": "Celadon City",
|
||||
"21:5": "Celadon Dept. Store 1F",
|
||||
"21:6": "Celadon Dept. Store 2F",
|
||||
"21:7": "Celadon Dept. Store 3F",
|
||||
"21:8": "Celadon Dept. Store 4F",
|
||||
"21:9": "Celadon Dept. Store 5F",
|
||||
"21:10": "Celadon Dept. Store 6F",
|
||||
"21:11": "Celadon Dept. Store elevator",
|
||||
"21:12": "Celadon Mansion 1F",
|
||||
"21:13": "Celadon Mansion 2F",
|
||||
"21:14": "Celadon Mansion 3F",
|
||||
"21:15": "Celadon Mansion roof",
|
||||
"21:16": "Celadon Mansion roof house",
|
||||
"21:17": "Pokémon Center",
|
||||
"21:19": "Celadon Game Corner",
|
||||
"21:20": "Game Corner prize room",
|
||||
"21:21": "Celadon Gym",
|
||||
"21:22": "Celadon Café",
|
||||
"21:23": "Fuchsia speech house",
|
||||
"21:24": "Route 16 gate",
|
||||
"21:25": "Route 7 Saffron gate",
|
||||
"21:26": "Route 17–18 gate",
|
||||
"23:1": "Route 2",
|
||||
"23:2": "Route 22",
|
||||
"23:3": "Viridian City",
|
||||
"23:4": "Viridian Gym",
|
||||
"23:5": "Nickname speech house",
|
||||
"23:6": "Trainer House 1F",
|
||||
"23:7": "Trainer House B1F",
|
||||
"23:8": "Viridian Mart",
|
||||
"23:9": "Pokémon Center",
|
||||
"23:11": "Nugget house",
|
||||
"23:12": "Route 2 gate",
|
||||
"25:1": "Route 5",
|
||||
"25:2": "Saffron City",
|
||||
"25:3": "Fighting Dojo",
|
||||
"25:4": "Saffron Gym",
|
||||
"25:5": "Saffron Mart",
|
||||
"25:6": "Pokémon Center",
|
||||
"25:8": "Mr. Psychic’s house",
|
||||
"25:9": "Magnet Train station",
|
||||
"25:10": "Silph Co. 1F",
|
||||
"25:11": "Copycat’s house 1F",
|
||||
"25:12": "Copycat’s house 2F",
|
||||
"25:13": "Underground Path entrance",
|
||||
"25:14": "Route 5 Saffron gate",
|
||||
"25:15": "Cleanse Tag house",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
|
||||
import { BATTLE, JOHTO, MENU, RAM, SYMBOLS } from "../rom/profile";
|
||||
import { changedHappiness } from "../battle/battle";
|
||||
import { readString } from "./text";
|
||||
import { NICKNAME_PATTERN } from "../party/pokemon";
|
||||
import { EGG } from "../rom/pokemon";
|
||||
import { EGG, NUM_TMS_HMS } from "../rom/pokemon";
|
||||
import { C } from "../battle/constants";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { PartyEvolution } from "./evolution";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
import {
|
||||
checkCountdown,
|
||||
@@ -220,3 +223,75 @@ export function* checkForLuckyNumberWinners(
|
||||
yield* context.flush();
|
||||
return place;
|
||||
}
|
||||
|
||||
/** MoveTutor (engine/events/move_tutor.asm): the tutor's move for
|
||||
* wScriptVar's MOVETUTOR_* (the TMHMMoves entries after the HMs that
|
||||
* MT01_MOVE to MT03_MOVE name), then ChooseMonToLearnTMHM's party menu with
|
||||
* ABLE or NOT ABLE until a Pokémon learns it (FALSE) or B (-1). An Egg
|
||||
* plays SFX_WRONG and the menu stays; an incompatible Pokémon, one that
|
||||
* knows the move or declines it returns to the menu. */
|
||||
export function* moveTutor(context: SpecialContext, tutor: number): Gen<number> {
|
||||
const { reader: r, data, state } = context,
|
||||
party = state.party;
|
||||
if (tutor < 1 || tutor > 3) throw new Error("Unknown move tutor move.");
|
||||
const move = r.byte(SYMBOLS.tmhmMoves + NUM_TMS_HMS + tutor - 1),
|
||||
name = data.moveName(move);
|
||||
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer2] = name;
|
||||
const strings = MENU.PartyMenuStrings + C.PARTYMENUACTION_TEACH_TMHM * 2;
|
||||
const prompt = readString(r, r.near(strings, r.bank(strings)), context.text());
|
||||
let pages = context.take();
|
||||
for (;;) {
|
||||
const slot = yield {
|
||||
kind: "menu",
|
||||
pages: [...pages, prompt],
|
||||
items: party.map((mon) => mon.nickname),
|
||||
// PlacePartyMonTMHMCompatibility: nothing for Eggs.
|
||||
details: party.map((mon) =>
|
||||
mon.egg
|
||||
? ""
|
||||
: readString(
|
||||
r,
|
||||
data.canLearnTMHM(mon.species, move)
|
||||
? MENU["PlacePartyMonTMHMCompatibility.string_able"]
|
||||
: MENU["PlacePartyMonTMHMCompatibility.string_not_able"],
|
||||
context.text(),
|
||||
),
|
||||
),
|
||||
selected: 0,
|
||||
};
|
||||
pages = [];
|
||||
if (typeof slot !== "number" || !Number.isInteger(slot) || slot >= party.length)
|
||||
throw new Error("Invalid party choice.");
|
||||
if (slot < 0) return 0xff;
|
||||
const mon = party[slot];
|
||||
if (mon.egg) {
|
||||
context.playSFX(SOUND.SFX_WRONG);
|
||||
yield* context.waitSFX();
|
||||
continue;
|
||||
}
|
||||
// CheckCanLearnMoveTutorMove: GetNickname into wStringBuffer1.
|
||||
context.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
context.strings[RAM.stringBuffer2] = name;
|
||||
if (!data.canLearnTMHM(mon.species, move)) {
|
||||
context.write(MENU.TMHMNotCompatibleText);
|
||||
context.playSFX(SOUND.SFX_WRONG);
|
||||
yield* context.flush();
|
||||
continue;
|
||||
}
|
||||
if (mon.moves.includes(move)) {
|
||||
context.write(MENU["KnowsMove.KnowsMoveText"]);
|
||||
yield* context.flush();
|
||||
continue;
|
||||
}
|
||||
const evolution = new PartyEvolution(state, {
|
||||
reader: r,
|
||||
data,
|
||||
timeOfDay: context.timeOfDay,
|
||||
text: context.text,
|
||||
});
|
||||
evolution.strings[RAM.stringBuffer1] = evolution.strings[RAM.stringBuffer2] = name;
|
||||
if (!(yield* evolution.learnMove(slot, move))) continue;
|
||||
changeHappiness(context, slot, C.HAPPINESS_LEARNMOVE);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,8 @@ import { unownPuzzle } from "./unown-puzzle";
|
||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||
import { slotMachine } from "./slot-machine";
|
||||
import { hallOfFame, profOaksPCBoot, redCredits, type CeremonyContext } from "./league";
|
||||
import { billsGrandfather, diploma, printDiploma, snorlaxAwake, trainerHouse } from "./kanto";
|
||||
import { magnetTrain } from "./magnet-train";
|
||||
import type { GameTime } from "../world/game-time";
|
||||
import { cardFlip } from "./card-flip";
|
||||
import { giveDratini, moveDeletion } from "./blackthorn";
|
||||
@@ -121,6 +123,7 @@ import {
|
||||
checkLuckyNumberShowFlag,
|
||||
firstPokemonHappiness,
|
||||
haircut,
|
||||
moveTutor,
|
||||
nameRater,
|
||||
printTodaysLuckyNumber,
|
||||
resetLuckyNumberShowFlag,
|
||||
@@ -811,6 +814,12 @@ export interface ScriptHost {
|
||||
initCallReceiveDelay?(): void;
|
||||
/** GetWorldMapLocation for any map. */
|
||||
landmarkOf?(mapId: string): number;
|
||||
/** SCGB_MAPPALS's palettes for the current map with wEnvironment set to
|
||||
* TOWN (MagnetTrain_Jumptable_FirstRunThrough). */
|
||||
townPalettes?(): { bg: Palette[]; obj: Palette[] };
|
||||
/** GetWarpDestCoords for the warp on the player's tile, for
|
||||
* MAPSETUP_TRAIN's EnterMapWarp after warpcheck. */
|
||||
warpHere?(): { map: string; x: number; y: number } | undefined;
|
||||
/** The play time, and GAME_TIMER_COUNTING_F (halloffame clears it). */
|
||||
gameTime?(): GameTime;
|
||||
gameTimer?(counting: boolean): void;
|
||||
@@ -1665,11 +1674,14 @@ export class ScriptRun {
|
||||
break;
|
||||
case 0x64: {
|
||||
// Null pointers request the battle engine's default end text.
|
||||
// PrintWinLossText reads the loss text only after a battle the
|
||||
// player could lose (BATTLETYPE_CANLOSE); scripts that cannot
|
||||
// lose may store -1 (Route 24's grunt), which is never read.
|
||||
const bank = r.bank(p),
|
||||
won = word(),
|
||||
lost = word();
|
||||
winText = won ? r.address(bank, won) : undefined;
|
||||
lossText = lost ? r.address(bank, lost) : undefined;
|
||||
lossText = lost && lost < 0x8000 ? r.address(bank, lost) : undefined;
|
||||
break;
|
||||
}
|
||||
case 0x5f: {
|
||||
@@ -1758,6 +1770,13 @@ export class ScriptRun {
|
||||
if (!warp)
|
||||
throw new UnsupportedScript(offset, opcode, " (no next warp)");
|
||||
destination = warp;
|
||||
} else if (method === OW.MAPSETUP_TRAIN) {
|
||||
// EnterMapWarp: the warp warpcheck found on the player's tile
|
||||
// (the Magnet Train's door), to the other station.
|
||||
const warp = this.host.warpHere?.();
|
||||
if (!warp)
|
||||
throw new UnsupportedScript(offset, opcode, " (no train warp)");
|
||||
destination = warp;
|
||||
} else if (
|
||||
method === OW.MAPSETUP_WARP ||
|
||||
method === OW.MAPSETUP_TELEPORT ||
|
||||
@@ -3133,6 +3152,7 @@ export class ScriptRun {
|
||||
return this.host.day();
|
||||
},
|
||||
weekday: () => this.host.clock().weekday,
|
||||
timeOfDay: () => this.host.timeOfDay(),
|
||||
write,
|
||||
take,
|
||||
flush: function* () {
|
||||
@@ -3812,6 +3832,39 @@ export class ScriptRun {
|
||||
case KANTO.ProfOaksPCBoot:
|
||||
yield* profOaksPCBoot(this.specialContext(pages, take));
|
||||
return value;
|
||||
case KANTO.SnorlaxAwake:
|
||||
return snorlaxAwake(r, this.host.sound?.engine.mapMusic ?? SOUND.MUSIC_NONE, this.host.position());
|
||||
case KANTO.MoveTutor:
|
||||
return yield* moveTutor(this.specialContext(pages, take), value);
|
||||
case KANTO.TrainerHouse:
|
||||
return trainerHouse();
|
||||
case KANTO.Diploma:
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
yield* diploma(this.specialContext(pages, take));
|
||||
return value;
|
||||
case KANTO.PrintDiploma:
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
yield* printDiploma(this.specialContext(pages, take));
|
||||
return value;
|
||||
case KANTO.MagnetTrain: {
|
||||
const tileset = this.host.tileset,
|
||||
palettes = this.host.townPalettes;
|
||||
if (!tileset || !palettes) return undefined;
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
yield* magnetTrain(
|
||||
{
|
||||
reader: r,
|
||||
female: this.host.player().gender === 1,
|
||||
tiles: () => tileset().graphics,
|
||||
palettes: () => palettes(),
|
||||
sound: this.host.sound,
|
||||
},
|
||||
Boolean(value),
|
||||
);
|
||||
return value;
|
||||
}
|
||||
case KANTO.BillsGrandfather:
|
||||
return yield* billsGrandfather(this.specialContext(pages, take));
|
||||
case JOHTO.SlotMachine:
|
||||
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
|
||||
return value;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { fontTiles, textTiles } from "../rom/font";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { EGG } from "../rom/pokemon";
|
||||
import { KANTO, RAM, TRAINER_CARD } from "../rom/profile";
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { printerDisconnected } from "./printer";
|
||||
import { blankScreen, romPalette, SCREEN_BUTTONS, SCREEN_WIDTH, type TileScreen } from "./screen";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
|
||||
/** SnorlaxAwake (engine/events/specials.asm): 1 when the Poké Flute
|
||||
* channel is wMapMusic (the Pokégear's radio, left playing) and the player
|
||||
* stands on one of .ProximityCoords beside Vermilion's Snorlax, else 0. */
|
||||
export function snorlaxAwake(
|
||||
r: RomReader,
|
||||
mapMusic: number,
|
||||
position: { x: number; y: number },
|
||||
): number {
|
||||
if (mapMusic !== SOUND.MUSIC_POKE_FLUTE_CHANNEL) return 0;
|
||||
for (let p = KANTO["SnorlaxAwake.ProximityCoords"]; r.byte(p) !== 0xff; p += 2)
|
||||
if (r.byte(p) === position.x && r.byte(p + 1) === position.y) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** TrainerHouse: sMysteryGiftTrainerHouseFlag, set only when a Mystery
|
||||
* Gift partner's trainer was received, which this port does not have:
|
||||
* the Trainer House fights its default trainer. */
|
||||
export const trainerHouse = () => 0;
|
||||
|
||||
/** BillsGrandfather (engine/events/haircut.asm): the Pokémon the player
|
||||
* shows from the party, as wScriptVar (EGG for an Egg), with its species
|
||||
* name in wStringBuffer1 and wStringBuffer3; 0 when cancelled. */
|
||||
export function* billsGrandfather(context: SpecialContext): Gen<number> {
|
||||
const slot = yield* context.selectMon();
|
||||
if (slot < 0) return 0;
|
||||
const mon = context.state.party[slot];
|
||||
const species = mon.egg ? EGG : mon.species;
|
||||
const name = context.data.speciesName(species);
|
||||
context.strings[RAM.stringBuffer1] = name;
|
||||
context.strings[RAM.stringBuffer3] = name;
|
||||
return species;
|
||||
}
|
||||
|
||||
/** PlaceDiplomaOnScreen (engine/events/diploma.asm): DiplomaGFX from
|
||||
* tile 0 under the standard font, DiplomaPage1Tilemap, "PLAYER" and the
|
||||
* player's name on row 5 and the certification from (2, 8) (<NEXT> two
|
||||
* rows down, '#' as POKé), in PREDEFPAL_DIPLOMA. */
|
||||
export function diplomaScreen(r: RomReader, name: string, frame: number): TileScreen {
|
||||
const tiles = fontTiles(r, frame);
|
||||
const gfx = decompress(r, KANTO.DiplomaGFX, 0x2000);
|
||||
tiles.set(gfx.subarray(0, Math.min(gfx.length, 0x80 * 16)));
|
||||
// _CGB_Diploma: CopyFourPalettes puts PalPacket_Diploma's first
|
||||
// predefined palette in BG palette 0, the only one the wiped
|
||||
// attributes use.
|
||||
const palette = romPalette(r, TRAINER_CARD.PredefPals + r.byte(KANTO.PalPacket_Diploma + 1) * 8);
|
||||
const screen = blankScreen(tiles, `diploma-${frame}`, [palette], `Diploma: ${name}`);
|
||||
screen.map = Array.from(r.slice(KANTO.DiplomaPage1Tilemap, SCREEN_WIDTH * 18));
|
||||
const place = (p: number, x: number, y: number) => {
|
||||
let column = x,
|
||||
line = y;
|
||||
for (let length = 0; length < 128; length++) {
|
||||
const code = r.byte(p++);
|
||||
if (code === 0x50) return;
|
||||
if (code === 0x4e) {
|
||||
line += 2;
|
||||
column = x;
|
||||
continue;
|
||||
}
|
||||
for (const tile of code === 0x54 ? [0x8f, 0x8e, 0x8a, 0xea] : [code])
|
||||
screen.map[line * SCREEN_WIDTH + column++] = tile;
|
||||
}
|
||||
throw new Error("Unterminated diploma string.");
|
||||
};
|
||||
place(KANTO["PlaceDiplomaOnScreen.Player"], 2, 5);
|
||||
textTiles(name).forEach((tile, i) => (screen.map[5 * SCREEN_WIDTH + 9 + i] = tile));
|
||||
place(KANTO["PlaceDiplomaOnScreen.Certification"], 2, 8);
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** Diploma: the diploma until A or B (WaitPressAorB_BlinkCursor, whose
|
||||
* cursor shows only over a ▼ already there, which this page has not). */
|
||||
export function* diploma(context: SpecialContext): Gen {
|
||||
const screen = diplomaScreen(context.reader, context.player().name, context.state.options.textboxFrame);
|
||||
for (;;) {
|
||||
const button = yield { kind: "screen", screen };
|
||||
if (typeof button !== "string" || !SCREEN_BUTTONS.includes(button)) throw new Error("Invalid diploma button.");
|
||||
if (button === "a" || button === "b") return;
|
||||
}
|
||||
}
|
||||
|
||||
/** PrintDiploma: the diploma on screen, then SendScreenToPrinter, which
|
||||
* finds no printer (PRINTER_ERROR_2) until B cancels. */
|
||||
export function* printDiploma(context: SpecialContext): Gen {
|
||||
yield* printerDisconnected(context.reader, context.state.options.textboxFrame);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { KANTO } from "../rom/profile";
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { Palette } from "../world/types";
|
||||
import { SCREEN_HEIGHT, SCREEN_WIDTH, type ScreenSprite, type TileScreen } from "./screen";
|
||||
import type { Gen } from "./special-context";
|
||||
|
||||
/** PAL_BG_* (constants/tileset_constants.asm). */
|
||||
const PAL_BG_GRAY = 0,
|
||||
PAL_BG_GREEN = 2,
|
||||
PAL_BG_YELLOW = 4;
|
||||
/** OAM attribute bits. */
|
||||
const OAM_PRIO = 0x80,
|
||||
OAM_YFLIP = 0x40,
|
||||
OAM_XFLIP = 0x20;
|
||||
/** The player icon's eight tiles follow the map's 256 in this port's
|
||||
* tile array (vTiles0 in the original). */
|
||||
const SPRITE_BASE = 0x100;
|
||||
/** MagnetTrain_Jumptable's entries. */
|
||||
const enum Step {
|
||||
InitPlayerSpriteAnim,
|
||||
Wait1,
|
||||
MoveTrain1,
|
||||
Wait2,
|
||||
MoveTrain2,
|
||||
Wait3,
|
||||
TrainArrived,
|
||||
}
|
||||
/** .Frameset_MagnetTrainRed/Blue: the walking icon's two OAMSETs
|
||||
* (vtile $00 and $04), the last one X-flipped, each held 8 frames. */
|
||||
const FRAMES: readonly { vtile: number; flags: number }[] = [
|
||||
{ vtile: 0, flags: 0 },
|
||||
{ vtile: 4, flags: 0 },
|
||||
{ vtile: 0, flags: 0 },
|
||||
{ vtile: 4, flags: OAM_XFLIP },
|
||||
];
|
||||
const FRAME_DURATION = 8;
|
||||
|
||||
/** What the Magnet Train needs from the world: the station's tileset
|
||||
* graphics (the train and bushes are its tiles) and SCGB_MAPPALS's
|
||||
* palettes with the environment set to TOWN for the time of day. */
|
||||
export interface MagnetTrainHost {
|
||||
reader: RomReader;
|
||||
female: boolean;
|
||||
tiles(): Uint8Array;
|
||||
palettes(): { bg: Palette[]; obj: Palette[] };
|
||||
sound?: GameSound;
|
||||
}
|
||||
|
||||
/** MagnetTrain (engine/events/magnet_train.asm) frame by frame: the
|
||||
* background drawn from MagnetTrainBGTiles and MagnetTrainTilemap, the
|
||||
* player's walking icon as a sprite animation, the bushes' bands scrolled
|
||||
* through wLYOverrides and the train's band moving to its hold position,
|
||||
* waiting, and leaving. */
|
||||
export class MagnetTrainMachine {
|
||||
readonly direction: number;
|
||||
private readonly holdPosition: number;
|
||||
private readonly finalPosition: number;
|
||||
private readonly playerX: number;
|
||||
private index = 0;
|
||||
private exit = false;
|
||||
private offset: number;
|
||||
private position: number;
|
||||
private waitCounter: number;
|
||||
private globalX = 0;
|
||||
private sprite?: { frame: number; duration: number };
|
||||
/** wLYOverridesBackup's bands as pushed: the bushes and the train. */
|
||||
private bands: { bushes: number; train: number };
|
||||
private palettesSet = false;
|
||||
/** The background map: 18 rows of 32 tiles and their palettes. */
|
||||
readonly bg: number[][];
|
||||
readonly attrs: number[][];
|
||||
oam: ScreenSprite[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly host: MagnetTrainHost,
|
||||
toGoldenrod: boolean,
|
||||
) {
|
||||
const r = host.reader;
|
||||
// MagnetTrain: forwards to Saffron, backwards to Goldenrod.
|
||||
this.direction = toGoldenrod ? -1 : 1;
|
||||
const initPosition = toGoldenrod ? -12 * 8 : 12 * 8;
|
||||
this.holdPosition = (toGoldenrod ? -8 * 8 : 8 * 8) & 0xff;
|
||||
this.finalPosition = (toGoldenrod ? 12 * 8 : -12 * 8) & 0xff;
|
||||
this.playerX = (toGoldenrod ? 11 * 8 + (11 * 8 + 4) : 11 * 8 - (11 * 8 + 4)) & 0xff;
|
||||
this.offset = this.position = this.waitCounter = initPosition & 0xff;
|
||||
this.bands = { bushes: this.position, train: this.position };
|
||||
// DrawMagnetTrain: each row repeats its MagnetTrainBGTiles pair across
|
||||
// the 32 columns; rows 6-9 then take MagnetTrainTilemap's 20 columns.
|
||||
this.bg = Array.from({ length: SCREEN_HEIGHT }, (_, row) => {
|
||||
const e = r.byte(KANTO.MagnetTrainBGTiles + row * 2),
|
||||
d = r.byte(KANTO.MagnetTrainBGTiles + row * 2 + 1);
|
||||
return Array.from({ length: 32 }, (_, col) => (col & 1 ? d : e));
|
||||
});
|
||||
for (let row = 0; row < 4; row++)
|
||||
for (let col = 0; col < SCREEN_WIDTH; col++)
|
||||
this.bg[6 + row][col] = r.byte(KANTO.MagnetTrainTilemap + row * SCREEN_WIDTH + col);
|
||||
// SetMagnetTrainPals.
|
||||
this.attrs = Array.from({ length: SCREEN_HEIGHT }, (_, row) =>
|
||||
Array<number>(32).fill(row < 4 || row >= 14 ? PAL_BG_GREEN : PAL_BG_GRAY),
|
||||
);
|
||||
for (let col = 7; col < 13; col++) this.attrs[8][col] = PAL_BG_YELLOW;
|
||||
}
|
||||
|
||||
get done() {
|
||||
return this.exit;
|
||||
}
|
||||
|
||||
/** One iteration of MagnetTrain's loop: PlaySpriteAnimations, the
|
||||
* jumptable, then MagnetTrain_UpdateLYOverrides (pushed for the next
|
||||
* frame by PushLYOverrides). */
|
||||
step() {
|
||||
this.oam = this.playSpriteAnimations();
|
||||
this.jumptable();
|
||||
// MagnetTrain_UpdateLYOverrides: hSCX and the bushes' bands from the
|
||||
// offset doubled, the train's band from its position.
|
||||
this.bands = { bushes: (this.offset * 2) & 0xff, train: this.position };
|
||||
this.offset = (this.offset + 2 * this.direction) & 0xff;
|
||||
}
|
||||
/** After the first iteration's frame, SCGB_MAPPALS (environment TOWN). */
|
||||
setPalettes() {
|
||||
this.palettesSet = true;
|
||||
}
|
||||
|
||||
private jumptable() {
|
||||
switch (this.index) {
|
||||
case Step.InitPlayerSpriteAnim:
|
||||
this.sprite = { frame: -1, duration: 0 };
|
||||
this.index++;
|
||||
this.waitCounter = 128;
|
||||
return;
|
||||
case Step.Wait1:
|
||||
case Step.Wait2:
|
||||
case Step.Wait3:
|
||||
if (this.waitCounter) this.waitCounter--;
|
||||
else this.index++;
|
||||
return;
|
||||
case Step.MoveTrain1:
|
||||
if (this.position === this.holdPosition) {
|
||||
this.index++;
|
||||
this.waitCounter = 128;
|
||||
return;
|
||||
}
|
||||
this.position = (this.position - this.direction) & 0xff;
|
||||
this.globalX = (this.globalX + this.direction) & 0xff;
|
||||
return;
|
||||
case Step.MoveTrain2:
|
||||
if (this.position === this.finalPosition) {
|
||||
this.index++;
|
||||
return;
|
||||
}
|
||||
this.position = (this.position - 2 * this.direction) & 0xff;
|
||||
this.globalX = (this.globalX + 2 * this.direction) & 0xff;
|
||||
return;
|
||||
case Step.TrainArrived:
|
||||
this.exit = true;
|
||||
this.host.sound?.playSFX(SOUND.SFX_TRAIN_ARRIVED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** GetSpriteAnimFrame and UpdateAnimFrame for the one icon: each frame
|
||||
* is held for its first draw plus 8 decrements; OAMData_MagnetTrainRed
|
||||
* (or Blue) placed from XCOORD = the start X and YCOORD = (8 + 2) * 8 +
|
||||
* 5, plus wGlobalAnimXOffset. */
|
||||
private playSpriteAnimations(): ScreenSprite[] {
|
||||
const sprite = this.sprite;
|
||||
if (!sprite) return [];
|
||||
if (sprite.duration) sprite.duration--;
|
||||
else {
|
||||
sprite.frame = (sprite.frame + 1) % FRAMES.length;
|
||||
sprite.duration = FRAME_DURATION;
|
||||
}
|
||||
const { vtile, flags } = FRAMES[sprite.frame];
|
||||
const r = this.host.reader,
|
||||
table = this.host.female
|
||||
? KANTO["SpriteAnimOAMData.OAMData_MagnetTrainBlue"]
|
||||
: KANTO["SpriteAnimOAMData.OAMData_MagnetTrainRed"];
|
||||
const count = r.byte(table),
|
||||
x = (this.playerX + this.globalX) & 0xff,
|
||||
y = (8 + 2) * 8 + 5;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const p = table + 1 + i * 4,
|
||||
dy = r.byte(p),
|
||||
dx = r.byte(p + 1),
|
||||
attr = r.byte(p + 3);
|
||||
const flipY = flags & OAM_YFLIP ? (-8 - signed(dy)) & 0xff : dy;
|
||||
const flipX = flags & OAM_XFLIP ? (-8 - signed(dx)) & 0xff : dx;
|
||||
const oamAttr = ((attr ^ flags) & (OAM_PRIO | OAM_YFLIP | OAM_XFLIP)) | (attr & ~(OAM_PRIO | OAM_YFLIP | OAM_XFLIP));
|
||||
return {
|
||||
x: ((x + flipX) & 0xff) - 8,
|
||||
y: ((y + flipY) & 0xff) - 16,
|
||||
tile: SPRITE_BASE + vtile + r.byte(p + 2),
|
||||
palette: oamAttr & 7,
|
||||
flipX: Boolean(oamAttr & OAM_XFLIP),
|
||||
flipY: Boolean(oamAttr & OAM_YFLIP),
|
||||
behind: Boolean(oamAttr & OAM_PRIO),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The tiles: the station's tileset, then the player's sprite frames
|
||||
* (GetPlayerIcon's standing tiles, then its walking ones 12 tiles on). */
|
||||
tiles(): Uint8Array {
|
||||
const r = this.host.reader,
|
||||
tiles = new Uint8Array((SPRITE_BASE + 8) * 16);
|
||||
tiles.set(this.host.tiles().subarray(0, SPRITE_BASE * 16));
|
||||
const gfx = this.host.female ? KANTO.KrisSpriteGFX : KANTO.ChrisSpriteGFX;
|
||||
tiles.set(r.slice(gfx, 4 * 16), SPRITE_BASE * 16);
|
||||
tiles.set(r.slice(gfx + 12 * 16, 4 * 16), (SPRITE_BASE + 4) * 16);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** The frame as displayed: the bands as pushed and the OAM drawn. */
|
||||
screen(tiles: Uint8Array, key: string): TileScreen {
|
||||
const white: Palette = [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
];
|
||||
const { bg, obj } = this.palettesSet ? this.host.palettes() : { bg: Array(8).fill(white), obj: Array(8).fill(white) };
|
||||
const band = (row: number) => (row >= 6 && row < 12 ? this.bands.train : this.bands.bushes);
|
||||
return {
|
||||
tiles,
|
||||
map: this.bg.flatMap((row) => row.slice(0, SCREEN_WIDTH)),
|
||||
attrs: this.attrs.flatMap((row) => row.slice(0, SCREEN_WIDTH)),
|
||||
palettes: bg,
|
||||
sprites: this.oam,
|
||||
objPalettes: obj,
|
||||
tileset: key,
|
||||
label: "Magnet Train",
|
||||
rowScroll: this.bg.map((tilesOfRow, row) => ({ row, x: band(row), tiles: tilesOfRow, attrs: this.attrs[row] })),
|
||||
};
|
||||
}
|
||||
|
||||
/** For comparisons: the jumptable and the bands. */
|
||||
debug() {
|
||||
return { index: this.index, position: this.position, offset: this.offset, globalX: this.globalX, bands: this.bands };
|
||||
}
|
||||
}
|
||||
|
||||
const signed = (value: number) => (value > 127 ? value - 256 : value);
|
||||
|
||||
/** The MagnetTrain special: MUSIC_MAGNET_TRAIN, the ride frame by frame
|
||||
* (one realtime screen a frame), and the screen white again at the end.
|
||||
* wScriptVar is TRUE for the train to Goldenrod. */
|
||||
export function* magnetTrain(host: MagnetTrainHost, toGoldenrod: boolean): Gen {
|
||||
const machine = new MagnetTrainMachine(host, toGoldenrod);
|
||||
const tiles = machine.tiles();
|
||||
const key = `magnet-train-${host.female ? "kris" : "chris"}`;
|
||||
host.sound?.playMusic(SOUND.MUSIC_MAGNET_TRAIN);
|
||||
// MagnetTrain_Jumptable_FirstRunThrough: its frame, then SCGB_MAPPALS
|
||||
// and UpdateTimePals, whose own frame shows the palettes.
|
||||
machine.step();
|
||||
yield { kind: "screen", screen: machine.screen(tiles, key), realtime: true };
|
||||
machine.setPalettes();
|
||||
yield { kind: "screen", screen: machine.screen(tiles, key), realtime: true };
|
||||
while (!machine.done) {
|
||||
machine.step();
|
||||
yield { kind: "screen", screen: machine.screen(tiles, key), realtime: true };
|
||||
}
|
||||
// .done: ClearBGPalettes, ClearTilemap and the rest take 8 white frames.
|
||||
const white = machine.screen(tiles, key);
|
||||
const blank: Palette = [[255, 255, 255], [255, 255, 255], [255, 255, 255], [255, 255, 255]];
|
||||
white.palettes = white.objPalettes = Array(8).fill(blank);
|
||||
white.sprites = [];
|
||||
for (let frame = 0; frame < TEARDOWN_FRAMES; frame++) yield { kind: "screen", screen: white, realtime: true };
|
||||
}
|
||||
/** Frames between the arrival and MagnetTrain's return, measured on the
|
||||
* cartridge (tests/reference-magnet-train.spec.ts). */
|
||||
export const TEARDOWN_FRAMES = 8;
|
||||
@@ -548,6 +548,7 @@ export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost):
|
||||
player: () => ({ id: scriptHost.player().id, name: scriptHost.text().player }),
|
||||
today: () => scriptHost.day?.() ?? 0,
|
||||
weekday: () => scriptHost.clock().weekday,
|
||||
timeOfDay: () => scriptHost.timeOfDay(),
|
||||
write: unused,
|
||||
take: unused,
|
||||
flush: unused,
|
||||
|
||||
@@ -58,9 +58,10 @@ export interface TileScreen {
|
||||
* `fill` (what the screen left in the rest of the 32-row BG map). */
|
||||
scroll?: { x?: number; y: number; fill: number };
|
||||
/** Tile rows with their own hSCX, as wLYOverrides scroll the credits'
|
||||
* borders: `tiles` are the row's 32 background map tiles (in its first
|
||||
* column's palette), for a row scrolled beyond the tilemap. */
|
||||
rowScroll?: { row: number; x: number; tiles?: number[] }[];
|
||||
* borders: `tiles` are the row's 32 background map tiles (in `attrs`'
|
||||
* palettes, or its first column's), for a row scrolled beyond the
|
||||
* tilemap. */
|
||||
rowScroll?: { row: number; x: number; tiles?: number[]; attrs?: number[] }[];
|
||||
}
|
||||
|
||||
/** A GBC color (RGB555, little-endian word) as RGB. */
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface SpecialContext {
|
||||
* source). */
|
||||
today(): number;
|
||||
weekday(): number;
|
||||
/** wTimeOfDay: morning 0, day 1, night 2. */
|
||||
timeOfDay(): number;
|
||||
/** Print a text into the pending pages. */
|
||||
write(address: number): void;
|
||||
/** Take the pending pages (for an effect that shows them). */
|
||||
|
||||
@@ -185,6 +185,7 @@ export const OW = {
|
||||
MAPSETUP_WARP: 0xf1,
|
||||
MAPSETUP_TELEPORT: 0xf4,
|
||||
MAPSETUP_DOOR: 0xf5,
|
||||
MAPSETUP_TRAIN: 0xf9,
|
||||
MAPSETUP_FLY: 0xfc,
|
||||
NUM_SPAWNS: 28,
|
||||
SPAWN_INDIGO: 13,
|
||||
|
||||
+28
-5
@@ -737,8 +737,10 @@ const WONT_DELETE = 1 << 1,
|
||||
MOVE_ANYWHERE = 1 << 5,
|
||||
NOCLIP_OBJS = 1 << 6,
|
||||
SWIMMING = 1 << 5;
|
||||
/** SpriteMovementData palette flag: a boulder Strength can push. */
|
||||
const STRENGTH_BOULDER = 1 << 6;
|
||||
/** SpriteMovementData palette flags: a boulder Strength can push, and an
|
||||
* object that covers 2x2 tiles. */
|
||||
const STRENGTH_BOULDER = 1 << 6,
|
||||
BIG_OBJECT = 1 << 7;
|
||||
const SPRITEMOVEDATA_STRENGTH_BOULDER = 25;
|
||||
const OBJECTTYPE_TRAINER = 2;
|
||||
/** Map environments (map_data_constants.asm) and the special call count. */
|
||||
@@ -1254,6 +1256,14 @@ export class GameWorld {
|
||||
this.notice = "";
|
||||
this.diagnostic = "";
|
||||
}
|
||||
/** IsNPCAtCoord's position test: an object's tile, or for a
|
||||
* BIG_OBJECT (the big Snorlax, Lapras and dolls) the 2x2 tiles from it
|
||||
* (WillObjectIntersectBigObject). */
|
||||
private covers(object: VisibleObject, x: number, y: number) {
|
||||
if (this.assets.spriteMovement(object.movement).paletteFlags & BIG_OBJECT)
|
||||
return x - object.x >= 0 && x - object.x < 2 && y - object.y >= 0 && y - object.y < 2;
|
||||
return object.x === x && object.y === y;
|
||||
}
|
||||
/** IsNPCAtCoord: an object blocks its tile and, mid-step, the tile it
|
||||
* is leaving (OBJECT_LAST_MAP_X/Y). */
|
||||
private occupied(x: number, y: number, except?: number) {
|
||||
@@ -1261,7 +1271,7 @@ export class GameWorld {
|
||||
if (object.id === except) return false;
|
||||
const motion = this.actor(object).motion;
|
||||
return (
|
||||
(object.x === x && object.y === y) ||
|
||||
this.covers(object, x, y) ||
|
||||
(motion !== undefined && motion.fromX === x && motion.fromY === y)
|
||||
);
|
||||
});
|
||||
@@ -2523,6 +2533,19 @@ export class GameWorld {
|
||||
day: () => this.gameDay(),
|
||||
gameTime: () => this.gameTime,
|
||||
gameTimer: (counting) => (this.gameTimerPaused = !counting),
|
||||
townPalettes: () => ({
|
||||
bg: this.assets.palettes({ ...this.map, environment: OW.TOWN }, this.timeOfDay),
|
||||
obj: Array.from({ length: 8 }, (_, index) => this.assets.objectPalette(index, this.timeOfDay)),
|
||||
}),
|
||||
warpHere: () => {
|
||||
const warp = this.map.warps.find(
|
||||
(candidate) => candidate.x === this.player.x && candidate.y === this.player.y,
|
||||
);
|
||||
const destination = warp && this.warpDestination(warp, this.liveState);
|
||||
if (!destination || !this.assets.isPorted(destination.map)) return undefined;
|
||||
const arrival = this.assets.map(destination.map).warps[destination.warp - 1];
|
||||
return arrival && { map: destination.map, x: arrival.x, y: arrival.y };
|
||||
},
|
||||
tileset: () => ({
|
||||
graphics: this.assets.tileset(this.map).graphics,
|
||||
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
||||
@@ -2568,7 +2591,7 @@ export class GameWorld {
|
||||
const [dx, dy] = VECTORS[this.player.direction];
|
||||
const x = this.player.x + dx,
|
||||
y = this.player.y + dy;
|
||||
return this.objects().find((object) => object.x === x && object.y === y);
|
||||
return this.objects().find((object) => this.covers(object, x, y));
|
||||
}
|
||||
/** CheckForHiddenItems: a BGEVENT_ITEM not found yet on the screen
|
||||
* around the player, from four squares left of and above the player to
|
||||
@@ -4564,7 +4587,7 @@ export class GameWorld {
|
||||
y += dy;
|
||||
}
|
||||
const object = this.objects().find(
|
||||
(object) => object.x === x && object.y === y,
|
||||
(object) => this.covers(object, x, y),
|
||||
);
|
||||
let script: number | undefined;
|
||||
// PlayTalkObject (and PlayClickSFX for tile scripts) as an event starts.
|
||||
|
||||
@@ -47,7 +47,7 @@ test("the Map tab shows the Johto atlas with the player and open areas", async (
|
||||
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
|
||||
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
|
||||
await expect(page.locator("#atlas-open")).toContainText("BLACKTHORN CITY");
|
||||
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
|
||||
await expect(page.locator("#atlas-open")).not.toContainText("SILVER CAVE");
|
||||
// Mt. Silver's cave is on Johto's map, opened with Kanto's postgame.
|
||||
await expect(page.locator("#atlas-open")).toContainText("SILVER CAVE");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
+3
-4
@@ -102,9 +102,8 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
||||
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
|
||||
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
|
||||
expect(at(LANDMARK_SILVER_CAVE)).toMatchObject({ open: false, flypoint: { visited: false } });
|
||||
// Kanto stays closed, so asking for it shows Johto.
|
||||
expect(at(LANDMARK_SILVER_CAVE)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
// Before Indigo Plateau or a step into Kanto, asking for it shows Johto.
|
||||
expect(world.atlas("kanto").region).toBe("johto");
|
||||
});
|
||||
|
||||
@@ -114,7 +113,7 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
||||
const kanto = world.atlas("kanto");
|
||||
expect(kanto.region).toBe("kanto");
|
||||
expect(kanto.player).toBeUndefined();
|
||||
expect(kanto.landmarks[0]).toMatchObject({ landmark: LANDMARK_PALLET_TOWN, open: false });
|
||||
expect(kanto.landmarks[0]).toMatchObject({ landmark: LANDMARK_PALLET_TOWN, open: true });
|
||||
expect(kanto.landmarks).toHaveLength(OW.KANTO_LANDMARK_LAST - OW.KANTO_LANDMARK + 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emptyScriptState } from "../src/game/scripts/interpreter";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import type { Direction } from "../src/game/world/types";
|
||||
import { settle } from "./world-driver";
|
||||
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
@@ -208,8 +209,15 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
|
||||
for (let press = 0; press < 4 && flyMap(world)?.name !== "SILVER CAVE"; press++)
|
||||
world.moveCursor("down");
|
||||
expect(flyMap(world)?.name).toBe("SILVER CAVE");
|
||||
world.choose("a");
|
||||
until(world);
|
||||
// Every flypoint is open now: close Mt. Silver's for this test.
|
||||
const silverCave = NATIVE_MAPS["19:2"];
|
||||
delete NATIVE_MAPS["19:2"];
|
||||
try {
|
||||
world.choose("a");
|
||||
until(world);
|
||||
} finally {
|
||||
NATIVE_MAPS["19:2"] = silverCave;
|
||||
}
|
||||
expect(world.notice).toBe("This destination is not ported yet.");
|
||||
close(world);
|
||||
expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { snorlaxAwake } from "../src/game/scripts/kanto";
|
||||
import { TEARDOWN_FRAMES } from "../src/game/scripts/magnet-train";
|
||||
import { SOUND } from "../src/game/audio/constants";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Kanto with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const VERMILION = "12:3",
|
||||
BILLS_HOUSE = "7:11",
|
||||
TRAINER_HOUSE_B1F = "23:7",
|
||||
SAFFRON_STATION = "25:9",
|
||||
GOLDENROD_STATION = "11:7",
|
||||
CELADON_MANSION_3F = "21:14",
|
||||
OAKS_LAB = "13:6",
|
||||
GOLDENROD = "11:2",
|
||||
MOUNT_MOON = "3:85",
|
||||
INDIGO_PLATEAU_CENTER = "16:2";
|
||||
const LICKITUNG = 108,
|
||||
EVERSTONE = 0x70,
|
||||
PASS = 0x86,
|
||||
EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA = 0x320,
|
||||
EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA = 0x31b,
|
||||
EVENT_ENABLE_DIPLOMA_PRINTING = 0xd6,
|
||||
EVENT_RESTORED_POWER_TO_KANTO = 0xcd,
|
||||
EVENT_OPENED_MT_SILVER = 0x74f,
|
||||
EVENT_TALKED_TO_OAK_IN_KANTO = 0xe1,
|
||||
EVENT_BEAT_ELITE_FOUR = 0x44,
|
||||
COIN_CASE = 0x36,
|
||||
PIKACHU = 25,
|
||||
THUNDERBOLT = 85,
|
||||
ENGINE_DAILY_MOVE_TUTOR = 94,
|
||||
EVENT_BEAT_RIVAL_IN_MT_MOON = 0x319;
|
||||
const world = (date = "2026-09-22T12:00:00") => {
|
||||
let seed = 33;
|
||||
const game = new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date(date),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
game.state.party = [mon(game, 157, 60)];
|
||||
return game;
|
||||
};
|
||||
const mon = (game: GameWorld, species: number, level = 30) =>
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: game.playerId,
|
||||
ot: "KAWA",
|
||||
dvs: 0x8888,
|
||||
caughtData: 0,
|
||||
});
|
||||
/** Stand at (x, y) on `map` facing `face`, press A and answer. */
|
||||
const talk = (
|
||||
game: GameWorld,
|
||||
map: string,
|
||||
x: number,
|
||||
y: number,
|
||||
face: "up" | "down" | "left" | "right",
|
||||
answers: (number | string)[] = [],
|
||||
) => {
|
||||
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;
|
||||
};
|
||||
/** Run scripts until a battle starts, answering questions with YES. */
|
||||
const untilBattle = (game: GameWorld) => {
|
||||
const shown: string[] = [];
|
||||
for (let tick = 0; tick < 3000 && !game.battle; tick++) {
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
const dialogue = game.dialogue;
|
||||
if (dialogue) {
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (!shown.includes(dialogue.pages[dialogue.index])) shown.push(dialogue.pages[dialogue.index]);
|
||||
if (last && dialogue.choice) game.choose(1);
|
||||
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
||||
else game.advanceDialogue();
|
||||
} else game.tick();
|
||||
}
|
||||
return shown.join(" ");
|
||||
};
|
||||
/** Run a screen that plays on its own to its end, counting its frames. */
|
||||
const watch = (game: GameWorld) => {
|
||||
let frames = 0;
|
||||
for (let tick = 0; tick < 2000 && game.dialogue?.choice?.kind === "screen"; tick++) {
|
||||
frames += 2;
|
||||
game.tick();
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
describe("Vermilion's Snorlax", () => {
|
||||
it("wakes only to the Poké Flute channel, from beside it", () => {
|
||||
const r = new CrystalAssets(bytes).reader;
|
||||
const flute = SOUND.MUSIC_POKE_FLUTE_CHANNEL;
|
||||
for (const [x, y] of [[33, 8], [34, 10], [35, 10], [36, 8], [36, 9]])
|
||||
expect(snorlaxAwake(r, flute, { x, y })).toBe(1);
|
||||
expect(snorlaxAwake(r, flute, { x: 34, y: 11 })).toBe(0);
|
||||
expect(snorlaxAwake(r, SOUND.MUSIC_NONE, { x: 34, y: 10 })).toBe(0);
|
||||
});
|
||||
it("covers two tiles by two and snores until the flute plays", () => {
|
||||
const game = world();
|
||||
// The big Snorlax at (34, 8) also fills (35, 9): no step there.
|
||||
game.warpTo(VERMILION, 36, 9);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 36, y: 9, direction: "left" });
|
||||
game.move("left");
|
||||
settle(game);
|
||||
expect({ x: game.x, y: game.y }).toEqual({ x: 36, y: 9 });
|
||||
expect(talk(game, VERMILION, 34, 10, "up")).toContain("SNORLAX is snoring\npeacefully…");
|
||||
// The Pokégear's radio left on the Poké Flute channel (wMapMusic).
|
||||
game.restore({ ...game.save(), x: 34, y: 10, direction: "up" });
|
||||
game.sound.engine.mapMusic = SOUND.MUSIC_POKE_FLUTE_CHANNEL;
|
||||
game.interact();
|
||||
expect(untilBattle(game)).toContain("SNORLAX");
|
||||
expect(game.battle?.engine.enemy.mon.species).toBe(143);
|
||||
expect(game.battle?.engine.enemy.mon.level).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
it("has Bill's grandpa take a look at a Lickitung for the Everstone", () => {
|
||||
const game = world();
|
||||
game.state.party.push(mon(game, LICKITUNG));
|
||||
// YES to show him, then the party's second Pokémon.
|
||||
const shown = talk(game, BILLS_HOUSE, 2, 4, "up", [1, 1]);
|
||||
expect(shown).toContain("Hm? You know BILL?");
|
||||
expect(shown).toContain("LICKITUNG");
|
||||
expect(game.state.events.has(EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA)).toBe(true);
|
||||
expect(game.state.events.has(EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA)).toBe(true);
|
||||
expect(game.state.bag.items.some((slot) => slot.item === EVERSTONE)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the Trainer House's default trainer, CAL", () => {
|
||||
const game = world();
|
||||
// The receptionist's coordinate event at (7, 3), from the stairs' side.
|
||||
game.warpTo(TRAINER_HOUSE_B1F, 8, 3);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 8, y: 3, direction: "left" });
|
||||
expect(game.move("left")).toBe(true);
|
||||
// TrainerHouse returns 0 without a Mystery Gift partner: CAL3.
|
||||
expect(untilBattle(game)).toContain("CAL");
|
||||
expect(game.battle?.engine.enemy.mon).toBeDefined();
|
||||
});
|
||||
|
||||
it("rides the Magnet Train both ways with the PASS once the power is back", () => {
|
||||
const game = world();
|
||||
game.state.bag.keyItems.push(PASS);
|
||||
game.state.events.add(EVENT_RESTORED_POWER_TO_KANTO);
|
||||
// Saffron's officer at (9, 9): YES, then the ride to Goldenrod.
|
||||
game.warpTo(SAFFRON_STATION, 9, 10);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 9, y: 10, direction: "up" });
|
||||
game.interact();
|
||||
for (let tick = 0; tick < 400 && game.dialogue?.choice?.kind !== "screen"; tick++) {
|
||||
if (game.dialogue?.choice) game.choose(1);
|
||||
else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(game.dialogue?.choice?.kind).toBe("screen");
|
||||
const frames = watch(game);
|
||||
// The first run through, its palette frame, the ride and the teardown.
|
||||
expect(frames).toBeGreaterThan(300 + TEARDOWN_FRAMES);
|
||||
settle(game);
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
expect(game.mapId).toBe(GOLDENROD_STATION);
|
||||
// And back from Goldenrod's officer.
|
||||
game.restore({ ...game.save(), x: 9, y: 10, direction: "up" });
|
||||
game.interact();
|
||||
for (let tick = 0; tick < 400 && game.dialogue?.choice?.kind !== "screen"; tick++) {
|
||||
if (game.dialogue?.choice) game.choose(1);
|
||||
else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
watch(game);
|
||||
settle(game);
|
||||
expect(game.mapId).toBe(SAFFRON_STATION);
|
||||
});
|
||||
|
||||
it("shows the Diploma for a complete Pokédex, then cannot print it", () => {
|
||||
const game = world();
|
||||
for (let species = 1; species <= 251; species++) {
|
||||
game.state.pokedex.seen.add(species);
|
||||
game.state.pokedex.caught.add(species);
|
||||
}
|
||||
// The game designer at (3, 6), from below.
|
||||
game.warpTo(CELADON_MANSION_3F, 3, 7);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 3, y: 7, direction: "up" });
|
||||
game.interact();
|
||||
let diploma = "";
|
||||
for (let tick = 0; tick < 400; tick++) {
|
||||
const choice = game.dialogue?.choice;
|
||||
if (choice?.kind === "screen") {
|
||||
diploma = choice.screen.label;
|
||||
game.choose("a");
|
||||
break;
|
||||
}
|
||||
if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(diploma).toBe(`Diploma: ${game.name}`);
|
||||
settle(game);
|
||||
expect(game.state.events.has(EVENT_ENABLE_DIPLOMA_PRINTING)).toBe(true);
|
||||
// The graphic artist at (3, 4) prints it: no printer, B cancels.
|
||||
game.restore({ ...game.save(), x: 3, y: 5, direction: "up" });
|
||||
game.interact();
|
||||
let printer = "";
|
||||
for (let tick = 0; tick < 400; tick++) {
|
||||
const choice = game.dialogue?.choice;
|
||||
if (choice?.kind === "yesno") game.choose(1);
|
||||
else if (choice?.kind === "screen") {
|
||||
printer = choice.screen.label;
|
||||
game.choose("b");
|
||||
break;
|
||||
} else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(printer).toMatch(/B/);
|
||||
settle(game);
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("has Professor Oak open Mt. Silver for sixteen badges and rate the Pokédex", () => {
|
||||
const game = world();
|
||||
for (let badge = 0x1b; badge < 0x1b + 16; badge++) game.state.flags.add(badge);
|
||||
for (const species of [1, 4, 7, 25]) game.state.pokedex.seen.add(species);
|
||||
game.state.pokedex.caught.add(25);
|
||||
// Oak at (4, 2), from below.
|
||||
const shown = talk(game, OAKS_LAB, 4, 3, "up");
|
||||
expect(game.state.events.has(EVENT_TALKED_TO_OAK_IN_KANTO)).toBe(true);
|
||||
expect(game.state.events.has(EVENT_OPENED_MT_SILVER)).toBe(true);
|
||||
expect(shown).toContain("4 POKéMON seen\n1 POKéMON owned");
|
||||
expect(shown).toContain("Look for POKéMON\nin grassy areas!");
|
||||
});
|
||||
it("has Goldenrod's move tutor teach Thunderbolt for 4000 coins on a Wednesday after the League", () => {
|
||||
// 2026-09-23 is a Wednesday.
|
||||
const game = world("2026-09-23T12:00:00");
|
||||
game.state.party.push(mon(game, PIKACHU, 5));
|
||||
game.state.events.add(EVENT_BEAT_ELITE_FOUR);
|
||||
game.state.bag.keyItems.push(COIN_CASE);
|
||||
game.state.coins = 5000;
|
||||
// The tutor at (12, 22), from below.
|
||||
game.warpTo(GOLDENROD, 12, 23);
|
||||
settle(game);
|
||||
expect(game.objects().some((object) => object.x === 12 && object.y === 22)).toBe(true);
|
||||
game.restore({ ...game.save(), x: 12, y: 23, direction: "up" });
|
||||
game.interact();
|
||||
// YES, YES to 4000 coins, THUNDERBOLT, then Typhlosion (NOT ABLE:
|
||||
// back to the menu) and Pikachu.
|
||||
const party = [0, 1],
|
||||
details: string[][] = [];
|
||||
for (let tick = 0; tick < 3000 && game.busy; tick++) {
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
const dialogue = game.dialogue;
|
||||
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice?.kind === "yesno") game.choose(1);
|
||||
else if (last && dialogue.choice?.kind === "menu" && dialogue.choice.items.includes("THUNDERBOLT"))
|
||||
game.choose(dialogue.choice.items.indexOf("THUNDERBOLT"));
|
||||
else if (last && dialogue.choice?.kind === "menu") {
|
||||
details.push(dialogue.choice.details ?? []);
|
||||
game.choose(party.shift()!);
|
||||
} else if (dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(details).toEqual([
|
||||
["NOT ABLE", "ABLE"],
|
||||
["NOT ABLE", "ABLE"],
|
||||
]);
|
||||
const pikachu = game.state.party[1];
|
||||
expect(pikachu.moves).toContain(THUNDERBOLT);
|
||||
expect(game.state.coins).toBe(1000);
|
||||
expect(game.state.flags.has(ENGINE_DAILY_MOVE_TUTOR)).toBe(true);
|
||||
// Into the Game Corner: gone from the street.
|
||||
expect(game.objects().some((object) => object.x === 12 && object.y === 22)).toBe(false);
|
||||
});
|
||||
describe("the rival in Kanto", () => {
|
||||
it("waits in Mt. Moon", () => {
|
||||
const game = world();
|
||||
// MountMoonRivalEncounterScene, from the Route 3 entrance.
|
||||
game.warpTo(MOUNT_MOON, 3, 3);
|
||||
expect(untilBattle(game)).toContain(`It's been a while,\n${game.name}.`);
|
||||
expect(game.battle).toBeDefined();
|
||||
});
|
||||
// PlateauRivalBattle1 and 2: Mondays and Wednesdays, after Mt. Moon.
|
||||
for (const [date, day, fights] of [
|
||||
["2026-09-21T12:00:00", "a Monday", true],
|
||||
["2026-09-22T12:00:00", "a Tuesday", false],
|
||||
] as const)
|
||||
it(`${fights ? "battles" : "stays away"} at Indigo Plateau on ${day}`, () => {
|
||||
const game = world(date);
|
||||
game.state.events.add(EVENT_BEAT_RIVAL_IN_MT_MOON);
|
||||
game.warpTo(INDIGO_PLATEAU_CENTER, 16, 5);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 16, y: 5, direction: "up" });
|
||||
expect(game.move("up")).toBe(true);
|
||||
if (fights) {
|
||||
expect(untilBattle(game)).toContain("Hold it.");
|
||||
expect(game.battle).toBeDefined();
|
||||
} else {
|
||||
settle(game);
|
||||
expect(game.battle).toBeUndefined();
|
||||
expect({ x: game.x, y: game.y }).toEqual({ x: 16, y: 4 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,12 @@ export class Playthrough {
|
||||
item: world.liveState.bag.balls[ball].item,
|
||||
slot: ball,
|
||||
});
|
||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
else if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
mon.hp * 2 < mon.maxHp &&
|
||||
// A careful player trapped by Bind or Wrap fights back.
|
||||
!(this.careful && log.slice(-4).includes("Can't escape!"))
|
||||
)
|
||||
world.battleAction({ action: "run" });
|
||||
else if (
|
||||
engine.battleMode !== C.WILD_BATTLE &&
|
||||
@@ -524,8 +529,10 @@ export class Playthrough {
|
||||
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;
|
||||
// A warp to another place on the same map lands at its partner.
|
||||
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||
const moved = arrival ? world.x === arrival.x && world.y === arrival.y : world.mapId !== from;
|
||||
if (moved && this.route(target, maps, depth - 1, goal)) return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
@@ -710,6 +717,74 @@ export class Playthrough {
|
||||
}
|
||||
this.play();
|
||||
};
|
||||
/** Face a tile (a hidden item) from a side reachable on foot or by
|
||||
* Surf and press A. From land a step towards it only turns the player
|
||||
* (a surfer would move onto it), so land sides come first. */
|
||||
inspect = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
land = (dx: number, dy: number) =>
|
||||
world.assets.permission(world.assets.collision(world.map, x + dx, y + dy)) === 0;
|
||||
const sides = (
|
||||
[
|
||||
["up", 0, 1],
|
||||
["down", 0, -1],
|
||||
["left", 1, 0],
|
||||
["right", -1, 0],
|
||||
] as const
|
||||
)
|
||||
.slice()
|
||||
.sort(([, ax, ay], [, bx, by]) => Number(!land(ax, ay)) - Number(!land(bx, by)));
|
||||
for (const [face, dx, dy] of sides) {
|
||||
try {
|
||||
findPath(world, x + dx, y + dy, { water: true, ledges: this.ledges });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
this.sail(x + dx, y + dy);
|
||||
if (world.save().direction !== face) world.move(face);
|
||||
world.interact();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No side to inspect ${x},${y}`);
|
||||
};
|
||||
/** Ride up a downhill slope (ENGINE_DOWNHILL rolls an idle bike down,
|
||||
* so stopping between steps loses ground): the planned direction is held
|
||||
* on every tick to (x, y), then up until the map changes. */
|
||||
rideUp = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
for (let tick = 0; tick < 20_000 && world.mapId === map; tick++) {
|
||||
if (world.dialogue || world.battle) {
|
||||
this.play();
|
||||
continue;
|
||||
}
|
||||
const path = world.x === x && world.y === y ? [] : findPath(world, x, y);
|
||||
world.tick(path[0] ?? "up");
|
||||
}
|
||||
expect(world.mapId, `riding up ${map}`).not.toBe(map);
|
||||
this.play();
|
||||
};
|
||||
/** The Pokégear's radio card from the start menu: turn the knob to
|
||||
* `frequency`, then close the Pokégear and the menu; the station keeps
|
||||
* playing (ExitPokegearRadio_HandleMusic). */
|
||||
tuneRadio = (frequency: string) => {
|
||||
const world = this.world;
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
this.choose("POKéGEAR");
|
||||
for (let press = 0; press < 3 && world.pokegear?.card !== "radio"; press++) world.choose("right");
|
||||
expect(world.pokegear?.card).toBe("radio");
|
||||
for (let press = 0; press < 80 && world.pokegear?.radio?.frequency !== frequency; press++) {
|
||||
world.bufferScreenButton(press < 60 ? "up" : "down");
|
||||
world.tick();
|
||||
}
|
||||
expect(world.pokegear?.radio?.frequency).toBe(frequency);
|
||||
for (let tick = 0; tick < 30; tick++) world.tick();
|
||||
world.bufferScreenButton("b");
|
||||
for (let tick = 0; tick < 30 && world.pokegear; tick++) world.tick();
|
||||
if (world.dialogue?.choice?.kind === "menu") world.choose(-1);
|
||||
for (let tick = 0; tick < 30 && world.busy; tick++) world.tick();
|
||||
};
|
||||
/** Push Strength boulder `id` one tile in `direction` from its far
|
||||
* side: the first bump only turns the player, a boulder Strength is not
|
||||
* yet active for asks first (AskStrengthScript, then YES), and the
|
||||
|
||||
@@ -238,6 +238,98 @@ const HM07 = 0xf9,
|
||||
EVENT_BEAT_ELITE_4_KAREN = 0x5bb,
|
||||
EVENT_BEAT_CHAMPION_LANCE = 0x5bc,
|
||||
EVENT_BEAT_ELITE_FOUR = 0x44;
|
||||
const FAST_SHIP_1F = "15:3",
|
||||
FAST_SHIP_CABINS_NNW = "15:4",
|
||||
FAST_SHIP_CABINS_SE = "15:6",
|
||||
FAST_SHIP_B1F = "15:7",
|
||||
OLIVINE_PORT = "15:1",
|
||||
OLIVINE_PORT_PASSAGE = "15:8",
|
||||
VERMILION_PORT_PASSAGE = "15:9",
|
||||
VERMILION = "12:3",
|
||||
VERMILION_CENTER = "12:5",
|
||||
VERMILION_GYM = "12:11",
|
||||
ROUTE_6 = "12:1",
|
||||
ROUTE_6_SAFFRON_GATE = "12:12",
|
||||
SAFFRON = "25:2",
|
||||
ROUTE_5 = "25:1",
|
||||
ROUTE_5_SAFFRON_GATE = "25:14",
|
||||
SAFFRON_GYM = "25:4",
|
||||
SAFFRON_MART = "25:5",
|
||||
SAFFRON_CENTER = "25:6",
|
||||
CERULEAN = "7:17",
|
||||
CERULEAN_CENTER = "7:4",
|
||||
CERULEAN_GYM = "7:6",
|
||||
POWER_PLANT = "7:10",
|
||||
ROUTE_9 = "7:13",
|
||||
ROUTE_10_NORTH = "7:14",
|
||||
ROUTE_24 = "7:15",
|
||||
ROUTE_25 = "7:16",
|
||||
ROCK_TUNNEL = ["3:87", "3:88"],
|
||||
ROUTE_10_SOUTH = "18:3",
|
||||
LAVENDER = "18:4",
|
||||
LAVENDER_CENTER = "18:5",
|
||||
LAVENDER_RADIO_TOWER = "18:12",
|
||||
ROUTE_8 = "18:1",
|
||||
ROUTE_8_SAFFRON_GATE = "18:13",
|
||||
DIGLETTS_CAVE = "3:84",
|
||||
ROUTE_2 = "23:1",
|
||||
VIRIDIAN = "23:3",
|
||||
ROUTE_22 = "23:2",
|
||||
VIRIDIAN_GYM = "23:4",
|
||||
VIRIDIAN_CENTER = "23:9",
|
||||
PEWTER = "14:2",
|
||||
PEWTER_GYM = "14:4",
|
||||
PEWTER_CENTER = "14:6",
|
||||
ROUTE_1 = "13:1",
|
||||
PALLET = "13:2",
|
||||
OAKS_LAB = "13:6",
|
||||
CINNABAR_CENTER = "6:1",
|
||||
ROUTE_19_FUCHSIA_GATE = "6:3",
|
||||
SEAFOAM_GYM = "6:4",
|
||||
ROUTE_19 = "6:5",
|
||||
ROUTE_20 = "6:6",
|
||||
ROUTE_21 = "6:7",
|
||||
CINNABAR = "6:8",
|
||||
FUCHSIA_GYM = "17:8",
|
||||
ROUTE_18 = "17:4",
|
||||
FUCHSIA = "17:5",
|
||||
FUCHSIA_CENTER = "17:10",
|
||||
ROUTE_7 = "21:1",
|
||||
ROUTE_17 = "21:3",
|
||||
CELADON = "21:4",
|
||||
CELADON_CENTER = "21:17",
|
||||
CELADON_GYM = "21:21",
|
||||
ROUTE_7_SAFFRON_GATE = "21:25",
|
||||
ROUTE_17_ROUTE_18_GATE = "21:26",
|
||||
ROUTE_28 = "19:1",
|
||||
SILVER_CAVE_OUTSIDE = "19:2",
|
||||
SILVER_CAVE_CENTER = "19:3",
|
||||
SILVER_CAVE = ["3:74", "3:75"],
|
||||
SILVER_CAVE_ROOM_3 = "3:76";
|
||||
const S_S_TICKET = 0x44,
|
||||
MACHINE_PART = 0x80,
|
||||
ENGINE_EXPN_CARD = 3,
|
||||
ENGINE_BOULDERBADGE = 0x23,
|
||||
ENGINE_CASCADEBADGE = 0x24,
|
||||
ENGINE_THUNDERBADGE = 0x25,
|
||||
ENGINE_RAINBOWBADGE = 0x26,
|
||||
ENGINE_SOULBADGE = 0x27,
|
||||
ENGINE_MARSHBADGE = 0x28,
|
||||
ENGINE_VOLCANOBADGE = 0x29,
|
||||
ENGINE_EARTHBADGE = 0x2a,
|
||||
EARTHQUAKE = 89,
|
||||
EVENT_GOT_SS_TICKET_FROM_ELM = 0x24,
|
||||
EVENT_FAST_SHIP_FIRST_TIME = 0x30,
|
||||
EVENT_RETURNED_MACHINE_PART = 0xc9,
|
||||
EVENT_MET_MANAGER_AT_POWER_PLANT = 0xca,
|
||||
EVENT_RESTORED_POWER_TO_KANTO = 0xcd,
|
||||
EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM = 0xfb,
|
||||
EVENT_TALKED_TO_OAK_IN_KANTO = 0xe1,
|
||||
EVENT_BLUE_IN_CINNABAR = 0x775,
|
||||
EVENT_BEAT_BLUE = 0x4cc,
|
||||
EVENT_OPENED_MT_SILVER = 0x74f,
|
||||
EVENT_FOUGHT_SNORLAX = 0x750,
|
||||
EVENT_RED_IN_MT_SILVER = 0x762;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -1653,5 +1745,385 @@ describe.skipIf(!existsSync(romPath))(
|
||||
reload();
|
||||
writeTestSave("hall-of-fame-new-bark");
|
||||
}, 300_000);
|
||||
|
||||
/** A Kanto gym leader in front of the player's way in: the battle
|
||||
* and the badge. */
|
||||
const beatLeader = (id: number, badge: number) => {
|
||||
talkTo(id);
|
||||
play();
|
||||
expect(p.world.state.flags.has(badge), p.log.slice(-3).join(" ")).toBe(true);
|
||||
};
|
||||
|
||||
it("takes the S.S. Ticket from Elm and sails the S.S. Aqua to Vermilion", () => {
|
||||
traceFailures();
|
||||
p.world.move("down");
|
||||
play();
|
||||
enter(ELMS_LAB, "up");
|
||||
play();
|
||||
talkTo(2); // Elm
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_SS_TICKET_FROM_ELM)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(S_S_TICKET);
|
||||
stairs(NEW_BARK);
|
||||
cross("left", ROUTE_29);
|
||||
cross("left", CHERRYGROVE);
|
||||
cross("up", ROUTE_30);
|
||||
cross("up", ROUTE_31);
|
||||
stairs(ROUTE_31_GATE);
|
||||
stairs(VIOLET);
|
||||
cross("left", ROUTE_36);
|
||||
cross("up", ROUTE_37);
|
||||
cross("up", ECRUTEAK);
|
||||
stairs(ROUTE_38_GATE);
|
||||
stairs(ROUTE_38);
|
||||
cross("left", ROUTE_39);
|
||||
cross("down", OLIVINE);
|
||||
travel(OLIVINE_PORT, [OLIVINE_PORT_PASSAGE]);
|
||||
// OlivinePortWalkUpToShipScript stops the player on the way to the
|
||||
// gangway: YES, the ticket, and on board.
|
||||
try {
|
||||
walk(7, 16);
|
||||
} catch {
|
||||
// The script takes over the walk.
|
||||
}
|
||||
play();
|
||||
expect(p.world.mapId).toBe(FAST_SHIP_1F);
|
||||
// FastShipB1FSailorBlocksLeft: a sailor steps in front of the
|
||||
// corridor west until the lazy sailor in the north cabins goes back
|
||||
// to work.
|
||||
stairs(FAST_SHIP_B1F);
|
||||
walk(30, 8);
|
||||
p.world.move("up");
|
||||
play();
|
||||
if (p.world.save().direction !== "up") p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
walk(31, 13); // back up the east ladder
|
||||
play();
|
||||
expect(route(FAST_SHIP_CABINS_NNW, [FAST_SHIP_1F], 3, { x: 4, y: 27 })).toBe(true);
|
||||
talkTo(6); // the lazy sailor
|
||||
play();
|
||||
// Down to B1F and along it to the captain's cabin, where the lost
|
||||
// girl is found and the ship comes in.
|
||||
expect(route(FAST_SHIP_CABINS_SE, [FAST_SHIP_B1F, FAST_SHIP_1F], 6, { x: 1, y: 26 })).toBe(true);
|
||||
talkTo(5);
|
||||
play();
|
||||
// Off the ship: the sailor at the gangway lets the player out.
|
||||
expect(route(FAST_SHIP_1F, [FAST_SHIP_B1F], 4, { x: 25, y: 3 })).toBe(true);
|
||||
talkTo(2);
|
||||
play();
|
||||
travel(VERMILION, [VERMILION_PORT_PASSAGE]);
|
||||
expect(p.world.state.events.has(EVENT_FAST_SHIP_FIRST_TIME)).toBe(true);
|
||||
reload();
|
||||
writeTestSave("vermilion-city");
|
||||
}, 300_000);
|
||||
|
||||
it("wins the THUNDERBADGE from Lt. Surge", () => {
|
||||
traceFailures();
|
||||
heal(VERMILION_CENTER, VERMILION);
|
||||
// A tree hides the gym's yard; Bellsprout cuts it.
|
||||
p.cut(13, 18);
|
||||
enter(VERMILION_GYM, "up");
|
||||
beatLeader(p.world.map.objects[0].id, ENGINE_THUNDERBADGE);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("wins the MARSHBADGE from Sabrina", () => {
|
||||
traceFailures();
|
||||
exit(VERMILION);
|
||||
p.cut(13, 18); // the tree has grown back
|
||||
cross("up", ROUTE_6);
|
||||
stairs(ROUTE_6_SAFFRON_GATE);
|
||||
stairs(SAFFRON);
|
||||
heal(SAFFRON_CENTER, SAFFRON);
|
||||
writeTestSave("saffron-city");
|
||||
enter(SAFFRON_GYM, "up");
|
||||
// Sabrina at (9, 8), past the gym's warp panels.
|
||||
expect(route(SAFFRON_GYM, [SAFFRON_GYM], 12, { x: 9, y: 9 })).toBe(true);
|
||||
beatLeader(p.world.map.objects[0].id, ENGINE_MARSHBADGE);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("meets the Power Plant's manager, finds the Machine Part and wins the CASCADEBADGE", () => {
|
||||
traceFailures();
|
||||
expect(route(SAFFRON, [SAFFRON_GYM], 12)).toBe(true);
|
||||
stairs(ROUTE_5_SAFFRON_GATE);
|
||||
stairs(ROUTE_5);
|
||||
cross("up", CERULEAN);
|
||||
heal(CERULEAN_CENTER, CERULEAN);
|
||||
cross("right", ROUTE_9);
|
||||
p.cut(5, 8);
|
||||
// Route 10's fence lets no one down from its Pokémon Center: the
|
||||
// river from Route 9's east end leads down to the Power Plant.
|
||||
sail(56, 17);
|
||||
p.world.move("down");
|
||||
play();
|
||||
sail(3, 11);
|
||||
stairs(POWER_PLANT);
|
||||
talkTo(7); // the manager
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_MET_MANAGER_AT_POWER_PLANT)).toBe(true);
|
||||
stairs(ROUTE_10_NORTH);
|
||||
// Back up the river, ashore at its top and west to Cerulean; the
|
||||
// tree has grown back.
|
||||
sail(16, 0);
|
||||
p.world.move("up");
|
||||
play();
|
||||
sail(50, 4);
|
||||
p.cut(5, 8);
|
||||
cross("left", CERULEAN);
|
||||
// The thief Rocket's scene on the way in, and the grunt on Route 24
|
||||
// tells where he hid the part.
|
||||
enter(CERULEAN_GYM, "up");
|
||||
play();
|
||||
exit(CERULEAN);
|
||||
cross("up", ROUTE_24);
|
||||
talkTo(2);
|
||||
play();
|
||||
cross("down", CERULEAN);
|
||||
enter(CERULEAN_GYM, "up");
|
||||
p.inspect(3, 8);
|
||||
expect(p.world.state.events.has(EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(MACHINE_PART);
|
||||
exit(CERULEAN);
|
||||
cross("up", ROUTE_24);
|
||||
cross("up", ROUTE_25);
|
||||
// Route25MistyDate: the date breaks up as the player walks by.
|
||||
walk(41, 6);
|
||||
p.world.move("right");
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("MISTY");
|
||||
cross("down", ROUTE_24);
|
||||
cross("down", CERULEAN);
|
||||
heal(CERULEAN_CENTER, CERULEAN);
|
||||
enter(CERULEAN_GYM, "up");
|
||||
beatLeader(3, ENGINE_CASCADEBADGE); // Misty, on the pool's island
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("restores the power, crosses Rock Tunnel and takes the EXPN card in Lavender", () => {
|
||||
traceFailures();
|
||||
exit(CERULEAN);
|
||||
cross("right", ROUTE_9);
|
||||
p.cut(5, 8);
|
||||
sail(56, 17);
|
||||
p.world.move("down");
|
||||
play();
|
||||
sail(3, 11);
|
||||
stairs(POWER_PLANT);
|
||||
talkTo(7);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_RETURNED_MACHINE_PART)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_RESTORED_POWER_TO_KANTO)).toBe(true);
|
||||
stairs(ROUTE_10_NORTH);
|
||||
sail(16, 0);
|
||||
p.world.move("up");
|
||||
play();
|
||||
sail(50, 4);
|
||||
// Rock Tunnel's door on Route 9 is reached from below: Route 10's
|
||||
// Pokémon Center grounds lead north into Route 9's corner by it.
|
||||
cross("down", ROUTE_10_NORTH);
|
||||
walk(8, 0);
|
||||
p.world.move("up");
|
||||
play();
|
||||
walk(48, 16);
|
||||
p.world.move("up");
|
||||
play();
|
||||
travel(ROUTE_10_SOUTH, ROCK_TUNNEL);
|
||||
cross("down", LAVENDER);
|
||||
heal(LAVENDER_CENTER, LAVENDER);
|
||||
enter(LAVENDER_RADIO_TOWER, "up");
|
||||
talkTo(5); // the director, by the stairs
|
||||
play();
|
||||
expect(p.world.state.flags.has(ENGINE_EXPN_CARD)).toBe(true);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("wakes Vermilion's Snorlax with the Poké Flute channel", () => {
|
||||
traceFailures();
|
||||
exit(LAVENDER);
|
||||
cross("left", ROUTE_8);
|
||||
stairs(ROUTE_8_SAFFRON_GATE);
|
||||
stairs(SAFFRON);
|
||||
stairs(ROUTE_6_SAFFRON_GATE);
|
||||
stairs(ROUTE_6);
|
||||
cross("down", VERMILION);
|
||||
// SnorlaxAwake: the radio left on the Poké Flute channel, beside it.
|
||||
walk(34, 10);
|
||||
p.tuneRadio("20.0");
|
||||
if (p.world.save().direction !== "up") p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_FOUGHT_SNORLAX)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("goes through Diglett's Cave and wins the BOULDERBADGE from Brock", () => {
|
||||
traceFailures();
|
||||
heal(VERMILION_CENTER, VERMILION);
|
||||
travel(ROUTE_2, [DIGLETTS_CAVE]);
|
||||
p.cut(5, 8);
|
||||
cross("up", PEWTER);
|
||||
heal(PEWTER_CENTER, PEWTER);
|
||||
enter(PEWTER_GYM, "up");
|
||||
beatLeader(p.world.map.objects[0].id, ENGINE_BOULDERBADGE);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("surfs from Pallet Town to Cinnabar and wins the VOLCANOBADGE at Seafoam", () => {
|
||||
traceFailures();
|
||||
exit(PEWTER);
|
||||
cross("down", ROUTE_2);
|
||||
p.cut(5, 8);
|
||||
cross("down", VIRIDIAN);
|
||||
heal(VIRIDIAN_CENTER, VIRIDIAN);
|
||||
cross("down", ROUTE_1);
|
||||
cross("down", PALLET);
|
||||
cross("down", ROUTE_21, { water: true });
|
||||
cross("down", CINNABAR, { water: true });
|
||||
heal(CINNABAR_CENTER, CINNABAR);
|
||||
cross("right", ROUTE_20, { water: true });
|
||||
travel(SEAFOAM_GYM, [], { water: true });
|
||||
beatLeader(2, ENGINE_VOLCANOBADGE); // Blaine
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("hears Blue out on Cinnabar and wins the SOULBADGE from Janine", () => {
|
||||
traceFailures();
|
||||
stairs(ROUTE_20);
|
||||
cross("left", CINNABAR, { water: true });
|
||||
talkTo(2); // Blue
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("Who are you?");
|
||||
expect(p.world.state.events.has(EVENT_BLUE_IN_CINNABAR)).toBe(true);
|
||||
cross("right", ROUTE_20, { water: true });
|
||||
cross("right", ROUTE_19, { water: true });
|
||||
travel(FUCHSIA, [ROUTE_19_FUCHSIA_GATE], { water: true });
|
||||
heal(FUCHSIA_CENTER, FUCHSIA);
|
||||
enter(FUCHSIA_GYM, "up");
|
||||
beatLeader(2, ENGINE_SOULBADGE);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("rides up Cycling Road and wins the RAINBOWBADGE from Erika", () => {
|
||||
traceFailures();
|
||||
exit(FUCHSIA);
|
||||
cross("left", ROUTE_18);
|
||||
// Cycling Road: on the Bicycle through the gate and up Route 17,
|
||||
// which continues north into Route 16 between x 4 and 9.
|
||||
p.world.useBicycle();
|
||||
play();
|
||||
stairs(ROUTE_17_ROUTE_18_GATE);
|
||||
stairs(ROUTE_17);
|
||||
p.rideUp(6, 0);
|
||||
// Through the Route 16 gate to its east side, then Celadon.
|
||||
walk(9, 7);
|
||||
p.world.move("right");
|
||||
play();
|
||||
walk(9, 5);
|
||||
p.world.move("right");
|
||||
play();
|
||||
cross("right", CELADON);
|
||||
if (p.world.state.playerState === 1) {
|
||||
p.world.useBicycle();
|
||||
play();
|
||||
}
|
||||
heal(CELADON_CENTER, CELADON);
|
||||
writeTestSave("celadon-city");
|
||||
p.cut(28, 35); // the tree by the gym's yard
|
||||
enter(CELADON_GYM, "up");
|
||||
beatLeader(2, ENGINE_RAINBOWBADGE);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("restocks in Saffron and wins the EARTHBADGE from Blue", () => {
|
||||
traceFailures();
|
||||
exit(CELADON);
|
||||
p.cut(28, 35); // grown back
|
||||
heal(CELADON_CENTER, CELADON);
|
||||
cross("right", ROUTE_7);
|
||||
stairs(ROUTE_7_SAFFRON_GATE);
|
||||
stairs(SAFFRON);
|
||||
// Saffron's mart sells Max Potions.
|
||||
enter(SAFFRON_MART, "up");
|
||||
buy(2, C.MAX_POTION, Math.min(99, Math.floor((p.world.state.money - 3000) / 2500)));
|
||||
exit(SAFFRON);
|
||||
stairs(ROUTE_6_SAFFRON_GATE);
|
||||
stairs(ROUTE_6);
|
||||
cross("down", VERMILION);
|
||||
travel(ROUTE_2, [DIGLETTS_CAVE]);
|
||||
p.cut(5, 8);
|
||||
cross("down", VIRIDIAN);
|
||||
heal(VIRIDIAN_CENTER, VIRIDIAN);
|
||||
enter(VIRIDIAN_GYM, "up");
|
||||
beatLeader(2, ENGINE_EARTHBADGE);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_BLUE)).toBe(true);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("has Oak open Mt. Silver for sixteen badges", () => {
|
||||
traceFailures();
|
||||
exit(VIRIDIAN);
|
||||
heal(VIRIDIAN_CENTER, VIRIDIAN);
|
||||
cross("down", ROUTE_1);
|
||||
cross("down", PALLET);
|
||||
enter(OAKS_LAB, "up");
|
||||
talkTo(2); // Oak
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_TALKED_TO_OAK_IN_KANTO)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_OPENED_MT_SILVER)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("POKéMON seen");
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("reaches Mt. Silver by Route 28 and trains outside Silver Cave", () => {
|
||||
traceFailures();
|
||||
exit(PALLET);
|
||||
cross("up", ROUTE_1);
|
||||
cross("up", VIRIDIAN);
|
||||
cross("left", ROUTE_22);
|
||||
stairs(VICTORY_ROAD_GATE);
|
||||
stairs(ROUTE_28);
|
||||
cross("left", SILVER_CAVE_OUTSIDE);
|
||||
heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
|
||||
writeTestSave("mt-silver");
|
||||
// Red's team is in the 70s and 80s: the lead trains in steps.
|
||||
travel(SILVER_CAVE_ROOM_3, SILVER_CAVE);
|
||||
travel(SILVER_CAVE_OUTSIDE, SILVER_CAVE);
|
||||
const rest = () => heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
|
||||
for (const level of [84, 88, 91, 93, 95]) train(level, rest);
|
||||
expect(lead().level).toBeGreaterThanOrEqual(95);
|
||||
reload();
|
||||
}, 900_000);
|
||||
|
||||
it("takes TM26 from Victory Road, beats Red and sees the credits", () => {
|
||||
traceFailures();
|
||||
cross("right", ROUTE_28);
|
||||
stairs(VICTORY_ROAD_GATE);
|
||||
stairs(VICTORY_ROAD);
|
||||
expect(route(VICTORY_ROAD, [VICTORY_ROAD], 8, { x: 4, y: 28 })).toBe(true);
|
||||
talkTo(3); // TM26's ball
|
||||
play();
|
||||
p.teach(tmhmItem(26), 0, 2); // Earthquake for Smokescreen
|
||||
expect(lead().moves[2]).toBe(EARTHQUAKE);
|
||||
travel(VICTORY_ROAD_GATE, [VICTORY_ROAD]);
|
||||
stairs(ROUTE_28);
|
||||
cross("left", SILVER_CAVE_OUTSIDE);
|
||||
heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
|
||||
travel(SILVER_CAVE_ROOM_3, SILVER_CAVE);
|
||||
talkTo(2); // Red
|
||||
play();
|
||||
const log = p.log.join(" ");
|
||||
expect(log).toContain("RED\nwas defeated!");
|
||||
// RedCredits, then SPAWN_MT_SILVER outside the cave, with Red gone
|
||||
// for good and the party healed.
|
||||
expect(p.world.state.events.has(EVENT_RED_IN_MT_SILVER)).toBe(true);
|
||||
expect(p.world.mapId).toBe(SILVER_CAVE_OUTSIDE);
|
||||
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 23, y: 20 });
|
||||
expect(p.world.state.party.every((mon) => mon.hp === mon.stats[0])).toBe(true);
|
||||
expect(p.world.busy).toBe(false);
|
||||
reload();
|
||||
writeTestSave("red-beaten");
|
||||
}, 300_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM/reference.");
|
||||
|
||||
/** The cartridge's PlaceDiplomaOnScreen with the LCD kept off (so its
|
||||
* graphics load at once) and only frame waits stubbed: the tilemap, the
|
||||
* tiles it shows and DiplomaPalettes' palette 0 match diplomaScreen for
|
||||
* several names and textbox frames. */
|
||||
test("PlaceDiplomaOnScreen matches the native diploma", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const flat = (name: string) => {
|
||||
const { bank, address } = routine(name);
|
||||
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||
};
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
cgb: at("hCGB"),
|
||||
tilemap: at("wTilemap"),
|
||||
palettes: at("wBGPals1"),
|
||||
frame: at("wTextboxFrame"),
|
||||
name: at("wPlayerName"),
|
||||
};
|
||||
const routines = { font: routine("_LoadStandardFont"), place: routine("PlaceDiplomaOnScreen") };
|
||||
const skip = ["WaitBGMap", "DelayFrame", "DelayFrames", "EnableLCD", "DisableLCD"].map(flat);
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, routines, skip }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { diplomaScreen } = await import("/src/game/scripts/kanto.ts");
|
||||
const { textTiles } = await import("/src/game/rom/font.ts");
|
||||
const { rgb } = await import("/src/game/scripts/screen.ts");
|
||||
const rom = new Uint8Array(
|
||||
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
||||
);
|
||||
const assets = new CrystalAssets(rom);
|
||||
for (const p of skip) rom[p] = 0xc9;
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, n: number) => reference.writeMemory(p, n & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (r: { bank: number; address: number }) => [
|
||||
0x3e, r.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, r.address & 255, r.address >> 8,
|
||||
];
|
||||
const failures: unknown[] = [];
|
||||
for (const [index, name] of ["KAWA", "ABCDEFG", "ᴾᴷᴹᴺ", "GOLD"].entries()) {
|
||||
write(0xff70, 1);
|
||||
write(0xff40, 0);
|
||||
write(a.cgb, 1);
|
||||
write(a.frame, index * 2);
|
||||
[...textTiles(name), 0x50].forEach((tile, i) => write(a.name + i, tile));
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, ...call(routines.font), ...call(routines.place), 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((value, offset) => write(0xc000 + offset, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 100 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
const native = diplomaScreen(assets.reader, name, index * 2);
|
||||
const map = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i));
|
||||
write(0xff70, 5);
|
||||
const palette = Array.from({ length: 4 }, (_, i) => rgb(read(a.palettes + i * 2) | (read(a.palettes + i * 2 + 1) << 8)));
|
||||
write(0xff70, 1);
|
||||
write(0xff4f, 0);
|
||||
const tileDiff = [...new Set(native.map)].flatMap((tile) =>
|
||||
Array.from({ length: 16 }, (_, byte) => {
|
||||
const actual = read((tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte);
|
||||
return actual === native.tiles[tile * 16 + byte] ? [] : [{ tile, byte }];
|
||||
}).flat(),
|
||||
);
|
||||
const mapDiff = map.flatMap((tile, i) => (tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }]));
|
||||
if (read(0xc0f0) !== 0x42 || mapDiff.length || tileDiff.length || JSON.stringify(palette) !== JSON.stringify(native.palettes[0]))
|
||||
failures.push({ name, returned: read(0xc0f0) === 0x42, mapDiff: mapDiff.slice(0, 8), tileDiff: tileDiff.slice(0, 4), palette, native: native.palettes[0] });
|
||||
}
|
||||
reference.dispose();
|
||||
return failures;
|
||||
},
|
||||
{ a, routines, skip },
|
||||
);
|
||||
expect(result, JSON.stringify(result)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
/** The cartridge's MagnetTrain runs with its interrupts after a boot, in
|
||||
* Saffron's station at day; each frame (sampled in VBlank, after the
|
||||
* cutscene's handler) its OAM, hSCX and wLYOverrides' bands, the BG map
|
||||
* and attributes, the icon's tiles and the palettes in CRAM are compared
|
||||
* with MagnetTrainMachine's, both ways and for both players. */
|
||||
for (const toGoldenrod of [false, true])
|
||||
for (const female of [false, true])
|
||||
test(`MagnetTrain matches the cartridge frame by frame ${toGoldenrod ? "to Goldenrod" : "to Saffron"}${female ? " as Kris" : ""}`, async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
program: at("wOverworldMapBlocks"),
|
||||
stackTop: at("wStackTop"),
|
||||
scriptVar: at("wScriptVar"),
|
||||
gender: at("wPlayerGender"),
|
||||
timeOfDay: at("wTimeOfDay"),
|
||||
mapGroup: at("wMapGroup"),
|
||||
mapNumber: at("wMapNumber"),
|
||||
tileset: at("wMapTileset"),
|
||||
jumptable: at("wJumptableIndex"),
|
||||
position: at("wMagnetTrainPosition"),
|
||||
lyOverrides: at("wLYOverrides"),
|
||||
scx: at("hSCX"),
|
||||
};
|
||||
const train = routine("MagnetTrain");
|
||||
const TEARDOWN = 8;
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, train, toGoldenrod, female }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { MagnetTrainMachine } = await import("/src/game/scripts/magnet-train.ts");
|
||||
const { rgb } = await import("/src/game/scripts/screen.ts");
|
||||
const rom = new Uint8Array(
|
||||
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
||||
);
|
||||
const assets = new CrystalAssets(rom);
|
||||
const station = assets.map("25:9");
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
reference.setMuted(true);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
reference.advanceFrames(600);
|
||||
write(0xff70, 1);
|
||||
write(a.gender, female ? 1 : 0);
|
||||
write(a.timeOfDay, 1);
|
||||
write(a.mapGroup, 25);
|
||||
write(a.mapNumber, 9);
|
||||
write(a.tileset, station.tileset);
|
||||
write(a.scriptVar, toGoldenrod ? 1 : 0);
|
||||
write(a.jumptable, 0xff);
|
||||
const sentinel = a.program + 0x80;
|
||||
const program = [
|
||||
0xf3, 0x31, a.stackTop & 255, a.stackTop >> 8, 0xfb,
|
||||
0x3e, train.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, train.address & 255, train.address >> 8,
|
||||
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(sentinel, 0);
|
||||
program.forEach((v, i) => write(a.program + i, v));
|
||||
reference.setProgramCounter(a.program);
|
||||
const toLine = (line: number) => {
|
||||
for (let guard = 0; read(0xff44) !== line; guard++) {
|
||||
if (guard > 20000) throw new Error("The LCD does not reach the line.");
|
||||
reference.advanceCycles(16);
|
||||
}
|
||||
};
|
||||
const cram = (index: number, obj: boolean) => {
|
||||
write(obj ? 0xff6a : 0xff68, index);
|
||||
return read(obj ? 0xff6b : 0xff69);
|
||||
};
|
||||
type State = { oam: number[]; bands: number[]; map: number[]; attrs: number[]; tiles: number[]; bg: number[]; obj: number[] };
|
||||
const sample = (): State => {
|
||||
toLine(152);
|
||||
write(0xff4f, 0);
|
||||
const map = Array.from({ length: 18 * 32 }, (_, i) => read(0x9800 + i));
|
||||
write(0xff4f, 1);
|
||||
const attrs = Array.from({ length: 18 * 32 }, (_, i) => read(0x9800 + i) & 7);
|
||||
write(0xff4f, 0);
|
||||
const state = {
|
||||
oam: Array.from({ length: 16 }, (_, i) => read(0xfe00 + i)),
|
||||
// Lines 1-143 take wLYOverrides (pushed at VBlank); line 0
|
||||
// takes hSCX, which already holds the next frame's here.
|
||||
bands: [read(a.lyOverrides), read(a.lyOverrides + 47), read(a.lyOverrides + 95)],
|
||||
map,
|
||||
attrs,
|
||||
tiles: Array.from({ length: 128 }, (_, i) => read(0x8000 + i)),
|
||||
bg: Array.from({ length: 64 }, (_, i) => cram(i, false)),
|
||||
obj: Array.from({ length: 16 }, (_, i) => cram(i, true)),
|
||||
};
|
||||
reference.advanceCycles(456 * 140);
|
||||
return state;
|
||||
};
|
||||
// Wait for the first run through: its jumptable entry leaves 1.
|
||||
let guard = 0;
|
||||
while (read(a.jumptable) !== 1 && guard++ < 300) reference.advanceFrames(1);
|
||||
const original: State[] = [];
|
||||
for (let frame = 0; frame < 600 && read(sentinel) !== 0x42; frame++) original.push(sample());
|
||||
const time = 1;
|
||||
const host = {
|
||||
reader: assets.reader,
|
||||
female,
|
||||
tiles: () => assets.tileset(station).graphics,
|
||||
palettes: () => ({
|
||||
bg: assets.palettes({ ...station, environment: 1 }, time),
|
||||
obj: Array.from({ length: 8 }, (_, i) => assets.objectPalette(i, time)),
|
||||
}),
|
||||
};
|
||||
const machine = new MagnetTrainMachine(host, toGoldenrod);
|
||||
const tiles = machine.tiles();
|
||||
const word = (c: number[]) => Math.round((c[0] * 31) / 255) | (Math.round((c[1] * 31) / 255) << 5) | (Math.round((c[2] * 31) / 255) << 10);
|
||||
const native: State[] = [];
|
||||
const present = () => {
|
||||
const screen = machine.screen(tiles, "");
|
||||
const oam = screen.sprites.flatMap((s) => [
|
||||
(s.y + 16) & 255,
|
||||
(s.x + 8) & 255,
|
||||
s.tile - 0x100,
|
||||
(s.behind ? 0x80 : 0) | (s.flipY ? 0x40 : 0) | (s.flipX ? 0x20 : 0) | s.palette,
|
||||
]);
|
||||
const d = machine.debug();
|
||||
const pal = (list: number[][][]) => list.flatMap((p) => p.flatMap((c) => { const w = word(c); return [w & 255, w >> 8]; }));
|
||||
native.push({
|
||||
oam: [...oam, ...Array(16 - oam.length).fill(-1)].slice(0, 16),
|
||||
bands: [d.bands.bushes, d.bands.train, d.bands.bushes],
|
||||
map: machine.bg.flat(),
|
||||
attrs: machine.attrs.flat(),
|
||||
tiles: Array.from(tiles.subarray(0x100 * 16, 0x108 * 16)),
|
||||
bg: pal(screen.palettes as number[][][]),
|
||||
obj: pal(screen.objPalettes.slice(0, 2) as number[][][]),
|
||||
});
|
||||
};
|
||||
machine.step();
|
||||
present();
|
||||
machine.setPalettes();
|
||||
present();
|
||||
while (!machine.done) {
|
||||
machine.step();
|
||||
present();
|
||||
}
|
||||
// The emulator's OAM keeps cleared sprites' bytes; compare the
|
||||
// four the icon uses, or nothing before it appears.
|
||||
const same = (o: State, n: State) => {
|
||||
const oam = n.oam[0] === -1 ? true : JSON.stringify(o.oam) === JSON.stringify(n.oam);
|
||||
return (
|
||||
oam &&
|
||||
JSON.stringify([o.bands, o.map, o.attrs, o.tiles, o.bg, o.obj]) ===
|
||||
JSON.stringify([n.bands, n.map, n.attrs, n.tiles, n.bg, n.obj])
|
||||
);
|
||||
};
|
||||
// The first sample can fall a frame either side of the first run
|
||||
// through: align on the offset where the frames agree.
|
||||
const offset = [0, 1, -1].find((off) => native.slice(2, 40).every((n, i) => original[i + 2 + off] && same(original[i + 2 + off], n))) ?? 0;
|
||||
const mismatches: unknown[] = [];
|
||||
// The first sample falls in the VBlank of the first run through,
|
||||
// before its overrides are pushed: compare from the next frame.
|
||||
native.forEach((n, i) => {
|
||||
if (i === 0) return;
|
||||
const o = original[i + offset];
|
||||
if (mismatches.length >= 4) return;
|
||||
if (!o) return void mismatches.push({ frame: i, original: "missing" });
|
||||
if (same(o, n)) return;
|
||||
const diff: Record<string, unknown> = { frame: i };
|
||||
for (const key of ["oam", "bands", "map", "attrs", "tiles", "bg", "obj"] as const) {
|
||||
const cells = o[key].flatMap((value, index) => (value === n[key][index] ? [] : [`${index}:${value}/${n[key][index]}`]));
|
||||
if (cells.length) diff[key] = cells.slice(0, 10).join(" ");
|
||||
}
|
||||
mismatches.push(diff);
|
||||
});
|
||||
return { original: original.length - offset, native: native.length, offset, mismatches };
|
||||
},
|
||||
{ a, train, toGoldenrod, female },
|
||||
);
|
||||
expect(result.mismatches, JSON.stringify(result)).toEqual([]);
|
||||
// The cartridge's teardown frames follow the arrival.
|
||||
expect(result.original).toBe(result.native + TEARDOWN);
|
||||
});
|
||||
Reference in new issue
Block a user