Merge branch 'capture-and-catching-tutorial' into main
Checks / web (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 17:15:31 +02:00
commit 5a0fe1e313
45 files changed
+7570 -592

No files matched your search

+5 -1
View File
@@ -12,7 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname.
Route 31 and beyond, the PC, party and bag menus, other items in battle, decorations and audio are still being ported. Catching with six Pokémon in your party needs the PC and is refused for now. Unported neighboring areas appear at the edges but cannot be entered yet.
Use Start or Select for Key Items, including the Bicycle when owned. The poster opens the Town Map.
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
Route 31 and beyond, full party and bag menus, moving Pokémon between boxes, the stats screen, other items in battle and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run
+27 -1
View File
@@ -34,7 +34,7 @@ flowchart LR
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only |
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets and money. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets, money and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
@@ -49,3 +49,29 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO
The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling.
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Battles receive the script's draft party, bag and Pokédex, so a catch, a thrown ball or a nickname commits only with the script that started the battle. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
The overworld extension keeps player travel mode, backup-warp data and Flash
state in version-3 saves, with defaults for older saves. Forced terrain and
turn-in-place timing run through the idle movement path. Dynamic warps and
elevators retain their return destination. Block mutations use a per-map working
copy and a revision counter, so rollback and the minimap see the same terrain.
Surf, Cut and Strength use the ROM scripts. Start/Select opens the Key Items
menu, pausing overworld ticks while choosing the Bicycle. The bedroom PC edits
decorations on the script draft and reloads the room when the menu commits.
`scripts/pc.ts` runs the Pokémon Center and house PCs on the same draft, so a
refused step leaves boxes and items unchanged. `party/storage.ts` holds the
boxes and the PC's items. A `memscript` effect queues Bill's call (like
`LoadMemScript`); the world keeps it in memory only and runs it before scene
scripts.
The wall/poster Town Map is a ROM-backed canvas presented by a script effect.
`TilesetAnimator` translates the ROM animation command tables, advancing two
video frames per overworld tick. It owns mutable graphics separately from the
asset cache. The renderer records background color indices for grass sprite
priority and applies special tileset palettes. Animation comparisons cover
Johto, Ice Path, restless Ilex Forest and Tin Tower. The live forest engine
flag selects the Celebi animation; the later story remains gated. Palette
comparisons execute the original LoadMapPals routine across 192 cases.
Movement traces compare event flags and check destinations across map-entry
boundaries; controlled scenes also compare scripted pose visibility and lift.
+8 -7
View File
@@ -25,14 +25,15 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. |
| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only maps explicitly listed in NATIVE_MAPS can be entered. Location names, scene-variable owners and connection offsets come from the ROM. |
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Terrain animation and other special palettes pending. |
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Dynamic Pokémon icons and room decorations pending. |
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Surfing remains absent; turn timing still needs comparison. |
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Native tileset animation and special palettes; Johto, Ice Path, restless Ilex Forest and Tin Tower each match 96 reference VRAM frames. 192 palette cases match the original routine. |
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Variable sprites and Pokémon icons decoded; grass priority supported. Owned room decorations support selection, removal, slot placement, descriptions and validated saves. |
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Forced movement, Surf, Cut, Strength, cycling rules, pits and elevators implemented. Fourteen reference traces compare turn timing, actors and event flags; map-entry boundaries verify the completed destination. Bicycle access uses the Key Items menu. |
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` moves the follower into the leader's previous square. |
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), the empty-room decoration toggles, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. |
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Moving Pokémon, the stats screen and printing are not ported. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. PC boxes, TMs and HMs are refused. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks.
@@ -87,7 +88,7 @@ A trainer sighting whose script is refused is not restarted until the player mov
## Next slice
Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. Next: the PC and boxes, then party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. The PC and boxes are ported. Next: party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
## Reflect and Light Screen
+60 -16
View File
@@ -16,7 +16,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
| 1. Complete the overworld foundation | In progress |
| 1. Complete the overworld foundation | Done |
| 2. Opening story and first battle slice | Mostly done |
| 3. Complete Crystal | Early |
| 4. Modern mobile presentation and delivery | First increment done |
@@ -116,15 +116,28 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- `GetPokeBallWobble` runs with `WobbleProbabilities` for each shake and picks the break-free message. Every throw uses the ball and, if the Pokémon breaks free, the turn. In trainer battles the ball is blocked, but the ball and turn are still used.
- A caught Pokémon is reloaded as `LoadEnemyMon` does, which redraws the wild item roll (the item itself is kept). It keeps its current HP, status, item and DVs, and gets back the moves and full PP it appeared with. It joins the party with `SetCaughtData`, is registered as caught (with the new-data message once the Pokédex is owned) and can be nicknamed; a blank name keeps the species name. The Pokédex page itself is not presented yet.
- Route 29's `Route29Tutorial1/2` and `CatchingTutorialDudeScript` run from the ROM. `catchtutorial` runs the battle as "DUDE": no Pokémon is sent out, the Dude's own Poké Ball always catches, and the Rattata is only seen, not kept. Messages advance on their own, as with `CatchTutorial`'s auto input.
- A catch with six Pokémon in the party would go to the PC (`SendMonIntoBox`), which is not ported, so the battle is refused and discarded. Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet.
- A catch with six Pokémon in the party goes to the current box (see the PC section below). Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet.
- A local stress run of 2,160 wild battles (three starters, ten early species, six levels) caught 2,047 Pokémon without refusals.
- Next: the PC and boxes, player switching and other items in battle, then party and bag menus.
## Implemented: the PC and boxes
- `TileCollisionStdScripts` is read from the ROM, so facing a `COLL_PC` tile (in any direction, as in the original) runs `PCScript`. Bookshelves, TVs and similar tiles no longer need the player to face up.
- `PokemonCenterPC` checks for a Pokémon, then lists the PCs from `PokemonCenterPC.WhichPC`: Bill's PC and the player's PC, plus Oak's PC once the Pokédex is owned. The Hall of Fame PC is refused, since no Hall of Fame entry can exist yet.
- Bill's PC: deposit (`BillsPC_CheckMail_PreventBlackout` keeps the last Pokémon and the last one that has not fainted; `RestorePPOfDepositedPokemon`), withdraw (`CalcLevel`, then `CalcMonStats` with stat experience, full HP and no status), release with confirmation ("Bye," with the species name), and change box: switch with the ROM's confirmation, name (a blank name keeps the old one) and print (only the empty-box message). Menus and messages come from the ROM; lists are presented natively with each Pokémon's level or each box's count.
- The player's PC withdraws, deposits (from every pocket; untossable items move one at a time) and tosses items (up to 50 stacks, `RemoveItemFromPocket` slot rules), and shows the empty mail box. The house PC adds DECORATION, which closes the PC after a change.
- Oak's PC rates the Pokédex from `OakRatings`.
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
- `VAR_BOXSPACE` reads the current box.
- Save format 3 gained `storage` (boxes, the current box and box names; a null name is the ROM default) and `pcItems`, migrated by absence to empty storage.
- Not ported: MOVE PKMN W/O MAIL and the stats screen (both show a notice and return to the menu), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
- Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus.
## 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 and type effectiveness. 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, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching, items other than balls in battle and the PC remain unfinished.
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 and type effectiveness. 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, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching and items other than balls in battle remain unfinished.
This is still not a complete new game. Party/bag/Pokégear menus, the PC, story progression beyond Route 30 and Mr. Pokémon's house, decorations, animated terrain, surfing and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
This is still not a complete new game. Full party/bag/Pokégear menus, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
## 1. Complete the overworld foundation
@@ -134,19 +147,50 @@ This is still not a complete new game. Party/bag/Pokégear menus, the PC, story
- [x] NPC random walks and spins, trainer sight lines
- [x] Item balls, hidden items and fruit trees with the daily reset
- [x] Connected transitions for every enabled map (New Bark Town to Route 30)
- [ ] Dynamic warps, elevators, ice, currents and the remaining forced movement
- [ ] Surfing, cycling, Cut trees and Strength boulders
- [ ] Decorations and tile changes (`changeblock`)
- [ ] Remaining NPC movement types (followers, bouncing) and movement commands
- [ ] Terrain animation, layering and special palettes
- [ ] Reference comparison of player turn timing
- [ ] Acceptance: outdoor, interior, cave and connected-map traces match the reference engine
- [x] Dynamic warps, elevators, ice, currents and forced movement
- [x] Native Surf, Cut and Strength interactions, cycling rules and boulder holes
- [x] Bicycle access from the Key Items menu with keyboard and touch controls
- [x] Tile changes (`changeblock`, `changemapblocks`) with rollback and minimap refresh
- [x] Room decorations, ownership, selection, removal, save validation and migration
- [x] Additional NPC movement types, variable sprites, Pokémon icons and movement-command decoding
- [x] Expanded original-engine coverage for scripted NPC poses and movement
- [x] Tileset animation, grass sprite priority and special map palettes
- [x] Restless forest animation and broader palette/animation reference comparisons
- [x] Reference comparison of player turn timing
- [x] Acceptance: outdoor, interior, cave and connected-map traces match the reference engine
Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison.
Movement now covers turn-in-place delays, bumps, ice, currents, forced tiles,
dynamic return warps, elevator menus, pits, Surf, Cut, Strength and cycling
rules. Map block changes use a working copy and roll back with a refused script.
Local-ROM tests exercise elevator travel, Burned Tower pits, Ice Path boulders,
field moves and cave/water encounters without enabling those later areas.
Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders) and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
Fourteen original-engine traces cover outdoor turns and bumps, connections in
both directions, ledges, house stairs and doors, the teacher's follow scene,
Ice Path, and eight controlled movement sequences: spin, teleport out/in,
skyfall, skyfall departure, tree shake, fishing, flicker and spin-flicker. They compare positions, facing,
selected actors and event flags. Map-entry ticks compare the destination
against the original's completed transition; other ticks compare directly.
Pose checks compare direction, visibility and vertical offset during scripted
movement, excluding blocking-text boundaries and subsequent random NPC behavior.
Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded.
Tileset animation runs from ROM command tables on a private graphics copy.
Johto, Ice Path, restless Ilex Forest and Tin Tower animation each match 96
original VRAM frames. All 36 tilesets run for 2,048 frames without modifying
cached graphics. An executed original palette routine matches 192 native
cases. These checks establish sampled engine behavior, not exhaustive
pixel-perfect screenshots or full Celebi story support.
The bedroom PC selects and puts away owned decorations, including left/right
doll placement. Room blocks, sprites and descriptions reflect saved choices.
The Town Map poster opens the native ROM-backed map. Start/Select opens Key
Items; using the Bicycle follows the native outdoor/indoor rules. Browser
checks cover both controls and decoration flows in Chromium and WebKit.
Full item/Pokémon PC storage and the other bag pockets belong to later work.
The foundation milestone is complete within these acceptance boundaries.
Next: finish the opening-story milestone below. Later maps remain gated until
their scripts and story progression are supported.
## 2. Opening story and first battle slice
@@ -160,7 +204,7 @@ Acceptance: representative outdoor/interior/cave/connected-map traces produce ma
- [x] Capture and the catching tutorial
- [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money
- [ ] Player switching, and items other than balls, in battle
- [ ] The PC and boxes (catching with a full party)
- [x] The PC and boxes (catching with a full party)
- [ ] The introduction's naming and clock screens
- [ ] Controlled-seed comparisons with the original beyond damage and type effectiveness
- [ ] Acceptance: one continuous playthrough of the opening quest with save/reload. The scenes are tested individually from fixtures so far.
@@ -169,7 +213,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture and the catching tutorial run now. Next: the PC, then the introduction's naming and clock screens.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial and the PC run now. Next: the introduction's naming and clock screens.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+145
View File
@@ -1,5 +1,8 @@
# Recovered Claude session
Latest continuation: see **The PC and boxes: 2026-09-25** at the end of
this file. Earlier sections are historical snapshots.
Recovered on 2026-09-23 from the local Claude Code transcript
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
The session stopped at its usage limit at 09:54 UTC, during research, without a final handoff.
@@ -330,3 +333,145 @@ Next: the PC and boxes (catching with a full party, `SendMonIntoBox`, Bill's
call), then player switching and other items in battle, and party and bag
menus. The Dude's back picture, ball animation and Pokédex page are not
presented.
## Overworld foundation recovery: 2026-09-25
Resumed Claude Code session `5d15e2b5-2020-4f49-83e7-eddee08c2315`, whose user
request was “Complete the overworld foundation.” Its last successful check
compared Johto and Ice Path animation with the original VRAM; the session then
hit its usage limit. The previous handoff ended at capture and was stale.
Preserved and validated Claude's uncommitted implementation:
- Turn delays, bumps, ice and currents, forced movement, dynamic return warps,
elevator menus, pits and arrival timing. Six original-engine traces cover
outdoor, connected, ledge, interior, scripted-follow and cave movement.
- Surf, Cut, Strength, Whirlpool/Waterfall interaction paths and cycling rules;
Strength boulders can fall into script-controlled holes. Later maps are
enabled only inside tests, not in the production map list.
- Working map blocks, `changeblock`, `changemapblocks`, rollback and minimap
invalidation; cave/water/Kanto encounter tables and `wildoff`.
- Additional NPC movement, variable sprites and Pokémon icons; all movement
command decoding and teleport/fishing/skyfall/shake/flicker presentation.
- Tileset animations on private graphics, grass sprite priority and special
map palettes. Johto and Ice Path match 96 original VRAM frames after matching
the reference animation state, including the scrolling scratch buffer.
Recovery changes:
- Removed `zz-grid.test.ts` and `zz-explore.spec.ts`, temporary diagnostics with
no assertions. The grid diagnostic also read a private ROM unconditionally.
- Added grass-priority/transparency and special-palette regressions, and ran
all 36 tilesets for 2,048 animation frames each while checking that cached
ROM graphics stay unchanged. `NUM_TILESETS` is verified by `check:reference`.
- Reconciled PLAN, ARCHITECTURE and NATIVE_PORT with the actual implementation
and its remaining gaps.
Validation: 271 unit tests passed with the local ROM. With an absent
`CRYSTAL_ROM_PATH`, 127 passed and 144 skipped. The full Chromium/WebKit run
passed 64 tests with 8 intentional WebKit skips; two of those passes were the
subsequently removed exploratory test, leaving 62 substantive browser checks.
The eight new original-engine comparisons run in Chromium only. Native and
reference builds, reference addresses/constants, release isolation and vendor
checks passed. No physical-device or CI run was performed. Changes remain
uncommitted; no push was made.
The whole foundation milestone is not complete. Continue with:
1. Room decorations: the two decoration specials still implement empty slots;
stored choices, ownership and the selection flow are absent.
2. Player bag-menu access to `GameWorld.useBicycle()`. Its rules are tested,
but it is not reachable through the current game controls.
3. Remaining reference acceptance: movement traces exclude the original's
map-entry tick and compare position/facing/selected actor positions, not
event flags. Most special poses and palettes lack executed-reference or
screenshot comparisons. The Celebi forest animation stays on frame one.
Do not clear those checklist items or enable later maps based on the existing
movement and rendering tests alone.
## Overworld foundation completed: 2026-09-25 (resumed from Codex)
After the recovery above, Codex continued with "Complete the foundation" and
stopped during its final validation, before updating this file. It had
already marked the milestone done in PLAN.md. Its work:
- Room decorations (`world/decorations.ts`): the initial bed and Town Map
poster, ownership through `DecorationAttributes` event flags, the bedroom
PC's selection and put-away flows (including left/right doll placement),
room blocks and variable sprites from saved choices, save validation and
migration of saves without decorations. The poster opens the native map.
- Start/Select opens Key Items; the Bicycle follows the native indoor and
outdoor rules, with keyboard and touch controls (`foundation-browser.spec.ts`).
- Movement traces now compare event flags as well as positions. Pose checks
compare direction, visibility and vertical offset during scripted
movement. An executed `LoadMapPals` matches 192 native palette cases.
Restless Ilex Forest and Tin Tower animation match original VRAM.
- Fixed fishing and flicker pose timing against the original routines.
Claude Code finished the increment. Codex had just added two pose traces.
Tree shake passed. Skyfall departure (`skyfall_top`) failed:
`StepFunction_SkyfallTop` leaves `OBJECT_SPRITE_Y_OFFSET` at $60 on the tick
that ends the command, and the offset persists through later steps. Actors
now keep a `yOffset`. Skyfall, teleports, the fishing bite and jumps clear it,
because those routines write the offset again. In the game, only the player
uses `skyfall_top` (Ruins of Alph chambers, Burned Tower), just before a warp.
The traces now number fourteen. PLAN and NATIVE_PORT were updated to match.
Checks: 276 unit tests with the local ROM (127 passed, 149 skipped without
it); 81 Chromium/WebKit browser tests passed and 19 were skipped intentionally
(the engine parity tests run in Chromium only); native and
reference builds; reference, vendor and release checks; `git diff --check`.
Not committed.
Next (PLAN section 2): the PC and boxes (a catch with a full party,
`SendMonIntoBox`, Bill's call), then the introduction's naming and clock
screens. Also open there: player switching and non-ball items in battle,
controlled-seed comparisons beyond damage and type effectiveness, and a
continuous opening-quest playthrough with save/reload. Later maps stay gated
until their scripts and story are supported.
## The PC and boxes: 2026-09-25
The foundation above was committed and pushed as `451464f`. This increment
continues PLAN section 2 with its first open item.
- `party/storage.ts`: 14 boxes of `BoxMon` (a party struct without status,
HP and stats), the current box, box names (null is the ROM's "BOX" and
number) and 50 PC item stacks. `SendGetMonIntoFromBox` deposit and
withdraw, `RestorePPOfDepositedPokemon` and `CalcLevel` are shared helpers.
`maxPP` (ComputeMaxPP) now also serves `HealParty`.
- `scripts/pc.ts`: `PokemonCenterPC` and the house PC. The Pokémon Center PC
opens Bill's PC (deposit, withdraw, release, change and name boxes) and the
player's PC (item withdraw, deposit and toss, the empty mail box), plus
Oak's rating once the Pokédex is owned. The house PC adds DECORATION. Menu
strings, lists and texts are read from the ROM (`PC` table in the profile,
verified by `check:reference`). The generic menu effect gained per-item
`details` (levels, box counts). A `notice` effect covers unported
options without aborting.
- Facing tiles now use the ROM's `TileCollisionStdScripts` in any direction,
as in the original (the old table only fired when facing up).
- Capture with a full party: `SendMonIntoBox` puts the catch first in the
current box, with nickname and `BallSentToPCText`. A full box keeps the ball
(`Ball_BoxIsFullMessage`). Filling the box sets `BATTLERESULT_BOX_FULL`.
`reloadmapafterbattle` then queues Bill's call through a new `memscript`
effect (`LoadMemScript`). It is kept in memory only and runs before scene
scripts.
- Save format 3 gained `storage` and `pcItems`, migrated by absence.
Not ported: MOVE PKMN W/O MAIL and the stats screen (notices), printing,
`givepoke` with a full party (still refused; unreachable in the open maps),
the save messages after a box switch, and the tutorial with a full party and
box (the original loops forever; refused).
Checks: 290 unit tests with the local ROM (130 passed, 160 skipped without
it); 83 Chromium/WebKit browser tests passed, 19 skipped intentionally
(including a new Pokémon Center PC flow in both browsers); native and
reference builds; reference, vendor and release checks; `git diff --check`.
Not committed.
Next (PLAN section 2): the introduction's naming and clock screens; player
switching and non-ball items in battle; MOVE PKMN W/O MAIL and the stats
screen with the party menu; a continuous opening playthrough.
+13 -6
View File
@@ -186,6 +186,8 @@ const ramNames = {
hiddenItemID: "wHiddenItemID",
runningTrainerBattleScript: "wRunningTrainerBattleScript",
callerContact: "wCallerContact",
surfingPlayerState: "wSurfingPlayerState",
strengthSpecies: "wStrengthSpecies",
moneyTemp: "hMoneyTemp",
};
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
@@ -199,14 +201,14 @@ for (const [key, symbol] of Object.entries(ramNames)) {
if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
}
// BATTLE entries are keyed by their PRET labels.
{
const block = /export const BATTLE = \{([\s\S]*?)\} as const;/.exec(profile)?.[1];
if (!block) throw new Error("Battle symbol table is missing.");
// BATTLE, OVERWORLD and PC entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (!block) throw new Error(`${table} symbol table is missing.`);
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
const label = quoted ?? bare;
if (symbols.get(label) !== Number(literal))
throw new Error(`Battle symbol mismatch: ${label}`);
throw new Error(`${table} symbol mismatch: ${label}`);
}
}
if (!profile.includes(`NUM_TRAINER_CLASSES = ${(symbols.get("Trainers") - symbols.get("TrainerGroups")) / 2};`))
@@ -294,6 +296,9 @@ const pretConstants = new Map();
pretConstants.set(match[2], stored);
if (match[1] === "shift_const") pretConstants.set(`${match[2]}_F`, value);
value += step;
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+const_value\s*-\s*(\w+)$/.exec(line))) {
const offset = /^\d+$/.test(match[2]) ? Number(match[2]) : pretConstants.get(match[2]);
if (offset !== undefined) pretConstants.set(match[1], value - offset);
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+([$%]?[0-9a-fA-F]+|\w+)$/.exec(line))) {
const target = match[2];
const resolved =
@@ -307,7 +312,9 @@ const pretConstants = new Map();
}
}
}
const battleConstants = readFileSync("src/game/battle/constants.ts", "utf8");
const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts"]
.map((file) => readFileSync(file, "utf8"))
.join("\n");
for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) {
for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim)) {
const expected = pretConstants.get(name);
+18
View File
@@ -262,6 +262,24 @@ export class BinjgbEmulator implements Emulator {
this.canvas!.putImageData(this.frame!, 0, 0);
}
/** Development-only: run a bounded number of CPU cycles, to align frame
* sampling with a scanline when comparing against native routines. */
advanceCycles(cycles: number) {
if (
!this.handle ||
this.raf ||
!Number.isInteger(cycles) ||
cycles < 1 ||
cycles > 70224
)
throw new Error("Pause the reference before bounded cycle stepping.");
const module = this.module!;
const target = module._emulator_get_ticks_f64(this.handle) + cycles;
for (let events = 0; events < 100; events++)
if (module._emulator_run_until_f64(this.handle, target) & 4) return;
throw new Error("Reference cycle budget exceeded.");
}
private fileData(
kind: "state" | "ext_ram",
action: (pointer: number, bytes: Uint8Array) => void,
+84 -25
View File
@@ -2,10 +2,18 @@ import { RomReader } from "../rom/reader";
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
import type { PokemonData } from "../rom/pokemon";
import type { Bag } from "../party/bag";
import {
MONS_PER_BOX,
currentBox,
restorePP,
type BoxMon,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
calcStats,
levelForExp,
type PartyMon,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
@@ -140,6 +148,8 @@ export interface BattleOptions {
text: () => TextContext;
/** The player's pack, needed to throw Poké Balls. */
bag?: Bag;
/** Bill's PC: a catch with a full party goes to the current box. */
storage?: PokemonStorage;
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
hasPokedex?: boolean;
/** SetCaughtData's word for a Pokémon caught at this level. */
@@ -148,6 +158,8 @@ export interface BattleOptions {
tutorial?: boolean;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
const BATTLERESULT_BOX_FULL = 0x80;
const SUB1 = 1,
SUB2 = 2,
SUB3 = 3,
@@ -844,12 +856,22 @@ export class Battle {
this.tossBall(item, slot);
return true;
}
// With a full party the Pokémon would go to the current box; if that is
// full too, the ball is not used (Ball_BoxIsFullMessage).
const storage = this.options.storage;
if (
this.options.party.length >= PARTY_LENGTH &&
(!storage || currentBox(storage).length >= MONS_PER_BOX)
) {
// The Dude's tutorial reaches this check too, and the original then
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
yield* this.say(BATTLE.BallBoxFullText);
return false;
}
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
// A full party sends the Pokémon into the current box (SendMonIntoBox).
// The tutorial never keeps its Pokémon, so it does not reach the box.
if (!tutorial && this.options.party.length >= PARTY_LENGTH)
throw new UnsupportedBattle("PC boxes are not ported yet.");
this.wildMon = 0;
yield* this.say(BATTLE.ItemUsedText);
@@ -920,10 +942,15 @@ export class Battle {
// NewPokedexEntry's Pokédex page is not presented yet.
yield* this.say(BATTLE.NewDexDataText);
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
this.tossBall(item, slot);
return true;
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
const mon: PartyMon = {
species,
item: enemy.item,
@@ -944,24 +971,59 @@ export class Battle {
stats: calcStats(data, species, enemy.dvs, level),
};
this.options.party.push(mon);
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (yield { kind: "yesno", text: "" }) {
// NamingScreen (NAME_MON): "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
if (!/^ *$/.test(answer)) mon.nickname = answer;
}
mon.nickname = yield* this.askNickname(name);
this.tossBall(item, slot);
return true;
}
/** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the
* chosen name, or the species name for a declined or blank one. */
private *askNickname(name: string): Gen<string> {
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (!(yield { kind: "yesno", text: "" })) return name;
// "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
return /^ *$/.test(answer) ? name : answer;
}
/** PokeBallEffect's .SendToPC: SendMonIntoBox puts the caught Pokémon
* first in the current box (from wEnemyMon, with restored PP), then
* SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for
* Bill's call; the nickname and BallSentToPCText follow. */
private *sendToPC(species: number, level: number, name: string): Gen {
const data = this.options.data,
storage = this.options.storage!,
box = currentBox(storage);
const mon: BoxMon = {
species,
item: this.enemy.mon.item,
moves: [...this.wildMoves] as BoxMon["moves"],
otId: this.options.playerId,
ot: this.options.playerName,
nickname: name,
exp: data.expAtLevel(data.baseData(species).growthRate, level),
statExp: [0, 0, 0, 0, 0],
dvs: this.enemy.mon.dvs,
pp: [...this.wildPP] as BoxMon["pp"],
happiness: C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData!(level),
level,
};
restorePP(data, mon);
box.unshift(mon);
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
mon.nickname = yield* this.askNickname(name);
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
yield* this.say(BATTLE.BallSentToPCText);
}
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
* selected slot if it matches, otherwise from the first matching slot. */
private tossBall(item: number, slot: number) {
@@ -2798,10 +2860,7 @@ export class Battle {
}
/** CalcLevel: the highest level whose exp does not exceed the total. */
private levelForExp(species: number, exp: number) {
const growth = this.options.data.baseData(species).growthRate;
let level = 1;
while (level < C.MAX_LEVEL && this.options.data.expAtLevel(growth, level + 1) <= exp) level++;
return level;
return levelForExp(this.options.data, species, exp);
}
/** LearnLevelMoves: moves the species learns at exactly this level. */
private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen {
+50 -11
View File
@@ -1,9 +1,13 @@
import type { PokemonData, Pocket } from "../rom/pokemon";
export interface ItemSlot {
item: number;
quantity: number;
}
/** The player's pack, with the original pocket capacities. */
export interface Bag {
items: { item: number; quantity: number }[];
balls: { item: number; quantity: number }[];
items: ItemSlot[];
balls: ItemSlot[];
keyItems: number[];
}
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
@@ -20,8 +24,8 @@ const MAX_ITEMS = 20,
/** PutItemInPocket: room exists if matching stacks can absorb the quantity
* or a new slot is free; stacks then fill to 99 before a new one starts. */
function putInPocket(
pocket: { item: number; quantity: number }[],
export function putInPocket(
pocket: ItemSlot[],
capacity: number,
item: number,
quantity: number,
@@ -80,9 +84,15 @@ export function hasItem(bag: Bag, item: number): boolean {
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
const slots = (list: unknown, capacity: number, pocket: Pocket) =>
/** A list of item stacks. Without `pocket`, any item with a pack pocket
* (the PC's list holds items from every pocket). */
export function validItemSlots(
data: PokemonData,
list: unknown,
capacity: number,
pocket?: Pocket,
): list is ItemSlot[] {
return (
Array.isArray(list) &&
list.length <= capacity &&
list.every(
@@ -92,13 +102,22 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
Number.isInteger(slot.quantity) &&
slot.quantity >= 1 &&
slot.quantity <= MAX_ITEM_STACK &&
itemIn(data, slot.item, pocket),
);
(pocket
? itemIn(data, slot.item, pocket)
: ["item", "ball", "key"].some((each) =>
itemIn(data, slot.item, each as Pocket),
)),
)
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
return (
Boolean(bag) &&
typeof bag === "object" &&
slots(bag.items, MAX_ITEMS, "item") &&
slots(bag.balls, MAX_BALLS, "ball") &&
validItemSlots(data, bag.items, MAX_ITEMS, "item") &&
validItemSlots(data, bag.balls, MAX_BALLS, "ball") &&
Array.isArray(bag.keyItems) &&
bag.keyItems.length <= MAX_KEY_ITEMS &&
bag.keyItems.every((item) => itemIn(data, item, "key"))
@@ -131,3 +150,23 @@ export function tossItem(bag: Bag, item: number, quantity: number): boolean {
}
return false;
}
/** RemoveItemFromPocket: take from the stack at `index` (wCurItemQuantity)
* if it holds `item`, otherwise from the first stack that does. Fails if
* that stack holds fewer. */
export function removeFromSlot(
list: ItemSlot[],
index: number,
item: number,
quantity: number,
): boolean {
const at =
list[index]?.item === item
? index
: list.findIndex((slot) => slot.item === item);
const slot = list[at];
if (!slot || quantity > slot.quantity) return false;
slot.quantity -= quantity;
if (!slot.quantity) list.splice(at, 1);
return true;
}
+64 -23
View File
@@ -30,9 +30,12 @@ export const PARTY_LENGTH = 6;
/** Characters a nickname can hold: the naming screen's letters and
* symbols, plus the digits and apostrophes of default species names. */
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
/** The same characters, for box names (up to eight). */
export const BOX_NAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,8}$/u;
/** BASE_HAPPINESS */
const BASE_HAPPINESS = 70;
const MAX_STAT_VALUE = 999;
const MAX_LEVEL = 100;
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
function squareRoot(value: number) {
@@ -159,11 +162,30 @@ export function generatePartyMon(
};
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
/** CalcLevel: the highest level whose experience the total reaches. */
export function levelForExp(
data: PokemonData,
value: unknown,
): value is PartyMon {
species: number,
exp: number,
): number {
const growth = data.baseData(species).growthRate;
let level = 1;
while (level < MAX_LEVEL && data.expAtLevel(growth, level + 1) <= exp)
level++;
return level;
}
/** ComputeMaxPP for a move with `ups` PP Ups: each adds a fifth of the base
* PP, clamped to 7, so a 40 PP move maxes at 61 instead of overflowing the
* packed byte at 64. The PP Ups stay in the top two bits. */
export function maxPP(data: PokemonData, move: number, ups: number): number {
const base = data.move(move).pp;
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
}
/** The box_struct fields of a stored Pokémon: everything but status, HP
* and stats. */
export function validBoxFields(value: unknown): boolean {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
@@ -171,28 +193,47 @@ export function validPartyMon(
Array.isArray(value) &&
value.length === length &&
value.every((item) => integer(item, 0, max));
try {
return (
Boolean(mon) &&
typeof mon === "object" &&
integer(mon.species, 1, 251) &&
integer(mon.item, 0, 255) &&
tuple(mon.moves, 4, 251) &&
integer(mon.otId, 0, 0xffff) &&
typeof mon.ot === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname) &&
integer(mon.exp, 0, 0xffffff) &&
tuple(mon.statExp, 5, 0xffff) &&
integer(mon.dvs, 0, 0xffff) &&
tuple(mon.pp, 4, 255) &&
integer(mon.happiness, 0, 255) &&
integer(mon.pokerus, 0, 255) &&
integer(mon.caughtData, 0, 0xffff) &&
integer(mon.level, 1, 100)
);
} catch {
return false;
}
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
data: PokemonData,
value: unknown,
): value is PartyMon {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
try {
if (
!mon ||
typeof mon !== "object" ||
!integer(mon.species, 1, 251) ||
!integer(mon.item, 0, 255) ||
!tuple(mon.moves, 4, 251) ||
!integer(mon.otId, 0, 0xffff) ||
typeof mon.ot !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) ||
typeof mon.nickname !== "string" ||
!NICKNAME_PATTERN.test(mon.nickname) ||
!integer(mon.exp, 0, 0xffffff) ||
!tuple(mon.statExp, 5, 0xffff) ||
!integer(mon.dvs, 0, 0xffff) ||
!tuple(mon.pp, 4, 255) ||
!integer(mon.happiness, 0, 255) ||
!integer(mon.pokerus, 0, 255) ||
!integer(mon.caughtData, 0, 0xffff) ||
!integer(mon.level, 1, 100) ||
!validBoxFields(mon) ||
!integer(mon.status, 0, 255) ||
!tuple(mon.stats, 6, MAX_STAT_VALUE) ||
!Array.isArray(mon.stats) ||
mon.stats.length !== 6 ||
!mon.stats.every((stat) => integer(stat, 0, MAX_STAT_VALUE)) ||
!integer(mon.hp, 0, mon.stats[0])
)
return false;
+133
View File
@@ -0,0 +1,133 @@
import type { PokemonData } from "../rom/pokemon";
import {
BOX_NAME_PATTERN,
calcStats,
levelForExp,
maxPP,
validBoxFields,
type PartyMon,
} from "./pokemon";
import { putInPocket, validItemSlots, type ItemSlot } from "./bag";
/** NUM_BOXES and MONS_PER_BOX (constants/pokemon_data_constants.asm). */
export const NUM_BOXES = 14;
export const MONS_PER_BOX = 20;
/** BOX_NAME_LENGTH minus the terminator. */
export const BOX_NAME_MAX = 8;
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
export const MAX_PC_ITEMS = 50;
/** The fields a stored BoxMon has, so no party fields slip into a box. */
const BOX_MON_FIELDS = 14;
/** box_struct: a party_struct without status, HP and stats. */
export type BoxMon = Omit<PartyMon, "status" | "hp" | "stats">;
/** Bill's PC: every box, wCurBox and wBoxNames. A null name is the one
* SetDefaultBoxNames writes ("BOX" and the box number, from the ROM). */
export interface PokemonStorage {
current: number;
names: (string | null)[];
boxes: BoxMon[][];
}
export const emptyStorage = (): PokemonStorage => ({
current: 0,
names: Array.from({ length: NUM_BOXES }, () => null),
boxes: Array.from({ length: NUM_BOXES }, () => []),
});
export const copyStorage = (storage: PokemonStorage): PokemonStorage => ({
current: storage.current,
names: [...storage.names],
boxes: structuredClone(storage.boxes),
});
export const currentBox = (storage: PokemonStorage) =>
storage.boxes[storage.current];
/** The first BOXMON_STRUCT_LENGTH bytes of a party struct. */
export function toBoxMon(mon: PartyMon): BoxMon {
const {
status: _status,
hp: _hp,
stats: _stats,
...box
} = structuredClone(mon);
return box;
}
/** RestorePPOfDepositedPokemon: each move's PP becomes its maximum,
* keeping the PP Ups (GetMaxPPOfMove through ComputeMaxPP). */
export function restorePP(data: PokemonData, mon: BoxMon) {
mon.pp = mon.moves.map((move, slot) =>
move ? maxPP(data, move, mon.pp[slot] >> 6) : mon.pp[slot],
) as BoxMon["pp"];
}
/** SendGetMonIntoFromBox with PC_DEPOSIT: the Pokémon joins the end of
* the current box with its PP restored. Returns false if the box is full. */
export function depositMon(
data: PokemonData,
storage: PokemonStorage,
mon: PartyMon,
): boolean {
const box = currentBox(storage);
if (box.length >= MONS_PER_BOX) return false;
const stored = toBoxMon(mon);
restorePP(data, stored);
box.push(stored);
return true;
}
/** SendGetMonIntoFromBox with PC_WITHDRAW: CalcLevel from the experience,
* then CalcMonStats with stat experience, no status and full HP. */
export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box);
const level = levelForExp(data, mon.species, mon.exp);
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
return { ...mon, level, status: 0, hp: stats[0], stats };
}
/** ReceiveItem into wNumPCItems. */
export function receivePCItem(
items: ItemSlot[],
item: number,
quantity: number,
): boolean {
return putInPocket(items, MAX_PC_ITEMS, item, quantity);
}
export function validStorage(value: unknown): value is PokemonStorage {
const storage = value as PokemonStorage;
return (
Boolean(storage) &&
typeof storage === "object" &&
Number.isInteger(storage.current) &&
storage.current >= 0 &&
storage.current < NUM_BOXES &&
Array.isArray(storage.names) &&
storage.names.length === NUM_BOXES &&
storage.names.every(
(name) =>
name === null ||
(typeof name === "string" &&
BOX_NAME_PATTERN.test(name) &&
!/^ *$/.test(name)),
) &&
Array.isArray(storage.boxes) &&
storage.boxes.length === NUM_BOXES &&
storage.boxes.every(
(box) =>
Array.isArray(box) &&
box.length <= MONS_PER_BOX &&
box.every(
(mon) =>
validBoxFields(mon) &&
Object.keys(mon as object).length === BOX_MON_FIELDS,
),
)
);
}
export function validPCItems(
data: PokemonData,
value: unknown,
): value is ItemSlot[] {
return validItemSlots(data, value, MAX_PC_ITEMS);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export class LocalMapView {
update(world: GameWorld) {
const map = world.map;
const key = `${map.id}:${world.timeOfDay}`;
const key = `${map.id}:${world.timeOfDay}:${world.blockRevision}`;
if (this.assets !== world.assets || this.mapKey !== key) {
this.buildTerrain(world);
this.assets = world.assets;
+14 -1
View File
@@ -10,7 +10,15 @@ export function drawTile(
x: number,
y: number,
palette: Palette,
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
options: {
transparent?: boolean;
flipX?: boolean;
flipY?: boolean;
/** Background color indices per pixel: a background tile records them;
* an OAM_PRIO sprite (`behind`) only draws over color 0. */
indices?: Uint8Array;
behind?: boolean;
} = {},
) {
const offset = tile * 16;
if (offset < 0 || offset + 16 > graphics.length) return;
@@ -25,6 +33,11 @@ export function drawTile(
const px = x + col,
py = y + row;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
if (options.indices) {
if (options.behind) {
if (options.indices[py * width + px]) continue;
} else if (!options.transparent) options.indices[py * width + px] = index;
}
const destination = (py * width + px) * 4,
color = palette[index];
target[destination] = color[0];
+37 -8
View File
@@ -1,7 +1,9 @@
import { OW } from "../world/constants";
import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
import { TilesetAnimator } from "./tileset-animation";
import {
BASE_VIEWPORT,
renderViewportForBox,
@@ -14,6 +16,11 @@ export class WorldRenderer {
private context: CanvasRenderingContext2D;
private image: ImageData;
private canvas: HTMLCanvasElement;
/** Background color indices of the current frame, for OAM priority. */
private indices = new Uint8Array(0);
/** The current tileset's animation, advanced two video frames per tick. */
private animator?: TilesetAnimator;
private animated = { key: "", ticks: 0 };
constructor(
private assets: CrystalAssets,
canvas: HTMLCanvasElement,
@@ -59,8 +66,19 @@ export class WorldRenderer {
}
render(world: GameWorld) {
const map = world.map,
tileset = this.assets.tileset(map),
palettes = this.assets.palettes(map, world.timeOfDay);
tileset = this.assets.tileset(map);
const key = `${map.tileset}:${map.group}`;
if (this.animated.key !== key) {
this.animator = new TilesetAnimator(this.assets, map, tileset.graphics);
this.animated = { key, ticks: world.ticks };
}
const animator = this.animator!;
// _AnimateTileset runs in VBlank: two video frames per overworld tick.
animator.advance(Math.min(240, (world.ticks - this.animated.ticks) * 2), world.timeOfDay === 3, world.liveState.flags.has(OW.ENGINE_FOREST_IS_RESTLESS));
this.animated.ticks = world.ticks;
const palettes = animator.palettes(this.assets.palettes(map, world.timeOfDay));
if (this.indices.length !== this.width * this.height)
this.indices = new Uint8Array(this.width * this.height);
const poses = world.poses();
const player = poses.find((pose) => pose.id === 0) ?? {
x: world.x * 16,
@@ -69,8 +87,9 @@ export class WorldRenderer {
// Keep the camera player-centered as the original engine does. Beyond the
// edges, connected maps are drawn like the original connection strips;
// elsewhere the map's own border block fills the view.
// An earthquake shakes the view vertically (StepFunction_ScreenShake).
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
cameraY = Math.round(player.y - (this.height / 2 - 8));
cameraY = Math.round(player.y - (this.height / 2 - 8)) + world.screenShake;
this.image.data.fill(255);
for (
let tileY = Math.floor(cameraY / 8);
@@ -97,11 +116,12 @@ export class WorldRenderer {
this.image.data,
this.width,
this.height,
tileset.graphics,
animator.graphics,
tile,
tileX * 8 - cameraX,
tileY * 8 - cameraY,
palettes[attribute & 7],
{ indices: this.indices },
);
}
}
@@ -136,6 +156,8 @@ export class WorldRenderer {
pose.frame,
time,
pose.palette,
pose.facing,
pose.inGrass,
);
const emote =
world.emote && poses.find((pose) => pose.id === world.emote!.object);
@@ -165,6 +187,8 @@ export class WorldRenderer {
frame: number,
time: number,
paletteIndex?: number,
facing?: number,
inGrass = false,
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
@@ -172,10 +196,11 @@ export class WorldRenderer {
paletteIndex ?? sprite.palette,
time,
);
for (const piece of this.assets.facing(
direction,
sprite.walking ? frame : 0,
)) {
const pieces =
facing !== undefined
? this.assets.facingTiles(facing)
: this.assets.facing(direction, sprite.walking ? frame : 0);
for (const piece of pieces) {
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
drawTile(
this.image.data,
@@ -190,6 +215,10 @@ export class WorldRenderer {
transparent: true,
flipX: Boolean(piece.attributes & 0x20),
flipY: Boolean(piece.attributes & 0x40),
// RELATIVE_ATTRIBUTES pieces (the lower half) take OAM_PRIO from
// IN_GRASS_F and hide behind the grass's colored pixels.
indices: this.indices,
behind: inGrass && Boolean(piece.attributes & 0x02),
},
);
}
+173
View File
@@ -0,0 +1,173 @@
import type { CrystalAssets } from "../rom/assets";
import { OVERWORLD, SYMBOLS } from "../rom/profile";
import type { GameMap, Palette } from "../world/types";
/** vTiles2 in VRAM bank 0: animations write tileset tiles from here. */
const VTILES2 = 0x9000;
/** wTileAnimBuffer, the scratch tile that scrolling animations use. */
const TILE_ANIM_BUFFER = 0xcf41;
/** Tile animation routines by ROM address (engine/tilesets/tileset_anims.asm). */
const R = OVERWORLD;
/** _AnimateTileset (engine/tilesets/tileset_anims.asm): the tileset's own
* list of (parameter, routine) pairs from data/tileset_anims.asm, run one
* entry per video frame. Routines are selected by their ROM address and
* translated natively; tiles are written to a private copy of the tileset
* graphics, and the palette animations to color 0 of two palettes. */
export class TilesetAnimator {
readonly graphics: Uint8Array;
private readonly script: number;
/** hTileAnimFrame, wTileAnimationTimer and hVBlankCounter. */
private frame = 0;
private timer = 0;
private vblank = 0;
private buffer = new Uint8Array(16);
/** Which of the water palette's colors 0-2 replaces color 0, and the
* dark cave entrance's yellow color 0 or 1. */
private water = 0;
private cave?: number;
constructor(
private assets: CrystalAssets,
map: GameMap,
graphics: Uint8Array,
) {
const r = assets.reader;
this.graphics = new Uint8Array(graphics);
const tileset = SYMBOLS.tilesets + map.tileset * 15;
this.script = r.near(tileset + 9, r.bank(R.WaitTileAnimation));
}
/** Run `frames` video frames; `dark` is wTimeOfDayPalset's darkness. */
advance(frames: number, dark: boolean, restless = false) {
for (let count = 0; count < frames; count++) {
this.vblank = (this.vblank + 1) & 0xff;
this.step(dark, restless);
}
}
/** Apply the palette animations to the map's palettes. */
palettes(palettes: Palette[]): Palette[] {
const result = [...palettes];
// AnimateWaterPalette: PAL_BG_WATER color 0 cycles through colors 0-2.
if (this.water) result[3] = [palettes[3][this.water], ...palettes[3].slice(1)];
if (this.cave !== undefined)
result[4] = [palettes[4][this.cave], ...palettes[4].slice(1)];
return result;
}
private step(dark: boolean, restless: boolean) {
const r = this.assets.reader,
entry = this.script + this.frame * 4;
this.frame = (this.frame + 1) & 0xff;
const parameter = r.word(entry),
routine = r.address(r.bank(R.WaitTileAnimation), r.word(entry + 2));
const bank = r.bank(R.WaitTileAnimation);
const at = (address: number) => r.address(bank, address);
switch (routine) {
case R.WaitTileAnimation:
return;
case R.DoneTileAnimation:
this.frame = 0;
return;
case R.StandingTileFrame8:
this.timer = (this.timer + 1) & 7;
return;
case R.StandingTileFrame:
this.timer = (this.timer + 1) & 0xff;
return;
case R.ScrollTileRightLeft:
this.timer = (this.timer + 1) & 7;
return this.scroll(parameter, this.timer & 4 ? "left" : "right");
case R.ScrollTileUpDown:
this.timer = (this.timer + 1) & 7;
return this.scroll(parameter, this.timer & 4 ? "down" : "up");
case R.ScrollTileLeft:
return this.scroll(parameter, "left");
case R.ScrollTileRight:
return this.scroll(parameter, "right");
case R.ScrollTileUp:
return this.scroll(parameter, "up");
case R.ScrollTileDown:
return this.scroll(parameter, "down");
case R.ReadTileToAnimBuffer:
this.buffer.set(this.memory(parameter));
return;
case R.WriteTileFromAnimBuffer:
this.memory(parameter).set(this.buffer);
return;
case R.AnimateWaterTile:
// A cycle of 4 frames, updating every other tick.
return this.write(parameter, R["AnimateWaterTile.WaterTileFrames"] + (this.timer & 6) * 8);
case R.AnimateFountainTile: {
const table = R["AnimateFountainTile.FountainTileFramePointers"];
return this.write(parameter, at(r.word(table + (this.timer & 7) * 2)));
}
case R.AnimateFlowerTile:
// CGB flower frames follow the DMG ones in .FlowerTileFrames.
return this.write(VTILES2 + 0x03 * 16, R["AnimateFlowerTile.FlowerTileFrames"] + ((this.timer & 2) + 1) * 16);
case R.ForestTreeLeftAnimation:
case R.ForestTreeLeftAnimation2:
case R.ForestTreeRightAnimation:
case R.ForestTreeRightAnimation2: {
const right = routine === R.ForestTreeRightAnimation || routine === R.ForestTreeRightAnimation2;
const alternate = routine === R.ForestTreeLeftAnimation2 || routine === R.ForestTreeRightAnimation2;
// GetForestTreeFrame returns the odd frame for all timer values >= 7.
const frame = restless ? ((this.timer >= 7 || (this.timer & 1) ? 1 : 0) ^ Number(alternate)) : 0;
return this.write(VTILES2 + (right ? 0x0f : 0x0c) * 16,
(right ? R.ForestTreeRightFrames : R.ForestTreeLeftFrames) + frame * 16);
}
case R.AnimateLavaBubbleTile1:
return this.write(VTILES2 + 0x5b * 16, R.LavaBubbleTileFrames + ((((this.timer & 6) >> 1) + 2) & 3) * 16);
case R.AnimateLavaBubbleTile2:
return this.write(VTILES2 + 0x38 * 16, R.LavaBubbleTileFrames + (this.timer & 6) * 8);
case R.AnimateTowerPillarTile: {
const pointer = at(parameter);
const offset = r.byte(R["AnimateTowerPillarTile.TowerPillarTileFrameOffsets"] + (this.timer & 7));
return this.write(r.word(pointer), at(r.word(pointer + 2)) + offset);
}
case R.AnimateWhirlpoolTile: {
const pointer = at(parameter);
return this.write(r.word(pointer), at(r.word(pointer + 2)) + (this.timer & 3) * 16);
}
case R.AnimateWaterPalette:
// Only on even ticks: colors 0, 1, 2, 1.
if (this.timer & 1) return;
this.water = (this.timer & 6) === 0 ? 0 : (this.timer & 6) === 4 ? 2 : 1;
return;
case R.FlickeringCaveEntrancePalette:
// Only in a dark cave: yellow color 0 alternates with color 1.
this.cave = dark ? (this.vblank & 2 ? 1 : 0) : undefined;
return;
default:
throw new Error("Tileset animation routine is not ported.");
}
}
/** A tile in vTiles2 (bank 0), or wTileAnimBuffer. */
private memory(address: number): Uint8Array {
if (address === TILE_ANIM_BUFFER) return this.buffer;
const tile = (address - VTILES2) >> 4;
if (address < VTILES2 || (address & 15) || tile >= 0x80)
throw new Error("Tileset animation writes outside the tiles.");
return this.graphics.subarray(tile * 16, tile * 16 + 16);
}
private write(destination: number, source: number) {
this.memory(destination).set(this.assets.reader.slice(source, 16));
}
/** ScrollTile*: bytes rotate for left/right; rows rotate for up/down. */
private scroll(address: number, direction: "left" | "right" | "up" | "down") {
const tile = this.memory(address);
if (direction === "left" || direction === "right")
for (let i = 0; i < 16; i++)
tile[i] =
direction === "left"
? ((tile[i] << 1) | (tile[i] >> 7)) & 0xff
: ((tile[i] >> 1) | (tile[i] << 7)) & 0xff;
else {
const rows = Array.from({ length: 8 }, (_, row) => tile.slice(row * 2, row * 2 + 2));
for (let row = 0; row < 8; row++)
tile.set(rows[direction === "up" ? (row + 1) % 8 : (row + 7) % 8], row * 2);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { GameWorld } from "../world/world";
import { OVERWORLD, SYMBOLS } from "../rom/profile";
import { decompress } from "../rom/lz";
import { drawTile } from "./pixels";
/** The ROM's Town Map, used by wall maps and the bedroom poster. */
export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
const { assets } = world,
r = assets.reader;
const landmark = world.map.landmark;
const map = landmark >= 47 ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
const palette =
world.avatar === "kris"
? OVERWORLD.FemalePokegearPals
: OVERWORLD.MalePokegearPals;
canvas.width = 160;
canvas.height = 144;
const context = canvas.getContext("2d")!;
const image = context.createImageData(160, 144);
image.data.fill(255);
for (let i = 0; i < 360; i++) {
const tile = r.byte(map + i);
const packed =
tile < 0x60 ? r.byte(OVERWORLD["TownMapPals.PalMap"] + (tile >> 1)) : 0;
const color = (tile & 1 ? packed >> 4 : packed) & 7;
drawTile(
image.data,
160,
144,
graphics,
tile,
(i % 20) * 8,
Math.floor(i / 20) * 8,
assets.palette(palette + color * 8),
);
}
context.putImageData(image, 0, 0);
// Landmark coordinates are the same ones TownMapPlayerIcon uses.
const p = SYMBOLS.landmarks + landmark * 4;
context.fillStyle = "#b32628";
context.fillRect(r.byte(p) - 10, r.byte(p + 1) - 18, 4, 4);
canvas.setAttribute("aria-label", `Town Map: ${world.map.location}`);
}
+87 -15
View File
@@ -6,11 +6,13 @@ import {
NATIVE_MAPS,
NUM_ENGINE_FLAGS,
NUM_SPRITEMOVEDATA,
OVERWORLD,
RAM,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import { PokemonData } from "./pokemon";
import { OW } from "../world/constants";
import type {
Direction,
GameMap,
@@ -308,6 +310,35 @@ export class CrystalAssets {
];
});
}
/** LoadSpecialMapPalette: tilesets with their own eight palettes, which
* do not follow the time of day. */
private specialPalettes(map: GameMap): Palette[] | undefined {
const eight = (address: number) =>
Array.from({ length: 8 }, (_, i) => this.palette(address + i * 8));
switch (map.tileset) {
case OW.TILESET_POKECOM_CENTER:
return eight(OVERWORLD.PokeComPalette);
case OW.TILESET_BATTLE_TOWER_INSIDE:
return eight(OVERWORLD.BattleTowerInsidePalette);
case OW.TILESET_ICE_PATH:
// The Hall of Fame shares the tileset but is INDOOR.
return (map.environment & 7) === OW.INDOOR ? undefined : eight(OVERWORLD.IcePathPalette);
case OW.TILESET_HOUSE:
return eight(SYMBOLS.housePalettes);
case OW.TILESET_RADIO_TOWER:
return eight(OVERWORLD.RadioTowerPalette);
case OW.TILESET_MANSION: {
// LoadMansionPalette: MansionPalette1, then yellow from
// MansionPalette2, water from palette 6 and roof from palette 8.
const palettes = eight(OVERWORLD.MansionPalette1);
palettes[4] = this.palette(OVERWORLD.MansionPalette2);
palettes[3] = this.palette(OVERWORLD.MansionPalette1 + 6 * 8);
palettes[6] = this.palette(OVERWORLD.MansionPalette1 + 8 * 8);
return palettes;
}
}
return undefined;
}
palettes(map: GameMap, time: number): Palette[] {
const r = this.reader;
const group =
@@ -316,13 +347,11 @@ export class CrystalAssets {
r.bank(SYMBOLS.environmentColors),
) +
time * 8;
const palettes = Array.from({ length: 8 }, (_, i) =>
this.palette(
map.tileset === 5
? SYMBOLS.housePalettes + i * 8
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
),
);
const palettes =
this.specialPalettes(map) ??
Array.from({ length: 8 }, (_, i) =>
this.palette(SYMBOLS.backgroundPalettes + r.byte(group + i) * 8),
);
if (map.environment === 1 || map.environment === 2) {
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
const colors = this.palette(roof);
@@ -344,13 +373,15 @@ export class CrystalAssets {
}
throw new Error("Grass collision list is unterminated.");
}
/** JohtoGrassWildMons for a map: encounter rates and seven slots per
* time of day (data/wild/johto_grass.asm). */
/** JohtoGrassWildMons or KantoGrassWildMons for a map: encounter rates
* and seven slots per time of day (data/wild/johto_grass.asm). */
grassWildMons(
id: string,
kanto = false,
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
const r = this.reader;
for (let p = SYMBOLS.johtoGrassWildMons, i = 0; i < 256; i++, p += 47) {
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
for (let p = table, i = 0; i < 256; i++, p += 47) {
const group = r.byte(p);
if (group === 0xff) return undefined;
if (`${group}:${r.byte(p + 1)}` !== id) continue;
@@ -365,6 +396,28 @@ export class CrystalAssets {
}
throw new Error("Wild encounter data is unterminated.");
}
/** JohtoWaterWildMons or KantoWaterWildMons: one rate and three slots
* (data/wild/johto_water.asm). */
waterWildMons(
id: string,
kanto = false,
): { rate: number; slots: { level: number; species: number }[] } | undefined {
const r = this.reader;
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
for (let p = table, i = 0; i < 256; i++, p += 9) {
const group = r.byte(p);
if (group === 0xff) return undefined;
if (`${group}:${r.byte(p + 1)}` !== id) continue;
return {
rate: r.byte(p + 2),
slots: Array.from({ length: 3 }, (_, slot) => ({
level: r.byte(p + 3 + slot * 2),
species: r.byte(p + 4 + slot * 2),
})),
};
}
throw new Error("Wild encounter data is unterminated.");
}
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
dailyEngineFlags(): number[] {
@@ -381,13 +434,20 @@ export class CrystalAssets {
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
spriteMovement(type: number): {
fn: number;
/** OBJECT_ACTION_* the object starts with. */
action: number;
flags: number;
paletteFlags: number;
} {
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
const r = this.reader,
p = SYMBOLS.spriteMovementData + type * 6;
return { fn: r.byte(p), flags: r.byte(p + 3), paletteFlags: r.byte(p + 5) };
return {
fn: r.byte(p),
action: r.byte(p + 2),
flags: r.byte(p + 3),
paletteFlags: r.byte(p + 5),
};
}
/** Emote bubble graphics (data/sprites/emotes.asm). */
emote(index: number): Uint8Array {
@@ -399,13 +459,25 @@ export class CrystalAssets {
r.byte(entry + 2),
);
}
/** GetSprite: overworld sprites, and Pokémon drawn from their party
* menu icons (GetMonSprite). Variable sprites are resolved by the world
* from wVariableSprites first; day-care Pokémon are not ported. */
sprite(id: number): Sprite | undefined {
// Variable decorations and Pokémon icons need separate decoders.
if (id < 1 || id >= 0xa0) return undefined;
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader,
p = SYMBOLS.sprites + (id - 1) * 6;
const r = this.reader;
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
// LoadOverworldMonIcon: eight tiles, two frames in the down and up
// standing slots, with palette 0.
const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON);
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
const pointer = r.near(OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers));
const sprite = { graphics: r.slice(pointer, 8 * 16), palette: 0, walking: true };
this.sprites.set(id, sprite);
return sprite;
}
if (id < 1 || id > OW.NUM_OVERWORLD_SPRITES) return undefined;
const p = SYMBOLS.sprites + (id - 1) * 6;
const length = r.byte(p + 2),
walking = r.byte(p + 4) === 1;
const sprite = {
+176
View File
@@ -155,6 +155,8 @@ export const RAM = {
hiddenItemID: 0xd040,
runningTrainerBattleScript: 0xd04d,
callerContact: 0xd03f,
surfingPlayerState: 0xd1eb,
strengthSpecies: 0xd1ef,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -174,6 +176,178 @@ export const ENGINE_FLAGS = {
allFruitTrees: 84,
} as const;
/** Overworld scripts, routines and tables, keyed by their PRET labels so
* that `npm run check:reference` verifies each address by name. */
export const OVERWORLD = {
"OverworldTownMap": 0xc2c0,
"TownMapGFX": 0xf8ba0,
"JohtoMap": 0x91fff,
"KantoMap": 0x92168,
"TownMapPals.PalMap": 0x91f4b,
"MalePokegearPals": 0xb729,
"FemalePokegearPals": 0xb759,
"DecorationAttributes": 0x26a4f,
"DecorationNames": 0x26b8d,
"_PlayerDecorationMenu.category_pointers": 0x267aa,
"PlayersHousePC": 0xc2e7,
"PutAwayTheDecoText": 0x26ed1,
"NothingToPutAwayText": 0x26ed6,
"SetUpTheDecoText": 0x26edb,
"PutAwayAndSetUpText": 0x26ee0,
"AlreadySetUpText": 0x26ee5,
"WhichSidePutOnText": 0x26e41,
"WhichSidePutAwayText": 0x26e6b,
"DecorationDesc_PosterPointers": 0x26f84,
"DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript": 0x26fd5,
"DecorationDesc_GiantOrnament.BigDollScript": 0x26fe3,
Script_ForcedMovement: 0x1253d,
FallIntoMapScript: 0x96c38,
"_Sine.sinetable_u10": 0x850b,
AskFloorElevatorText: 0x1350d,
Elevator_CurrentFloorText: 0x13537,
ElevatorFloorNames: 0x13583,
JohtoWaterWildMons: 0x2b11d,
KantoGrassWildMons: 0x2b274,
KantoWaterWildMons: 0x2b7f7,
WaterMonProbTable: 0x2a1d9,
SpriteMons: 0x14495,
MonMenuIcons: 0x8eac4,
IconPointers: 0x8ebbf,
AskSurfScript: 0xca2c,
"UsedSurfScript.stubbed_fn": 0xc9a2,
AskCutScript: 0xd1a9,
"AskCutScript.CheckMap": 0xd1ba,
CantCutScript: 0xd1cd,
CutDownTreeOrGrass: 0xc810,
GetPartyNickname: 0xc706,
CutTreeBlockPointers: 0xc862,
WhirlpoolBlockPointers: 0xc8a4,
TryStrengthOW: 0xcd78,
SetStrengthFlag: 0xcd12,
Script_AskWaterfall: 0xcb86,
Script_CantDoWaterfall: 0xcb7e,
"Script_UsedWaterfall.CheckContinueWaterfall": 0xcb38,
Script_AskWhirlpoolOW: 0xce6e,
Script_MightyWhirlpool: 0xce66,
DisappearWhirlpool: 0xce1d,
ChrisStateSprites: 0x141b7,
KrisStateSprites: 0x141c0,
Script_GetOnBike: 0xd13e,
Script_GetOffBike: 0xd158,
Script_CantGetOffBike: 0xd171,
UpdatePlayerSprite: 0xe4a,
PlayMapMusic: 0x3cdf,
SurfStartStep: 0x8379,
UpdateTimePals: 0x485,
WaitTileAnimation: 0xfc2fe,
DoneTileAnimation: 0xfc2fb,
StandingTileFrame8: 0xfc2ff,
StandingTileFrame: 0xfc673,
AnimateWaterTile: 0xfc402,
"AnimateWaterTile.WaterTileFrames": 0xfc41c,
AnimateWaterPalette: 0xfc6d7,
AnimateFlowerTile: 0xfc56d,
"AnimateFlowerTile.FlowerTileFrames": 0xfc58c,
AnimateFountainTile: 0xfc387,
"AnimateFountainTile.FountainTileFramePointers": 0xfc3a2,
ForestTreeLeftAnimation: 0xfc45c,
ForestTreeRightAnimation: 0xfc4c4,
ForestTreeLeftAnimation2: 0xfc4f2,
ForestTreeRightAnimation2: 0xfc51c,
ForestTreeLeftFrames: 0xfc484,
ForestTreeRightFrames: 0xfc4a4,
AnimateLavaBubbleTile1: 0xfc5cc,
AnimateLavaBubbleTile2: 0xfc5eb,
LavaBubbleTileFrames: 0xfc605,
AnimateTowerPillarTile: 0xfc645,
"AnimateTowerPillarTile.TowerPillarTileFrameOffsets": 0xfc66b,
AnimateWhirlpoolTile: 0xfc678,
ReadTileToAnimBuffer: 0xfc6a2,
WriteTileFromAnimBuffer: 0xfc696,
ScrollTileRightLeft: 0xfc309,
ScrollTileUpDown: 0xfc318,
ScrollTileLeft: 0xfc327,
ScrollTileRight: 0xfc33b,
ScrollTileUp: 0xfc34f,
ScrollTileDown: 0xfc36a,
FlickeringCaveEntrancePalette: 0xfc71e,
PokeComPalette: 0x49501,
BattleTowerInsidePalette: 0x49550,
IcePathPalette: 0x4959f,
RadioTowerPalette: 0x4963d,
MansionPalette1: 0x4967d,
MansionPalette2: 0x496fe,
} as const;
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
* data, strings and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const PC = {
TileCollisionStdScripts: 0x13681,
PokemonCenterPC: 0x1559a,
"PokemonCenterPC.WhichPC": 0x1562c,
"PokemonCenterPC.Jumptable": 0x155e6,
"PC_CheckPartyForPokemon.PokecenterPCCantUseText": 0x15663,
PokecenterPCTurnOnText: 0x15a27,
PokecenterPCWhoseText: 0x15a2c,
PokecenterBillsPCText: 0x15a31,
PokecenterPlayersPCText: 0x15a36,
PokecenterOaksPCText: 0x15a3b,
PokecenterPCOaksClosedText: 0x15a40,
PlayersPCTurnOnText: 0x156ff,
PlayersPCAskWhatDoText: 0x157cc,
"PlayersPCMenuData.WhichPC": 0x157ac,
"PlayersPCMenuData.PlayersPCMenuPointers": 0x15746,
"PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText": 0x15850,
"PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText": 0x15855,
"PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText": 0x1585a,
"PlayerDepositItemMenu.PlayersPCNoItemsText": 0x158c7,
"PlayerDepositItemMenu.PlayersPCHowManyDepositText": 0x1596e,
"PlayerDepositItemMenu.PlayersPCDepositItemsText": 0x15973,
"PlayerDepositItemMenu.PlayersPCNoRoomDepositText": 0x15978,
"TossItemFromPC.ItemsTossOutHowManyText": 0x12a45,
"TossItemFromPC.ItemsThrowAwayText": 0x12a4a,
"TossItemFromPC.ItemsDiscardedText": 0x12a4f,
"TossItemFromPC.ItemsTooImportantText": 0x12a5b,
"_PlayerMailBoxMenu.EmptyMailboxText": 0x447b4,
"_BillsPC.PCGottaHavePokemonText": 0xe417,
"_BillsPC.PCWhatText": 0xe43a,
"_BillsPC.strings": 0xe47f,
"BillsPCDepositMenuHeader.MenuData": 0xe2545,
"BillsPC_Withdraw.MenuData": 0xe2739,
"BillsPC_ChangeBoxSubmenu.MenuData": 0xe3783,
PCString_ChooseaPKMN: 0xe34dd,
PCString_WhatsUp: 0xe34ea,
PCString_ReleasePKMN: 0xe34f4,
PCString_ItsYourLastPKMN: 0xe350f,
PCString_NoMoreUsablePKMN: 0xe3531,
PCString_ReleasedPKMN: 0xe3551,
PCString_Bye: 0xe355e,
PCString_Stored: 0xe3563,
PCString_Got: 0xe356b,
PCString_BoxFull: 0xe3575,
PCString_PartyFull: 0xe3586,
PCString_NoReleasingEGGS: 0xe3597,
"BillsPC_PlaceChooseABoxString.ChooseABox": 0xe37a1,
"BillsPC_PlaceEmptyBoxString_SFX.NoMonString": 0xe37d3,
"BillsPC_PrintBoxName.Current": 0xe36f1,
"BillsPC_PrintBoxCountAndCapacity.OutOf20": 0xe3668,
"BillsPC_RefreshTextboxes.CancelString": 0xe2c67,
"BillsPC_BoxName.PartyPKMN": 0xe2abd,
"NamingScreenJumptable.BoxNameString": 0x11822,
"SetDefaultBoxNames.Box": 0x5ccf,
ChangeBoxSaveText: 0x152a1,
OakPCText1: 0x266de,
OakPCText2: 0x266e3,
OakPCText3: 0x266e8,
OakPCText4: 0x2674c,
OakRatings: 0x2667f,
PhoneContacts: 0x9045f,
Script_ReceivePhoneCall: 0x90241,
} as const;
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
@@ -187,6 +361,8 @@ export const BATTLE = {
NewDexDataText: 0xedf0,
AskGiveNicknameText: 0xedf5,
BallBlockedText: 0xf824,
BallBoxFullText: 0xf838,
BallSentToPCText: 0xedeb,
BallDontBeAThiefText: 0xf829,
WobbleProbabilities: 0xf9ba,
"CatchTutorial.Dude": 0x4e5da,
+15 -4
View File
@@ -9,6 +9,7 @@ import { WorldRenderer } from "./rendering/renderer";
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
fieldMenuOpen = false;
private renderer?: WorldRenderer;
private pressed = new Set<Button>();
private raf = 0;
@@ -26,6 +27,7 @@ export class NativeRuntime implements GameRuntime {
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
this.fieldMenuOpen = false;
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets);
const player = this.player();
@@ -57,20 +59,28 @@ export class NativeRuntime implements GameRuntime {
if (this.pressed.has(button) || !this.world) return;
this.pressed.add(button);
const world = this.world;
if (world.battle && this.battleInput?.(button)) {
if (this.battleInput?.(button)) {
this.publish();
return;
}
if (button === "a") world.interact();
if (button === "b") world.advanceDialogue();
if (["up", "down", "left", "right"].includes(button) &&
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
if (
["up", "down", "left", "right"].includes(button) &&
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno")
)
world.moveCursor(button as Direction);
if ((button === "start" || button === "select") && !world.busy)
world.notice =
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
this.publish();
}
useBicycle() {
this.fieldMenuOpen = false;
if (this.world && !this.world.useBicycle())
this.world.notice = "You can’t use the BICYCLE here.";
this.publish();
}
/** Answer a choice from a pointer or keyboard control outside the canvas. */
choose(answer: number | string) {
this.world?.choose(answer);
@@ -108,7 +118,7 @@ export class NativeRuntime implements GameRuntime {
let ticks = 0;
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
this.pending -= 1 / TICKS_PER_SECOND;
world.tick(held);
if (!this.fieldMenuOpen) world.tick(held);
ticks++;
}
if (ticks === 6) this.pending = 0;
@@ -225,6 +235,7 @@ export class NativeRuntime implements GameRuntime {
restoreState(bytes: Uint8Array) {
if (!this.world || bytes.length > 64_000)
throw new Error("Invalid native save size.");
this.fieldMenuOpen = false;
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);
File diff suppressed because it is too large. Load diff
+104 -9
View File
@@ -1,18 +1,64 @@
import { RomReader } from "../rom/reader";
import { DIRECTION_ORDER, type Direction } from "../world/types";
/** Poses a movement command holds for a number of ticks (OBJECT_ACTION and
* the step functions that drive it in engine/overworld/map_objects.asm). */
export type MovementPose =
/** step_dig: SetFacingCounterclockwiseSpin in place. */
| "spin"
/** return_dig: StepFunction_DigTo alternates spinning and flickering. */
| "spin-flicker"
/** rock_smash: StepFunction_RockSmash flickers the object. */
| "flicker"
/** tree_shake: OBJECT_ACTION_WEIRD_TREE. */
| "tree"
/** fish_got_bite: StepFunction_GotBite jiggles the rod. */
| "fish-bite"
/** fish_cast_rod: OBJECT_ACTION_FISHING for one tick. */
| "fish-cast"
/** skyfall: invisible for 15 ticks, then falls from above in 16. */
| "skyfall"
/** skyfall_top: 16 ticks in the air (SetFacingSkyfall). */
| "skyfall-top"
/** teleport_from: spins for 16 ticks, then spins while rising for 16. */
| "teleport-from"
/** teleport_to: waits 16, descends spinning 16, then spins 16. */
| "teleport-to";
/** One command from the game's own movement data (macros/scripts/movement.asm).
* Durations are overworld ticks: the original loop advances objects once per
* two video frames. Commands without a duration take effect immediately. */
export type MovementStep =
| { kind: "turn"; direction: Direction; ticks: 1 }
| { kind: "step"; direction: Direction; ticks: number; animate: boolean }
/** turn_step: StepFunction_Turn faces the new direction after one tick
* and reads the next command on the fourth. */
| { kind: "turn-step"; direction: Direction; ticks: 3 }
/** Steps, slides (`animate` false) and the spinning turn_away, turn_in and
* turn_waterfall steps (`spin`). */
| { kind: "step"; direction: Direction; ticks: number; animate: boolean; spin?: boolean }
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
| { kind: "jump"; direction: Direction; ticks: number }
| { kind: "sleep"; ticks: number }
/** A pose held in place for `ticks`. */
| { kind: "pose"; pose: MovementPose; ticks: number }
/** step_bump: one tick walking in place. */
| { kind: "bump"; ticks: 1 }
| { kind: "fix-facing"; fixed: boolean }
/** set_sliding/remove_sliding: SLIDING_F shows no walking frames. */
| { kind: "sliding"; sliding: boolean }
| { kind: "visibility"; hidden: boolean }
| { kind: "emote"; shown: boolean };
| { kind: "emote"; shown: boolean }
/** step_shake: ShakeScreen with the given displacement byte. */
| { kind: "shake"; displacement: number }
/** step_wait_end: the script continues at once; the object sleeps, then
* returns to its own movement. */
| { kind: "wait-end"; ticks: number }
/** remove_object: DeleteMapObject; the script continues. */
| { kind: "remove" }
/** step_stop: the object stands for good; the script continues. */
| { kind: "stop" }
/** step_loop: start the movement data over, forever. */
| { kind: "loop" };
export class UnsupportedMovement extends Error {
constructor(
@@ -27,9 +73,10 @@ export class UnsupportedMovement extends Error {
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
/** Decode movement data until step_end. Unknown commands abort the script
* rather than approximating a jump, slide or special effect. The source is
* usually the ROM; wMovementBuffer data built natively decodes the same way. */
/** Decode movement data until step_end or another command that ends it.
* Unknown commands abort the script rather than approximating an effect.
* The source is usually the ROM; wMovementBuffer data built natively
* decodes the same way. */
export function readMovement(
reader: Pick<RomReader, "byte">,
start: number,
@@ -41,14 +88,16 @@ export function readMovement(
command = reader.byte(p++);
const direction = DIRECTION_ORDER[command & 3];
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
else if (command >= 0x08 && command < 0x14)
else if (command < 0x08)
steps.push({ kind: "turn-step", direction, ticks: 3 });
else if (command < 0x14)
steps.push({
kind: "step",
direction,
ticks: STEP_TICKS[(command - 0x08) >> 2],
animate: true,
});
else if (command >= 0x14 && command < 0x20)
else if (command < 0x20)
// Slides move without the walking animation (e.g. being pushed).
steps.push({
kind: "step",
@@ -56,12 +105,23 @@ export function readMovement(
ticks: STEP_TICKS[(command - 0x14) >> 2],
animate: false,
});
else if (command >= 0x2c && command < 0x38)
else if (command < 0x2c)
// turn_away, turn_in, turn_waterfall: TurningStep spins while moving.
steps.push({
kind: "step",
direction,
ticks: STEP_TICKS[(command - 0x20) >> 2],
animate: true,
spin: true,
});
else if (command < 0x38)
steps.push({
kind: "jump",
direction,
ticks: STEP_TICKS[(command - 0x2c) >> 2],
});
else if (command === 0x38 || command === 0x39)
steps.push({ kind: "sliding", sliding: command === 0x39 });
else if (command === 0x3a || command === 0x3b)
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
else if (command === 0x3c || command === 0x3d)
@@ -69,10 +129,45 @@ export function readMovement(
else if (command >= 0x3e && command <= 0x45)
steps.push({ kind: "sleep", ticks: command - 0x3d });
else if (command === 0x46)
steps.push({ kind: "sleep", ticks: reader.byte(p++) });
steps.push({ kind: "sleep", ticks: reader.byte(p++) || 256 });
else if (command === 0x47) return steps;
else if (command === 0x48) {
steps.push({ kind: "wait-end", ticks: reader.byte(p++) || 256 });
return steps;
} else if (command === 0x49) {
steps.push({ kind: "remove" });
return steps;
} else if (command === 0x4a) {
steps.push({ kind: "loop" });
return steps;
} else if (command === 0x4b) {
steps.push({ kind: "stop" });
return steps;
} else if (command === 0x4c)
steps.push({ kind: "pose", pose: "teleport-from", ticks: 32 });
else if (command === 0x4d)
steps.push({ kind: "pose", pose: "teleport-to", ticks: 49 });
else if (command === 0x4e)
steps.push({ kind: "pose", pose: "skyfall", ticks: 31 });
else if (command === 0x4f)
steps.push({ kind: "pose", pose: "spin", ticks: reader.byte(p++) || 256 });
else if (command === 0x50) steps.push({ kind: "bump", ticks: 1 });
else if (command === 0x51)
steps.push({ kind: "pose", pose: "fish-bite", ticks: 8 });
else if (command === 0x52)
steps.push({ kind: "pose", pose: "fish-cast", ticks: 1 });
else if (command === 0x53 || command === 0x54)
steps.push({ kind: "emote", shown: command === 0x54 });
else if (command === 0x55)
steps.push({ kind: "shake", displacement: reader.byte(p++) });
else if (command === 0x56)
steps.push({ kind: "pose", pose: "tree", ticks: 24 });
else if (command === 0x57)
steps.push({ kind: "pose", pose: "flicker", ticks: reader.byte(p++) || 256 });
else if (command === 0x58)
steps.push({ kind: "pose", pose: "spin-flicker", ticks: reader.byte(p++) || 256 });
else if (command === 0x59)
steps.push({ kind: "pose", pose: "skyfall-top", ticks: 16 });
else throw new UnsupportedMovement(offset, command);
}
throw new Error("Movement data is unterminated.");
+647
View File
@@ -0,0 +1,647 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, PC, RAM } from "../rom/profile";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { BOX_NAME_PATTERN, PARTY_LENGTH } from "../party/pokemon";
import {
NUM_BOXES,
currentBox,
depositMon,
receivePCItem,
withdrawnMon,
} from "../party/storage";
import { readString, readText, type TextContext } from "./text";
import type { MartEntry, ScriptEffect, ScriptState } from "./interpreter";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** What the PC routines need from the running script. */
export interface PcHost {
state: ScriptState;
reader: RomReader;
data: PokemonData;
text(): TextContext;
/** text_ram and text_decimal values, keyed by RAM address. */
strings: Record<number, string>;
numbers: Record<number, number>;
/** _PlayerDecorationMenu: true if a decoration changed. */
decorationMenu(): Gen<boolean>;
}
/** PokemonCenterPC's menu items (PCPCITEM_*). */
const PCPCITEM_PLAYERS_PC = 0,
PCPCITEM_BILLS_PC = 1,
PCPCITEM_OAKS_PC = 2,
PCPCITEM_TURN_OFF = 4;
/** _PlayersPC's menu items (PLAYERSPCITEM_*). */
const PLAYERSPCITEM_WITHDRAW_ITEM = 0,
PLAYERSPCITEM_DEPOSIT_ITEM = 1,
PLAYERSPCITEM_TOSS_ITEM = 2,
PLAYERSPCITEM_MAIL_BOX = 3,
PLAYERSPCITEM_DECORATION = 4;
/** _BillsPC's menu items. */
const BILLSPC_WITHDRAW = 0,
BILLSPC_DEPOSIT = 1,
BILLSPC_CHANGE_BOX = 2,
BILLSPC_MOVE = 3;
/** The box submenus: DEPOSIT or WITHDRAW, STATS, RELEASE, CANCEL; and
* SWITCH, NAME, PRINT, QUIT for a box. */
const SUBMENU_TAKE = 0,
SUBMENU_STATS = 1,
SUBMENU_RELEASE = 2;
const BOX_SWITCH = 0,
BOX_NAME = 1,
BOX_PRINT = 2;
/** DelayFrames after a Bill's PC message, in overworld ticks (two frames). */
const MESSAGE_TICKS = 25,
RELEASED_TICKS = 40;
const EGG = 0xfd;
/** A pack entry and where it came from, for TossItem's wCurItemQuantity. */
interface PackSlot extends ItemSlot {
pocket: "items" | "balls" | "keyItems";
index: number;
}
/** The Pokémon Center PC (engine/events/pokecenter_pc.asm), Bill's PC
* (engine/pokemon/bills_pc_top.asm and bills_pc.asm) and Oak's PC
* (engine/events/prof_oaks_pc.asm). The original draws its own screens;
* here menus, lists and messages are presented natively. */
export class PcSession {
constructor(private host: PcHost) {}
private get state() {
return this.host.state;
}
private string(address: number) {
return readString(this.host.reader, address, this.host.text());
}
private pages(address: number) {
return readText(this.host.reader, address, this.host.text()).split("\n\n");
}
private *print(address: number): Gen {
yield { kind: "text", pages: this.pages(address) };
}
/** BillsPC_PlaceString followed by DelayFrames: a message that stays
* for a fixed time without waiting for a button. */
private *placeString(text: string, ticks = MESSAGE_TICKS): Gen {
yield { kind: "text", pages: [text], holdTicks: ticks };
}
/** A menu of `items`; the chosen index, or -1 for B. */
private *menu(
pages: string[],
items: string[],
details?: string[],
selected = 0,
): Gen<number> {
const choice = yield { kind: "menu", pages, items, details, selected };
return typeof choice === "number" && choice >= 0 && choice < items.length
? choice
: -1;
}
/** Consecutive "@"-terminated strings, as static menus store them. */
private strings(address: number, count: number) {
const r = this.host.reader,
result: string[] = [];
let p = address;
for (let i = 0; i < count; i++) {
result.push(this.string(p));
while (r.byte(p++) !== 0x50);
}
return result;
}
/** A WhichPC list: a count, the item ids, then -1. */
private indexSet(address: number, set: number) {
const r = this.host.reader;
let p = address;
for (let i = 0; i < set; i++) p += r.byte(p) + 2;
const count = r.byte(p);
return Array.from({ length: count }, (_, i) => r.byte(p + 1 + i));
}
/** A jumptable of (routine, string) pointers: the item's string. */
private itemString(table: number, item: number) {
const r = this.host.reader,
entry = table + item * 4 + 2;
return this.string(r.near(entry, r.bank(entry)));
}
// ------------------------------------------------------------ PC menus
/** PokemonCenterPC: a Pokémon is needed, then the PCs listed for the
* player's progress until TURN OFF or B. */
*pokemonCenterPC(): Gen {
if (!this.state.party.length) {
yield* this.print(PC["PC_CheckPartyForPokemon.PokecenterPCCantUseText"]);
return;
}
yield* this.print(PC.PokecenterPCTurnOnText);
const whose = this.pages(PC.PokecenterPCWhoseText);
let cursor = 0;
for (;;) {
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX or PCPC_BEFORE_HOF.
// No Hall of Fame entry exists before the League is ported.
const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? 1 : 0;
const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set);
const choice = yield* this.menu(
whose,
items.map((item) =>
this.itemString(PC["PokemonCenterPC.Jumptable"], item),
),
undefined,
cursor,
);
if (choice < 0) return;
cursor = choice;
switch (items[choice]) {
case PCPCITEM_BILLS_PC:
yield* this.print(PC.PokecenterBillsPCText);
yield* this.billsPC();
break;
case PCPCITEM_PLAYERS_PC:
yield* this.print(PC.PokecenterPlayersPCText);
yield* this.playersPC(false);
break;
case PCPCITEM_OAKS_PC:
yield* this.print(PC.PokecenterOaksPCText);
yield* this.oaksPC();
break;
case PCPCITEM_TURN_OFF:
yield* this.print(PC.PokecenterPCOaksClosedText);
return;
default:
throw new Error("The Hall of Fame PC is not ported.");
}
}
}
/** _PlayersHousePC: returns true when a decoration changed, which makes
* the script reload the room. */
*playersHousePC(): Gen<boolean> {
yield* this.print(PC.PlayersPCTurnOnText);
return yield* this.playersPC(true);
}
/** _PlayersPC: PLAYERSPC_NORMAL or PLAYERSPC_HOUSE. A changed
* decoration closes the PC. */
private *playersPC(house: boolean): Gen<boolean> {
const items = this.indexSet(PC["PlayersPCMenuData.WhichPC"], house ? 1 : 0);
const names = items.map((item) =>
this.itemString(PC["PlayersPCMenuData.PlayersPCMenuPointers"], item),
);
const ask = this.pages(PC.PlayersPCAskWhatDoText);
let cursor = 0;
for (;;) {
const choice = yield* this.menu(ask, names, undefined, cursor);
if (choice < 0) return false;
cursor = choice;
switch (items[choice]) {
case PLAYERSPCITEM_WITHDRAW_ITEM:
yield* this.withdrawItems();
break;
case PLAYERSPCITEM_DEPOSIT_ITEM:
yield* this.depositItems();
break;
case PLAYERSPCITEM_TOSS_ITEM:
yield* this.tossItems();
break;
case PLAYERSPCITEM_MAIL_BOX:
// _PlayerMailBoxMenu: no MAIL can be stored yet.
yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
break;
case PLAYERSPCITEM_DECORATION:
if (yield* this.host.decorationMenu()) return true;
break;
default:
// LOG OFF and TURN OFF.
return false;
}
}
}
// ------------------------------------------------------ item storage
private itemEntries(slots: ItemSlot[]): MartEntry[] {
const data = this.host.data;
return slots.map((slot) => ({
item: slot.item,
name: data.itemName(slot.item),
price: 0,
owned: slot.quantity,
}));
}
/** The item named by PartyMonItemName (wStringBuffer1 and 2) and the
* quantity text_decimal shows (wItemQuantityChange). */
private nameItem(item: number, quantity?: number) {
const name = this.host.data.itemName(item);
this.host.strings[RAM.stringBuffer1] = name;
this.host.strings[RAM.stringBuffer2] = name;
if (quantity !== undefined)
this.host.numbers[RAM.itemQuantityChange] = quantity;
}
/** SelectQuantityToToss for a stack, unless _CheckTossableItem makes it
* always one. Returns 0 if cancelled. */
private *quantity(
mode: "withdraw" | "deposit" | "toss",
slot: ItemSlot,
prompt: number,
): Gen<number> {
if (this.host.data.itemUnsellable(slot.item)) return 1;
this.nameItem(slot.item);
const chosen = yield {
kind: "martquantity",
mode,
item: slot.item,
name: this.host.data.itemName(slot.item),
price: 0,
max: slot.quantity,
prompt: readText(this.host.reader, prompt, this.host.text()),
selected: 1,
};
return typeof chosen === "number" &&
Number.isInteger(chosen) &&
chosen >= 1 &&
chosen <= slot.quantity
? chosen
: 0;
}
/** PlayerWithdrawItemMenu: into the matching pack pocket. */
private *withdrawItems(): Gen {
const items = this.state.pcItems;
for (;;) {
const index = yield {
kind: "martlist",
mode: "withdraw",
entries: this.itemEntries(items),
selected: 0,
};
const slot = typeof index === "number" ? items[index] : undefined;
if (!slot) return;
const quantity = yield* this.quantity(
"withdraw",
slot,
PC["PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText"],
);
if (!quantity) continue;
const item = slot.item;
if (!receiveItem(this.host.data, this.state.bag, item, quantity)) {
yield* this.print(
PC["PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText"],
);
continue;
}
removeFromSlot(items, index as number, item, quantity);
this.nameItem(item, quantity);
yield* this.print(
PC["PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText"],
);
}
}
/** The pack as DepositSellPack lists it: items, balls and key items,
* with the pocket and index each entry comes from. */
private packSlots(): PackSlot[] {
const bag = this.state.bag;
return [
...bag.items.map((slot, index) => ({
...slot,
pocket: "items" as const,
index,
})),
...bag.balls.map((slot, index) => ({
...slot,
pocket: "balls" as const,
index,
})),
...bag.keyItems.map((item, index) => ({
item,
quantity: 1,
pocket: "keyItems" as const,
index,
})),
];
}
/** PlayerDepositItemMenu: from any pocket into the PC. */
private *depositItems(): Gen {
if (!this.packSlots().length) {
yield* this.print(PC["PlayerDepositItemMenu.PlayersPCNoItemsText"]);
return;
}
for (;;) {
const slots = this.packSlots();
const choice = yield {
kind: "martlist",
mode: "deposit",
entries: this.itemEntries(slots),
selected: 0,
};
const slot = typeof choice === "number" ? slots[choice] : undefined;
if (!slot) return;
const quantity = yield* this.quantity(
"deposit",
slot,
PC["PlayerDepositItemMenu.PlayersPCHowManyDepositText"],
);
if (!quantity) continue;
if (!receivePCItem(this.state.pcItems, slot.item, quantity)) {
yield* this.print(
PC["PlayerDepositItemMenu.PlayersPCNoRoomDepositText"],
);
continue;
}
const bag = this.state.bag;
if (slot.pocket === "keyItems") bag.keyItems.splice(slot.index, 1);
else removeFromSlot(bag[slot.pocket], slot.index, slot.item, quantity);
this.nameItem(slot.item, quantity);
yield* this.print(PC["PlayerDepositItemMenu.PlayersPCDepositItemsText"]);
}
}
/** PlayerTossItemMenu and TossItemFromPC. */
private *tossItems(): Gen {
const items = this.state.pcItems;
for (;;) {
const index = yield {
kind: "martlist",
mode: "toss",
entries: this.itemEntries(items),
selected: 0,
};
const slot = typeof index === "number" ? items[index] : undefined;
if (!slot) return;
if (this.host.data.itemUnsellable(slot.item)) {
yield* this.print(PC["TossItemFromPC.ItemsTooImportantText"]);
continue;
}
const quantity = yield* this.quantity(
"toss",
slot,
PC["TossItemFromPC.ItemsTossOutHowManyText"],
);
if (!quantity) continue;
const item = slot.item;
this.nameItem(item, quantity);
const confirmed = yield {
kind: "yesno",
pages: this.pages(PC["TossItemFromPC.ItemsThrowAwayText"]),
};
if (!confirmed) continue;
removeFromSlot(items, index as number, item, quantity);
this.nameItem(item, quantity);
yield* this.print(PC["TossItemFromPC.ItemsDiscardedText"]);
}
}
// --------------------------------------------------------- Bill's PC
/** GetBoxName: the saved name, or SetDefaultBoxNames' "BOX" and number. */
boxName(box: number) {
return (
this.state.storage.names[box] ??
`${this.string(PC["SetDefaultBoxNames.Box"])}${box + 1}`
);
}
private monDetails(mons: { level: number }[]) {
return [...mons.map((mon) => `:L${mon.level}`), ""];
}
/** _BillsPC: the top menu until SEE YA! or B. */
private *billsPC(): Gen {
if (!this.state.party.length) {
yield* this.print(PC["_BillsPC.PCGottaHavePokemonText"]);
return;
}
const names = this.strings(PC["_BillsPC.strings"], 5);
const what = this.pages(PC["_BillsPC.PCWhatText"]);
let cursor = 0;
for (;;) {
const choice = yield* this.menu(what, names, undefined, cursor);
if (choice < 0) return;
cursor = choice;
if (choice === BILLSPC_WITHDRAW) yield* this.withdrawMons();
else if (choice === BILLSPC_DEPOSIT) yield* this.depositMons();
else if (choice === BILLSPC_CHANGE_BOX) yield* this.changeBox();
else if (choice === BILLSPC_MOVE)
yield {
kind: "notice",
text: "Moving Pokémon between boxes is not ported yet.",
};
else return;
}
}
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
* or the last that has not fainted, stays. No MAIL exists yet. */
private *partyMonMayLeave(index: number): Gen<boolean> {
const party = this.state.party;
let text: number | undefined;
if (party.length < 2) text = PC.PCString_ItsYourLastPKMN;
else if (party.every((mon, i) => i === index || !mon.hp))
text = PC.PCString_NoMoreUsablePKMN;
if (text === undefined) return true;
yield* this.placeString(this.string(text));
return false;
}
/** ReleasePKMN_ByePKMN: the species name, not the nickname. */
private *released(species: number): Gen {
yield* this.placeString(
this.string(PC.PCString_ReleasedPKMN),
RELEASED_TICKS,
);
yield* this.placeString(
`${this.string(PC.PCString_Bye)} ${this.host.data.speciesName(species)}!`,
);
}
private *confirmRelease(): Gen<boolean> {
return Boolean(
yield { kind: "yesno", pages: [this.string(PC.PCString_ReleasePKMN)] },
);
}
/** _DepositPKMN: choose a party Pokémon, then DEPOSIT, STATS or RELEASE. */
private *depositMons(): Gen {
const party = this.state.party,
options = this.strings(PC["BillsPCDepositMenuHeader.MenuData"] + 2, 4);
for (;;) {
const index = yield* this.menu(
[
`${this.string(PC["BillsPC_BoxName.PartyPKMN"])}\n${this.string(PC.PCString_ChooseaPKMN)}`,
],
[
...party.map((mon) => mon.nickname),
this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
],
this.monDetails(party),
);
if (index < 0 || index >= party.length) return;
for (;;) {
const action = yield* this.menu(
[this.string(PC.PCString_WhatsUp)],
options,
);
const mon = party[index];
if (action === SUBMENU_TAKE) {
if (!(yield* this.partyMonMayLeave(index))) break;
if (!depositMon(this.host.data, this.state.storage, mon)) {
yield* this.placeString(this.string(PC.PCString_BoxFull));
continue;
}
party.splice(index, 1);
yield* this.placeString(
`${this.string(PC.PCString_Stored)}${mon.nickname}!`,
);
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (!(yield* this.partyMonMayLeave(index))) break;
if (mon.species === EGG) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
break;
}
if (!(yield* this.confirmRelease())) continue;
party.splice(index, 1);
yield* this.released(mon.species);
break;
}
}
}
/** _WithdrawPKMN: choose a Pokémon in the current box, then WITHDRAW,
* STATS or RELEASE. */
private *withdrawMons(): Gen {
const options = this.strings(PC["BillsPC_Withdraw.MenuData"] + 2, 4);
for (;;) {
const box = currentBox(this.state.storage);
const index = yield* this.menu(
[
`${this.boxName(this.state.storage.current)}\n${this.string(PC.PCString_ChooseaPKMN)}`,
],
[
...box.map((mon) => mon.nickname),
this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
],
this.monDetails(box),
);
if (index < 0 || index >= box.length) return;
for (;;) {
const action = yield* this.menu(
[this.string(PC.PCString_WhatsUp)],
options,
);
const mon = box[index];
if (action === SUBMENU_TAKE) {
if (this.state.party.length >= PARTY_LENGTH) {
yield* this.placeString(this.string(PC.PCString_PartyFull));
continue;
}
box.splice(index, 1);
this.state.party.push(withdrawnMon(this.host.data, mon));
yield* this.placeString(
`${this.string(PC.PCString_Got)}${mon.nickname}!`,
);
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (mon.species === EGG) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
continue;
}
if (!(yield* this.confirmRelease())) continue;
box.splice(index, 1);
yield* this.released(mon.species);
break;
}
}
}
/** _ChangeBox: choose a box, then SWITCH, NAME, PRINT or QUIT. */
private *changeBox(): Gen {
const storage = this.state.storage,
options = this.strings(PC["BillsPC_ChangeBoxSubmenu.MenuData"] + 2, 4),
current = this.string(PC["BillsPC_PrintBoxName.Current"]),
outOf = this.string(PC["BillsPC_PrintBoxCountAndCapacity.OutOf20"]);
let cursor = 0;
for (;;) {
const names = Array.from({ length: NUM_BOXES }, (_, box) =>
this.boxName(box),
);
const box = yield* this.menu(
[
`${current}\n${this.boxName(storage.current)}\n${this.string(PC["BillsPC_PlaceChooseABoxString.ChooseABox"])}`,
],
names,
storage.boxes.map((mons) => `${mons.length}${outOf}`),
cursor,
);
if (box < 0) return;
cursor = box;
const action = yield* this.menu(
[this.string(PC.PCString_WhatsUp)],
options,
);
if (action === BOX_SWITCH) {
if (box === storage.current) continue;
// ChangeBoxSaveGame: the native session saves continuously, so the
// switch itself is what the confirmation commits.
const confirmed = yield {
kind: "yesno",
pages: this.pages(PC.ChangeBoxSaveText),
};
if (confirmed) storage.current = box;
} else if (action === BOX_NAME) {
const name = yield {
kind: "name",
pages: [this.string(PC["NamingScreenJumptable.BoxNameString"])],
current: names[box],
max: 8,
};
if (typeof name !== "string" || (name && !BOX_NAME_PATTERN.test(name)))
throw new Error("Invalid box name.");
// InitString: a blank name keeps the box's name.
if (!/^ *$/.test(name)) storage.names[box] = name;
} else if (action === BOX_PRINT) {
if (!storage.boxes[box].length)
yield* this.placeString(
this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]),
);
else
yield {
kind: "notice",
text: "Printing needs a Game Boy Printer, which is not supported.",
};
}
}
}
// ----------------------------------------------------------- Oak's PC
/** ProfOaksPC: an optional Pokédex rating from OakRatings. */
private *oaksPC(): Gen {
const r = this.host.reader;
const rate = yield {
kind: "yesno",
pages: this.pages(PC.OakPCText1),
};
if (rate) {
yield* this.print(PC.OakPCText2);
const { seen, caught } = this.state.pokedex;
this.host.strings[RAM.stringBuffer3] = String(seen.size);
this.host.strings[RAM.stringBuffer4] = String(caught.size);
yield* this.print(PC.OakPCText3);
// FindOakRating: the first entry whose count reaches the owned total.
let entry = PC.OakRatings;
while (r.byte(entry) < caught.size) entry += 5;
yield* this.print(r.near(entry + 3, r.bank(entry)));
}
yield* this.print(PC.OakPCText4);
}
}
+111
View File
@@ -0,0 +1,111 @@
/** Overworld constants keyed by their PRET names, so that
* `npm run check:reference` verifies every value against the pinned
* disassembly (constants/*.asm). */
export const OW = {
// Collision types (constants/collision_constants.asm).
COLL_CUT_TREE: 0x12,
COLL_CUT_TREE_1A: 0x1a,
COLL_TALL_GRASS_10: 0x10,
COLL_TALL_GRASS: 0x18,
COLL_LONG_GRASS: 0x14,
COLL_LONG_GRASS_1C: 0x1c,
COLL_HEADBUTT_TREE: 0x15,
COLL_HEADBUTT_TREE_1D: 0x1d,
COLL_ICE: 0x23,
COLL_ICE_2B: 0x2b,
COLL_WHIRLPOOL: 0x24,
COLL_WHIRLPOOL_2C: 0x2c,
COLL_WATERFALL: 0x33,
COLL_CURRENT_DOWN: 0x3b,
COLL_PIT: 0x60,
COLL_PIT_68: 0x68,
COLL_WARP_CARPET_DOWN: 0x70,
COLL_DOOR: 0x71,
COLL_WARP_CARPET_LEFT: 0x76,
COLL_WARP_CARPET_UP: 0x78,
COLL_DOOR_79: 0x79,
COLL_STAIRCASE: 0x7a,
COLL_CAVE: 0x7b,
COLL_WARP_CARPET_RIGHT: 0x7e,
COLL_COUNTER: 0x90,
HI_NYBBLE_CURRENT: 0x30,
HI_NYBBLE_WALK: 0x40,
HI_NYBBLE_WALK_ALT: 0x50,
HI_NYBBLE_WARPS: 0x70,
HI_NYBBLE_LEDGES: 0xa0,
HI_NYBBLE_SIDE_WALLS: 0xb0,
LAND_TILE: 0x00,
WATER_TILE: 0x01,
// wPlayerState (constants/ram_constants.asm).
PLAYER_NORMAL: 0,
PLAYER_BIKE: 1,
PLAYER_SKATE: 2,
PLAYER_SURF: 4,
PLAYER_SURF_PIKA: 8,
// Engine flags backed by wBikeFlags (constants/engine_flags.asm).
ENGINE_FOREST_IS_RESTLESS: 100,
ENGINE_STRENGTH_ACTIVE: 24,
ENGINE_ALWAYS_ON_BIKE: 25,
ENGINE_DOWNHILL: 26,
ENGINE_ZEPHYRBADGE: 27,
ENGINE_HIVEBADGE: 28,
ENGINE_PLAINBADGE: 29,
ENGINE_FOGBADGE: 30,
ENGINE_GLACIERBADGE: 33,
ENGINE_RISINGBADGE: 34,
// Map environments (constants/map_data_constants.asm).
TOWN: 1,
ROUTE: 2,
INDOOR: 3,
CAVE: 4,
ENVIRONMENT_5: 5,
GATE: 6,
DUNGEON: 7,
// Tilesets (constants/tileset_constants.asm).
NUM_TILESETS: 36,
TILESET_JOHTO: 1,
TILESET_JOHTO_MODERN: 2,
TILESET_KANTO: 3,
TILESET_HOUSE: 5,
TILESET_POKECENTER: 7,
TILESET_PORT: 9,
TILESET_FOREST: 31,
TILESET_PARK: 25,
TILESET_POKECOM_CENTER: 21,
TILESET_BATTLE_TOWER_INSIDE: 22,
TILESET_RADIO_TOWER: 27,
TILESET_MANSION: 13,
TILESET_ICE_PATH: 29,
TILESET_ELITE_FOUR_ROOM: 15,
TILESET_TOWER: 23,
TILESET_CAVE: 24,
TILESET_DARK_CAVE: 30,
// Map palettes (constants/map_data_constants.asm).
PALETTE_DARK: 4,
// Regions (constants/landmark_constants.asm).
LANDMARK_SPECIAL: 0,
LANDMARK_FAST_SHIP: 95,
KANTO_LANDMARK: 47,
COLL_WATER: 0x29,
// Sprite ranges (constants/sprite_constants.asm).
NUM_OVERWORLD_SPRITES: 102,
SPRITE_POKEMON: 0x80,
NUM_POKEMON_SPRITES: 35,
SPRITE_VARS: 0xf0,
// Moves used as field moves (constants/move_constants.asm).
CUT: 0x0f,
SURF: 0x39,
STRENGTH: 0x46,
WHIRLPOOL: 250,
WATERFALL: 127,
HEADBUTT: 29,
BICYCLE: 7,
PIKACHU: 25,
EGG: 0xfd,
} as const;
/** Directions in the original's order (DOWN, UP, LEFT, RIGHT). */
export const DOWN = 0,
UP = 1,
LEFT = 2,
RIGHT = 3;
+100
View File
@@ -0,0 +1,100 @@
import { RomReader } from "../rom/reader";
import { OVERWORLD } from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import { defaultTextContext, readString } from "../scripts/text";
export const DECORATION_SLOTS = [
"bed",
"carpet",
"plant",
"poster",
"console",
"bigDoll",
"left",
"right",
] as const;
export type DecorationSlot = (typeof DECORATION_SLOTS)[number];
export type Decorations = Record<DecorationSlot, number>;
export const emptyDecorations = (): Decorations => ({
bed: 0,
carpet: 0,
plant: 0,
poster: 0,
console: 0,
bigDoll: 0,
left: 0,
right: 0,
});
export const initialDecorations = (): Decorations => ({
...emptyDecorations(),
bed: 2,
poster: 16,
});
/** DecorationAttributes and GetDecoName, read from the user's ROM. */
export class DecorationData {
constructor(
private reader: RomReader,
private pokemon: PokemonData,
) {}
attribute(id: number) {
if (!Number.isInteger(id) || id < 0 || id > 52)
throw new Error("Invalid decoration.");
const p = OVERWORLD.DecorationAttributes + id * 6,
r = this.reader;
return {
type: r.byte(p),
name: r.byte(p + 1),
action: r.byte(p + 2),
event: r.word(p + 3),
sprite: r.byte(p + 5),
};
}
private word(index: number) {
let p = OVERWORLD.DecorationNames;
for (let i = 0; i < index; i++) {
while (this.reader.byte(p++) !== 0x50) {}
}
return readString(this.reader, p, defaultTextContext("PLAYER"));
}
name(id: number) {
const a = this.attribute(id);
if (a.type === 1) return this.word(a.name);
if (a.type === 2 || a.type === 3)
return this.word(a.name) + this.word(a.type === 2 ? 13 : 14);
const mon = this.pokemon.speciesName(a.name);
return a.type === 6
? this.word(17) + mon
: mon + this.word(a.type === 4 ? 15 : 16);
}
owned(events: Set<number>, action: number) {
return Array.from({ length: 52 }, (_, i) => i + 1).filter((id) => {
const a = this.attribute(id);
return a.action === action && events.has(a.event);
});
}
valid(value: unknown, events: Set<number>): value is Decorations {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).length !== 8
)
return false;
const slots = value as Decorations;
return (
DECORATION_SLOTS.every((slot, i) => {
const id = slots[slot];
return (
Number.isInteger(id) &&
id >= 0 &&
id <= 52 &&
(id === 0 ||
(this.attribute(id).action === (i < 6 ? i * 2 + 1 : 13) &&
events.has(this.attribute(id).event)))
);
}) &&
(!slots.left || slots.left !== slots.right)
);
}
}
+1611 -264
View File
File diff suppressed because it is too large. Load diff
+241 -39
View File
@@ -1,3 +1,5 @@
import { drawTownMap } from "./game/rendering/town-map";
import { OW } from "./game/world/constants";
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
@@ -46,6 +48,10 @@ function showWorld(world: GameWorld) {
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
showBattle(world);
showFieldMenu(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
const dialogue = world.dialogue;
showPicture(world);
// A page without text is a button wait while a picture is shown.
@@ -53,7 +59,8 @@ function showWorld(world: GameWorld) {
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
);
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden = !dialogue || pictureWait || Boolean(mart);
get("native-dialogue").hidden =
!dialogue || pictureWait || Boolean(mart) || world.townMap;
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
@@ -131,6 +138,33 @@ function showChoice(choice: Choice | undefined) {
container.replaceChildren(
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
);
else if (choice.kind === "menu") {
// A vertical menu such as the elevator's floors; B cancels.
const list = document.createElement("div");
list.className = "dialogue-menu";
list.setAttribute("role", "listbox");
list.replaceChildren(
...choice.items.map((item, index) => {
const option = button(item, choice.selected === index, () =>
runtime.choose(index),
);
const detail = choice.details?.[index];
if (detail) {
const small = document.createElement("small");
small.textContent = detail;
option.append(small);
}
option.setAttribute("role", "option");
option.setAttribute("aria-label", detail ? `${item} ${detail}` : item);
option.setAttribute("aria-selected", String(choice.selected === index));
return option;
}),
);
container.replaceChildren(
list,
button("CANCEL", undefined, () => runtime.choose(-1)),
);
}
}
/** The naming screen as a text field and OK: an empty name keeps the
* current one. */
@@ -190,6 +224,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
martMenuKey = "";
return;
}
// The PC's item storage reuses these lists without prices.
const shop =
mart.kind === "martmenu" || mart.mode === "buy" || mart.mode === "sell";
get("mart").setAttribute("aria-label", shop ? "Poké Mart" : "Item storage");
get("mart-money").hidden = !shop;
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.prompt;
const key = JSON.stringify(mart);
@@ -232,7 +271,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
const buttons = mart.entries.map((entry, index) =>
button(entry.name, () => runtime.choose(index), {
detail:
mart.mode === "sell" ? `have ${entry.owned}` : `¥${entry.price}`,
mart.mode === "sell"
? `have ${entry.owned}`
: mart.mode === "buy"
? `¥${entry.price}`
: `×${entry.owned}`,
selected: mart.selected === index,
}),
);
@@ -262,7 +305,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
return element;
};
const output = document.createElement("output");
output.textContent = `${mart.name} ×${mart.selected} ¥${total}`;
output.textContent = shop
? `${mart.name} ×${mart.selected} ¥${total}`
: `${mart.name} ×${mart.selected}`;
stepper.append(
step("◀◀", "Minus 10", "left"),
step("▼", "Minus 1", "down"),
@@ -272,7 +317,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
);
menu.replaceChildren(
stepper,
button(mart.mode === "sell" ? "SELL" : "BUY", () => runtime.choose(mart.selected), { wide: true }),
button(mart.mode.toUpperCase(), () => runtime.choose(mart.selected), {
wide: true,
}),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
@@ -286,12 +333,28 @@ function statusName(status: number) {
if (status & 7) return "SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false) {
function drawPic(
target: HTMLCanvasElement,
tiles: Uint8Array,
size: number,
palette: Palette,
flip = false,
) {
const context = target.getContext("2d")!;
const pixels = size * 8;
const image = context.createImageData(pixels, pixels);
for (let tile = 0; tile < size * size; tile++)
drawTile(image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip });
drawTile(
image.data,
pixels,
pixels,
tiles,
tile,
Math.floor(tile / size) * 8,
(tile % size) * 8,
palette,
{ flipX: flip },
);
context.putImageData(image, 0, 0);
}
const battlePics = { foe: "", player: "" };
@@ -302,8 +365,67 @@ let showPack = false;
let battleCursor = 0;
let battleSelectionKey = "";
let fieldCursor = 0;
function fieldButtons() {
return [
...get("field-items").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
function showFieldMenu(world: GameWorld) {
const panel = get("field-menu");
panel.hidden = !runtime.fieldMenuOpen;
if (panel.hidden) return;
const buttons = world.state.bag.keyItems.map((item) => {
const button = document.createElement("button");
button.textContent = world.assets.pokemon.itemName(item);
button.disabled = item !== OW.BICYCLE;
button.onclick = () => {
if (session.state !== "playing") return;
runtime.useBicycle();
canvas.focus({ preventScroll: true });
};
return button;
});
const close = document.createElement("button");
close.textContent = "CLOSE";
close.onclick = () => {
runtime.fieldMenuOpen = false;
showFieldMenu(world);
canvas.focus();
};
get("field-items").replaceChildren(...buttons, close);
fieldButtons().forEach((button, i) =>
button.classList.toggle("battle-selected", i === fieldCursor),
);
}
function handleFieldInput(button: Button): boolean {
const world = runtime.world;
if (!world || world.battle) return false;
if ((button === "start" || button === "select") && !world.busy) {
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
fieldCursor = 0;
return true;
}
if (!runtime.fieldMenuOpen) return false;
if (button === "b") runtime.fieldMenuOpen = false;
else if (button === "a") fieldButtons()[fieldCursor]?.click();
else if (["up", "down", "left", "right"].includes(button)) {
const length = fieldButtons().length;
fieldCursor =
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
length;
}
return true;
}
function battleButtons() {
return [...get("battle-menu").querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
return [
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
function highlightBattleChoice() {
battleButtons().forEach((button, index) => {
@@ -312,6 +434,7 @@ function highlightBattleChoice() {
}
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
function handleBattleInput(button: Button): boolean {
if (handleFieldInput(button)) return true;
const world = runtime.world;
if (!world?.battle) return false;
if (button === "b" && (showMoves || showPack)) {
@@ -354,7 +477,12 @@ function showBattle(world: GameWorld) {
const key = `${foe.species}:${foe.dvs}`;
if (battlePics.foe !== key) {
battlePics.foe = key;
drawPic(get("battle-foe-pic"), data.frontPic(foe.species), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)));
drawPic(
get("battle-foe-pic"),
data.frontPic(foe.species),
7,
data.palette(foe.species, PokemonData.shiny(foe.dvs)),
);
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
}
get("battle-foe-name").textContent = foe.nickname;
@@ -367,7 +495,12 @@ function showBattle(world: GameWorld) {
const key = `${own.species}:${own.dvs}`;
if (battlePics.player !== key) {
battlePics.player = key;
drawPic(get("battle-player-pic"), data.backPic(own.species), 6, data.palette(own.species, PokemonData.shiny(own.dvs)));
drawPic(
get("battle-player-pic"),
data.backPic(own.species),
6,
data.palette(own.species, PokemonData.shiny(own.dvs)),
);
get("battle-player-pic").setAttribute("aria-label", own.nickname);
}
get("battle-player-name").textContent = own.nickname;
@@ -387,11 +520,28 @@ function showBattle(world: GameWorld) {
}
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
const balls = world.liveState.bag.balls;
const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, view.message?.prompt]);
const key = JSON.stringify([
prompt,
view.selected,
showMoves,
showPack,
own?.pp,
balls,
view.message?.prompt,
]);
if (key === battleMenuKey) return;
battleMenuKey = key;
const menu = get("battle-menu");
const button = (label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean } = {}) => {
const button = (
label: string,
action: () => void,
options: {
detail?: string;
disabled?: string;
wide?: boolean;
pressed?: boolean;
} = {},
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
@@ -405,7 +555,8 @@ function showBattle(world: GameWorld) {
element.title = options.disabled;
}
if (options.wide) element.classList.add("wide");
if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed));
if (options.pressed !== undefined)
element.setAttribute("aria-pressed", String(options.pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
@@ -419,38 +570,64 @@ function showBattle(world: GameWorld) {
own.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
}, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }),
button(
data.moveName(move),
() => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
},
{ detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` },
),
);
});
buttons.push(button("BACK", () => {
showMoves = false;
showWorld(world);
}, { wide: true }));
buttons.push(
button(
"BACK",
() => {
showMoves = false;
showWorld(world);
},
{ wide: true },
),
);
} else if (showPack) {
balls.forEach(({ item, quantity }, slot) =>
buttons.push(
button(data.itemName(item), () => {
showPack = false;
runtime.battleAction({ action: "item", item, slot });
}, { detail: `×${quantity}` }),
button(
data.itemName(item),
() => {
showPack = false;
runtime.battleAction({ action: "item", item, slot });
},
{ detail: `×${quantity}` },
),
),
);
if (!balls.length)
buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." }));
buttons.push(button("BACK", () => {
showPack = false;
showWorld(world);
}, { wide: true }));
buttons.push(
button("No BALLS", () => {}, {
disabled: "The BALL pocket is empty.",
}),
);
buttons.push(
button(
"BACK",
() => {
showPack = false;
showWorld(world);
},
{ wide: true },
),
);
} else {
buttons.push(
button("FIGHT", () => {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
button("PKMN", () => {}, {
disabled: "Switching Pokémon is not ported yet.",
}),
button("PACK", () => {
showPack = true;
showWorld(world);
@@ -460,29 +637,48 @@ function showBattle(world: GameWorld) {
}
} else if (prompt?.kind === "yesno") {
buttons.push(
button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1 }),
button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0 }),
button("YES", () => runtime.battleChoose(1), {
pressed: view.selected === 1,
}),
button("NO", () => runtime.battleChoose(0), {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
buttons.push(
button(data.moveName(move), () => runtime.battleChoose(index)),
),
);
buttons.push(
button("CANCEL", () => runtime.battleChoose(4), { wide: true }),
);
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
} else if (prompt?.kind === "name") {
menu.replaceChildren(
...nameField(prompt.current, prompt.max, (name) => runtime.battleName(name)),
...nameField(prompt.current, prompt.max, (name) =>
runtime.battleName(name),
),
);
return;
} else if (prompt?.kind === "stats") {
prompt.labels.forEach((label, index) =>
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
buttons.push(
button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " }),
),
);
buttons.push(
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
);
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
} else if (view.message?.prompt) {
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
buttons.push(
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
);
}
menu.replaceChildren(...buttons);
battleCursor = Math.min(battleCursor, Math.max(0, battleButtons().length - 1));
battleCursor = Math.min(
battleCursor,
Math.max(0, battleButtons().length - 1),
);
highlightBattleChoice();
}
function setHp(bar: HTMLElement, hp: number, max: number) {
@@ -768,3 +964,9 @@ setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
get("town-map-close").onclick = () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};
+3 -1
View File
@@ -35,6 +35,8 @@ export function renderShell(root: HTMLElement) {
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
@@ -74,5 +76,5 @@ export function renderShell(root: HTMLElement) {
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. Party and bag menus, the PC and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
}
+51
View File
@@ -341,6 +341,13 @@ dialog h2 {
gap: 6px;
margin-top: 6px;
}
.dialogue-menu {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 9.5em;
overflow-y: auto;
}
.dialogue-choice input {
flex: 1 1 12ch;
min-width: 0;
@@ -1024,3 +1031,47 @@ dialog dd {
scroll-behavior: auto !important;
}
}
.field-menu {
position: absolute;
inset: 1rem 1rem auto auto;
z-index: 6;
max-height: calc(100% - 2rem);
overflow: auto;
min-width: 10rem;
padding: 1rem;
border: 2px solid #27332a;
border-radius: 0.4rem;
background: #f8f4df;
color: #27332a;
}
.field-menu[hidden] {
display: none;
}
.field-menu p {
margin: 0 0 0.5rem;
}
#field-items {
display: grid;
gap: 0.3rem;
}
.town-map-view {
position: absolute;
inset: 0;
z-index: 7;
background: #f8f4df;
display: grid;
place-items: center;
padding: 0.5rem;
}
.town-map-view[hidden] {
display: none;
}
#town-map-canvas {
width: auto;
height: calc(100% - 3rem);
max-width: 100%;
image-rendering: pixelated;
object-fit: contain;
}
+3 -1
View File
@@ -25,8 +25,10 @@ async function start(page: Page, scenario: BattleScenario = "win") {
await page.locator("#play").click();
for (const direction of ENCOUNTER_WALK) {
const key = `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`;
// Long enough to turn in place first (4 ticks) and start one step,
// short of a second step when already facing that way.
await page.keyboard.down(key);
await page.clock.runFor(70);
await page.clock.runFor(250);
await page.keyboard.up(key);
await page.clock.runFor(300);
}
+93 -8
View File
@@ -163,23 +163,108 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
});
it("rolls back the whole battle when a full party would need the PC", () => {
/** Throw balls until the battle ends, answering prompts; returns the
* battle messages and the pages shown afterwards. */
const catchAll = (world: GameWorld, nickname?: string) => {
const messages: string[] = [], pages: string[] = [];
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
const view = world.battle;
if (!view) {
if (world.dialogue) {
const page = world.dialogue.pages[world.dialogue.index];
if (pages.at(-1) !== page) pages.push(page);
world.advanceDialogue();
} else world.tick();
continue;
}
const text = view.message?.text;
if (text && messages.at(-1) !== text) messages.push(text);
const prompt = view.prompt;
if (prompt?.kind === "action")
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(nickname ? 1 : 0);
else if (prompt?.kind === "name") world.battleName(nickname!);
else if (view.message?.prompt) world.interact();
else world.tick();
}
return {
messages: messages.map((text) => text.replace(/\n/g, " ")),
pages: pages.map((text) => text.replace(/\n/g, " ")),
};
};
const fullParty = (boxed = 0) => {
const world = battleFixtureWorld(assets());
const mon = world.state.party[0];
world.state.party = Array.from({ length: 6 }, () => structuredClone(mon));
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
const { status: _s, hp: _h, stats: _t, ...box } = mon;
world.state.storage.boxes[0] = Array.from({ length: boxed }, () => structuredClone(box));
return world;
};
it("sends a catch with a full party to the current box", () => {
const world = fullParty();
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
const { messages } = catchAll(world, "PIDGE");
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("PIDGE was sent to BILL's PC.");
expect(world.state.party).toHaveLength(6);
// SendMonIntoBox: first in the box, with full PP and base happiness.
const [boxed] = world.state.storage.boxes[0];
expect(boxed).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name, happiness: 70, statExp: [0, 0, 0, 0, 0] });
expect(boxed).not.toHaveProperty("hp");
const data = world.assets.pokemon;
expect(boxed.pp).toEqual(boxed.moves.map((move) => (move ? data.move(move).pp : 0)));
expect(boxed.caughtData >> 8).toBe((2 << 6) | 3);
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("keeps the ball when the party and the box are full", () => {
const world = fullParty(20);
const before = world.save();
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++)
if (world.battle?.message?.prompt) world.interact();
else world.tick();
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
for (let tick = 0; tick < 200 && world.battle; tick++) {
if (world.battle.prompt?.kind === "action")
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
const messages: string[] = [];
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++) {
const text = world.battle?.message?.text;
if (text && messages.at(-1) !== text) messages.push(text.replace(/\n/g, " "));
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
// Ball_BoxIsFullMessage: the item is not used and the turn is not taken.
expect(messages).toEqual(["The POKéMON BOX is full. That can't be used now."]);
expect(world.battle?.prompt?.kind).toBe("action");
expect(world.battle?.engine.enemy.mon.hp).toBe(world.battle?.engine.enemy.mon.maxHp);
for (let tick = 0; tick < 2000 && world.battle; tick++) {
if (world.battle.prompt?.kind === "action") world.battleAction({ action: "run" });
else if (world.battle.message?.prompt) world.interact();
else world.tick();
}
expect(world.battle).toBeUndefined();
expect(world.notice).toMatch(/not been ported/);
expect(world.state.bag).toEqual({ ...before.bag });
expect(world.state.party).toHaveLength(6);
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bag).toEqual(before.bag);
expect(world.state.storage).toEqual(before.storage);
});
it("has Bill call after a catch fills the box", () => {
const world = fullParty(19);
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
const { messages } = catchAll(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("PIDGEY was sent to BILL's PC.");
expect(world.state.storage.boxes[0]).toHaveLength(20);
// Script_SpecialBillCall runs at the next PlayerEvents.
expect(world.busy).toBe(false);
world.tick();
const call = settle(world).join(" ").replace(/\n/g, " ");
expect(call).toContain("It's me, BILL!");
expect(call).toContain("Click!");
expect(world.busy).toBe(false);
});
});
+169
View File
@@ -0,0 +1,169 @@
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 {
DecorationData,
initialDecorations,
} from "../src/game/world/decorations";
import { EVENTS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
function fixture() {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return {
assets,
world,
data: new DecorationData(assets.reader, assets.pokemon),
};
}
function pc(world: GameWorld) {
const save = world.save();
world.restore({
...save,
map: "24:7",
x: 2,
y: 2,
direction: "up",
objects: [],
});
world.interact();
expect(world.notice, world.diagnostic).toBe("");
// _PlayersHousePC: "<PLAYER> turned on the PC.", then _PlayersPC's menu.
acknowledge(world);
expect(world.dialogue?.choice?.kind).toBe("menu");
choose(world, "DECORATION");
}
function choose(world: GameWorld, name: string) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected decoration menu");
const index = choice.items.indexOf(name);
expect(index, name).toBeGreaterThanOrEqual(0);
world.choose(index);
}
function acknowledge(world: GameWorld) {
for (let i = 0; world.dialogue && !world.dialogue.choice && i < 20; i++)
world.advanceDialogue();
}
describe.skipIf(!existsSync(romPath))("room decorations", () => {
it("starts with the original bed and Town Map and migrates old saves", () => {
const { world, assets } = fixture();
expect(world.state.decorations).toEqual(initialDecorations());
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
expect(assets.block(world.map, 6, 0)).toBe(0x1f);
expect(world.state.events.has(EVENTS.playersRoomPoster)).toBe(true);
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
delete old.decorations;
world.restore(old);
expect(world.state.decorations).toEqual(initialDecorations());
});
it("PC puts away and restores a bed; mid-menu saves retain committed choices", () => {
const { world, assets } = fixture();
pc(world);
choose(world, "BED");
choose(world, "PUT IT AWAY");
expect(world.liveState.decorations.bed).toBe(0);
expect(world.save().decorations.bed).toBe(2);
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect(world.state.decorations.bed).toBe(0);
expect(assets.block(world.map, 0, 4)).toBe(
assets.block(assets.map("24:7"), 0, 4),
);
world.restore(world.save());
pc(world);
choose(world, "BED");
choose(world, "FEATHERY BED");
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
});
it("owned ornaments move between sides without duplication and reload as sprites", () => {
const { world, data } = fixture();
world.state.events.add(data.attribute(30).event);
pc(world);
choose(world, "ORNAMENT");
choose(world, "PIKACHU DOLL");
choose(world, "RIGHT SIDE");
acknowledge(world);
choose(world, "ORNAMENT");
choose(world, "PIKACHU DOLL");
choose(world, "LEFT SIDE");
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect([
world.state.decorations.left,
world.state.decorations.right,
]).toEqual([30, 0]);
expect(world.objects().some((object) => object.id === 3)).toBe(true);
expect(world.objects().some((object) => object.id === 4)).toBe(false);
world.restore(world.save());
expect(world.state.variables[1]).toBe(data.attribute(30).sprite);
});
it("validates ownership and categories before changing the current game", () => {
const { world } = fixture();
const before = world.save();
for (const decorations of [
null,
{ ...before.decorations, bed: 30 },
{ ...before.decorations, bed: 3 },
{ ...before.decorations, poster: 999 },
]) {
expect(() => world.restore({ ...before, decorations })).toThrow(
/Invalid native save/,
);
expect(world.save()).toEqual(before);
}
});
it("sets every tile category and sprite category from the owned ROM attributes", () => {
const { world, assets, data } = fixture();
for (let id = 1; id <= 52; id++) {
const a = data.attribute(id);
if (a.action & 1) world.state.events.add(a.event);
}
world.state.decorations = {
bed: 5,
carpet: 10,
plant: 14,
poster: 19,
console: 24,
bigDoll: 28,
left: 51,
right: 52,
};
world.warpTo("24:7", 2, 2);
expect(
[
[0, 4],
[7, 4],
[6, 0],
[0, 0],
[0, 2],
[2, 2],
[4, 2],
].map(([x, y]) => assets.block(world.map, x, y)),
).toEqual([0x1e, 0x22, 0x25, 0x11, 0x12, 0x13, 0x12]);
expect([0, 1, 2, 3].map((i) => world.state.variables[i])).toEqual(
[24, 51, 52, 28].map((id) => data.attribute(id).sprite),
);
expect(world.objects()).toHaveLength(4);
world.restore(world.save());
expect(world.objects()).toHaveLength(4);
});
});
+400
View File
@@ -0,0 +1,400 @@
import { describe, expect, it } from "vitest";
import { WALK_TICKS } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
import { SYMBOLS } from "../src/game/rom/profile";
import type { Direction } from "../src/game/world/types";
import { fixture } from "./movement-fixture";
import { settle } from "./world-driver";
const pose = (world: ReturnType<typeof fixture>["world"]) =>
world.poses().find((candidate) => candidate.id === 0)!;
/** Hold a direction for `ticks` ticks and record the tick numbers on which
* the player's tile changed. */
function hold(
world: ReturnType<typeof fixture>["world"],
direction: Direction | undefined,
ticks: number,
) {
const moved: number[] = [];
for (let tick = 1; tick <= ticks; tick++) {
const at = `${world.x},${world.y}`;
world.tick(direction);
if (`${world.x},${world.y}` !== at) moved.push(tick);
}
return moved;
}
describe("turning in place (DoPlayerMovement .CheckTurning)", () => {
it("turns on the next tick and walks on the fourth tick after a turn", () => {
const { world } = fixture();
expect(world.direction).toBe("down");
world.tick("right");
// StepFunction_Turn shows the new facing a tick later.
expect([world.x, world.direction, world.busy]).toEqual([3, "down", true]);
world.tick("right");
expect(world.direction).toBe("right");
expect(pose(world).frame).toBe(1);
// ChangeDirectionScript's deactivatefacing 3 holds input until then.
expect(hold(world, "right", 2)).toEqual([]);
expect(hold(world, "right", 1)).toEqual([1]);
// Continuous walking needs no further turns.
expect(hold(world, "right", WALK_TICKS * 2)).toEqual([WALK_TICKS, WALK_TICKS * 2]);
});
it("turns without walking when the direction is only tapped", () => {
const { world } = fixture();
world.tick("left");
expect(hold(world, undefined, 8)).toEqual([]);
expect([world.x, world.y, world.direction, world.busy]).toEqual([3, 3, "left", false]);
// Facing that way now, the next press walks at once.
expect(hold(world, "left", 1)).toEqual([1]);
});
it("changes direction without turning while walking, and turns again after stopping", () => {
const { world } = fixture();
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Still walking: the new direction steps on the next decision.
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.direction).toBe("right");
hold(world, undefined, WALK_TICKS + 1);
expect(hold(world, "up", 4)).toEqual([]);
expect(world.direction).toBe("up");
});
it("bumps into walls in place at half animation speed and must turn after a bump", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 7);
const frames: number[] = [];
for (let tick = 0; tick < 16; tick++) {
world.tick("down");
frames.push(pose(world).frame);
}
expect([world.x, world.y]).toEqual([3, 3]);
// SetFacingBumpAction: OBJECT_STEP_FRAME >> 3.
expect(frames).toEqual([0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2]);
// The bump cleared wPlayerTurningDirection, so a new direction turns first.
expect(hold(world, "left", 4)).toEqual([]);
expect(hold(world, "left", 1)).toEqual([1]);
});
it("walking into a wall in a new direction bumps without facing it, then turns", () => {
const { assets, world } = fixture();
assets.tile(4, 4, 7);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Mid-walk turning is not needed, but the blocked step does not turn.
world.tick("right");
expect([world.x, world.y, world.direction]).toEqual([3, 4, "down"]);
world.tick("right");
world.tick("right");
expect(world.direction).toBe("right");
});
it("does not turn toward the direction the player already faces", () => {
const { world } = fixture();
expect(hold(world, "down", 1)).toEqual([1]);
});
});
describe("forced terrain (DoPlayerMovement .CheckTile)", () => {
it.each<[number, Direction | undefined]>([
[0x40, undefined],
[0x41, "right"],
[0x42, "left"],
[0x43, "up"],
[0x44, "down"],
[0x45, undefined],
[0x50, "right"],
[0x51, "left"],
[0x52, "up"],
[0x53, "down"],
[0x54, undefined],
])("walk tile %i moves the player %s without input", (tile, direction) => {
const { assets, world } = fixture();
assets.tile(3, 3, tile);
world.tick();
if (!direction) {
expect([world.x, world.y, world.busy]).toEqual([3, 3, false]);
return;
}
const vectors = { right: [4, 3], left: [2, 3], up: [3, 2], down: [3, 4] };
expect([world.x, world.y, world.direction]).toEqual([...vectors[direction], direction]);
expect(pose(world).frame).toBe(0);
});
it("forced steps ignore held input and walls, like .continue_walk", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0x41);
assets.tile(4, 3, 7);
world.tick("left");
expect([world.x, world.y]).toEqual([4, 3]);
});
it.each<[number, Direction]>([
[0x30, "right"],
[0x31, "left"],
[0x32, "up"],
[0x33, "down"],
[0x38, "right"],
[0x3b, "down"],
])("currents and waterfalls (%i) carry a surfer %s", (tile, direction) => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
assets.tile(3, 3, tile);
world.tick();
expect(world.direction).toBe(direction);
expect(world.activity).toBe("walking");
});
});
describe("ice (STEP_ICE and .CheckForced)", () => {
it("slides at four ticks per tile until something blocks the slide", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
assets.tile(7, 3, 7);
world.restore({ ...world.save(), direction: "right" });
// Stepping from floor onto ice is still a walk; here we start on ice.
const moved = hold(world, "right", 1);
expect(moved).toEqual([1]);
expect(pose(world).frame).toBe(0);
// Input no longer matters while sliding.
expect(hold(world, "up", 16)).toEqual([4, 8]);
expect([world.x, world.y]).toEqual([6, 3]);
// The wall stopped the slide with a bump, so the player turned up during
// the last hold; still on ice, the step off it is another four-tick slide.
expect(world.direction).toBe("up");
expect(hold(world, "up", 5)).toEqual([1, 5]);
});
it("does not allow button actions while sliding", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
world.restore({ ...world.save(), direction: "right" });
hold(world, "right", 4);
world.interact();
expect(world.notice).toBe("");
});
it("walks normally from floor onto ice", () => {
const { assets, world } = fixture();
assets.tile(3, 4, OW.COLL_ICE);
const moved = hold(world, "down", WALK_TICKS);
expect(moved).toEqual([1]);
expect(pose(world).frame).not.toBe(undefined);
});
});
describe("bikes, surfing and side buoys", () => {
it("rides a bike at four ticks per tile", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
expect(hold(world, "down", 8)).toEqual([1, 5]);
});
it("rides downhill without input and slowly uphill", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
world.restore({ ...world.save(), y: 1 });
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
expect(hold(world, undefined, 8)).toEqual([1, 5]);
// Still moving, so no turn: uphill against the slope is a walk.
expect(hold(world, "up", 16)).toEqual([1, 9]);
});
it("surfs over water, refuses walls and gets out onto land", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
assets.tile(3, 5, 0x27);
assets.tile(4, 4, 0);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_SURF);
expect(hold(world, "down", 4)).toEqual([]);
world.tick("right");
world.tick("right");
world.tick("right");
world.tick("right");
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_NORMAL);
});
it("buoys block surfing like side walls", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
// COLL_UP_BUOY below the player blocks a downward move into it.
assets.tile(3, 4, 0xc2);
expect(hold(world, "down", 4)).toEqual([]);
assets.tile(3, 4, 0xc3);
expect(hold(world, "down", 1)).toEqual([1]);
});
it("walking cannot enter water", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 0x29);
expect(hold(world, "down", 4)).toEqual([]);
});
});
describe("dynamic warps (-1)", () => {
it("remembers the entrance on arrival and returns through a -1 warp", () => {
const { assets, world } = fixture();
// 24:7's warp 1 at (3,4) leads to 24:6 warp 1, a -1 warp back.
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:7").warps.push({ x: 5, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:6").warps.push({ x: 2, y: 2, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
assets.tile(5, 4, 0x72);
assets.tile(2, 3, 0x72, "24:6");
assets.map("24:6").warps.push({ x: 2, y: 3, targetMap: "24:6", targetWarp: 0xff });
world.restore({ ...world.save(), x: 5 });
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:6", 2, 2]);
expect(world.state.backupWarp).toEqual({ map: "24:7", warp: 2 });
const saved = world.save();
expect(saved.backupWarp).toEqual({ map: "24:7", warp: 2 });
world.restore(saved);
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:7", 5, 4]);
});
it("refuses a -1 warp without a recorded destination", () => {
const { assets, world } = fixture();
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
expect(world.move("down")).toBe(false);
expect(world.notice).toMatch(/not ported/);
});
it("rejects malformed backup warps in saves", () => {
const { world } = fixture();
for (const backupWarp of [{ map: "x", warp: 1 }, { map: "24:6", warp: 0 }, 5])
expect(() => world.restore({ ...world.save(), backupWarp })).toThrow("Invalid native save");
expect(() => world.restore({ ...world.save(), playerState: 3 })).toThrow("Invalid native save");
});
});
describe("Strength boulders", () => {
it("pushes a boulder one tile over sixteen ticks while the player bumps", () => {
const { assets, world } = fixture();
const type = 19; // SPRITEMOVEDATA_STRENGTH_BOULDER
const entry = SYMBOLS.spriteMovementData + type * 6;
// MovementFunction_Strength; WONT_DELETE | FIXED_FACING | SLIDING |
// MOVE_ANYWHERE; STRENGTH_BOULDER.
assets.bytes.set([0x11, 0, 1, 0x2e, 0, 0x40], entry);
assets.map("24:7").objects.push({
id: 2, x: 3, y: 4, sprite: 1, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
// Without Strength the boulder does not move.
hold(world, "down", 20);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 4 });
world.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
world.tick("down");
expect(world.objects()[0]).toMatchObject({ x: 3, y: 5 });
expect([world.x, world.y]).toEqual([3, 3]);
hold(world, undefined, 15);
expect(world.save().objects).toEqual([{ id: 2, x: 3, y: 5, direction: "down" }]);
// It stops at walls.
assets.tile(3, 7, 7);
hold(world, "down", 60);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 6 });
});
});
describe("changeblock", () => {
const trigger = (script: number[]) => {
const { assets, world } = fixture();
assets.bytes.set(script, 0x100);
assets.map("24:7").coordinateEvents.push({ x: 3, y: 4, scene: 0xff, script: 0x100 });
return { assets, world };
};
it("changes the working block data until the map loads again", () => {
// changeblock 2, 2, $05; end
const { world } = trigger([0x7a, 2, 2, 5, 0x91]);
expect(world.move("down")).toBe(true);
settle(world);
expect(world.notice).toBe("");
expect(world.map.blocks[1 * 4 + 1]).toBe(5);
expect(world.blockRevision).toBe(1);
world.warpTo("24:7", 3, 3);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("restores the blocks when a script that changed them is refused", () => {
// changeblock 2, 2, $05; an unported command
const { world } = trigger([0x7a, 2, 2, 5, 0xff]);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("refuses blocks outside the map or beyond the tileset", () => {
for (const script of [[0x7a, 8, 2, 5, 0x91], [0x7a, 2, 2, 0x80, 0x91]]) {
const { world } = trigger(script);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
}
});
});
describe("NPC movement functions", () => {
const withObject = (fn: number, action = 1, flags = 0, sprite = 1) => {
const { assets, world } = fixture();
const type = 30;
assets.bytes.set([fn, 0, action, flags, 0, 0], SYMBOLS.spriteMovementData + type * 6);
assets.map("24:7").objects.push({
id: 2, x: 5, y: 3, sprite, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
return { assets, world };
};
const facingOf = (world: ReturnType<typeof fixture>["world"]) =>
world.objects().find((object) => object.id === 2)!.direction;
it.each<[number, Direction[]]>([
[0x18, ["down", "left", "up", "right", "down"]],
[0x19, ["down", "right", "up", "left", "down"]],
])("SPRITEMOVEFN %i turns every sixteen ticks", (fn, expected) => {
const { world } = withObject(fn);
const seen: Direction[] = [];
for (let tick = 0; tick < 16 * 5; tick++) {
world.tick();
if (tick % 16 === 0) seen.push(facingOf(world));
}
expect(seen).toEqual(expected);
});
it("bounces between the down and up standing frames every eight ticks", () => {
const { world } = withObject(0x16, 0x0a, 0x2e);
const facings: (number | undefined)[] = [];
for (let tick = 0; tick < 17; tick++) {
world.tick();
facings.push(world.poses().find((pose) => pose.id === 2)?.facing);
}
expect(facings.slice(0, 7).every((facing) => facing === 0)).toBe(true);
expect(facings.slice(7, 15).every((facing) => facing === 4)).toBe(true);
expect(facings[15]).toBe(0);
});
it("draws big dolls with their own facing layouts", () => {
const { world } = withObject(0x15, 0x09);
world.tick();
expect(world.poses().find((pose) => pose.id === 2)?.facing).toBe(0x17);
});
it("resolves variable sprites from wVariableSprites and hides unset ones", () => {
const { world } = withObject(0x06, 1, 0, 0xf3);
expect(world.objects()).toHaveLength(0);
world.state.variables[3] = 0x2a;
world.restore(world.save());
expect(world.poses().find((pose) => pose.id === 2)?.sprite).toBe(0x2a);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test.beforeEach(() =>
test.skip(!existsSync(romPath), "Requires the local user ROM."),
);
async function start(page: Page, room: boolean | "poster" | "center" = false) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({
response,
body: source.replace(
needle,
`const { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`,
),
});
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
async function backup(page: Page) {
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await download).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
return {
path,
state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")),
};
}
test("Start opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
page,
}) => {
await start(page);
await page.keyboard.press("Enter");
await expect(page.locator("#field-menu")).toBeVisible();
await page.keyboard.down("ArrowDown");
await page.clock.runFor(1000);
await page.keyboard.up("ArrowDown");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "6");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
await expect(page.locator("#field-menu")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.playerState).toBe(1);
});
test("the bedroom PC puts away a decoration and saves the result", async ({
page,
}) => {
await start(page, true);
await page.keyboard.press("KeyX");
await expect(page.locator("#game-notice")).toHaveText("");
await expect(page.locator("#dialogue-text")).toContainText(
"turned on the PC.",
);
await page.keyboard.press("KeyX");
const choices = page.locator("#dialogue-choice");
await choices
.getByRole("option", { name: "DECORATION", exact: true })
.click();
await expect(page.locator("#dialogue-text")).toHaveText("DECORATION");
await choices.getByRole("option", { name: "BED", exact: true }).click();
await choices
.getByRole("option", { name: "PUT IT AWAY", exact: true })
.click();
await page.keyboard.press("KeyX");
await choices.getByRole("option", { name: "EXIT", exact: true }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.decorations.bed).toBe(0);
});
test("B cancels the pack and an indoor Bicycle attempt leaves the player walking", async ({
page,
}) => {
await start(page, true);
await page.keyboard.press("Enter");
await page.keyboard.press("KeyZ");
await expect(page.locator("#field-menu")).toBeHidden();
await page.keyboard.press("Enter");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
await expect(page.locator("#game-notice")).toContainText("can’t use");
expect((await backup(page)).state.playerState).toBe(0);
});
test("the Town Map poster opens a native map and closes with B", async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 568 });
await start(page, "poster");
await page.keyboard.press("KeyX");
await expect(page.locator("#dialogue-text")).toContainText("TOWN MAP");
await page.keyboard.press("KeyX");
await expect(page.locator("#town-map-view")).toBeVisible();
const colors = await page
.locator("#town-map-canvas")
.evaluate(
(canvas: HTMLCanvasElement) =>
new Set(canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data)
.size,
);
expect(colors).toBeGreaterThan(8);
await page.keyboard.press("KeyZ");
await expect(page.locator("#town-map-view")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
});
test("the Pokémon Center PC deposits a Pokémon and an item", async ({
page,
}) => {
await start(page, "center");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice");
await page.keyboard.press("KeyX");
await expect(text).toContainText("turned on the PC.");
await page.keyboard.press("KeyX");
await choices.getByRole("option", { name: "BILL's PC", exact: true }).click();
await expect(text).toContainText("BILL's PC");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await choices
.getByRole("option", { name: "DEPOSIT POKéMON", exact: true })
.click();
await choices.getByRole("option", { name: "PIDGEY :L4" }).click();
await choices.getByRole("option", { name: "DEPOSIT", exact: true }).click();
await expect(text).toHaveText("Stored PIDGEY!");
await page.clock.runFor(2000);
await expect(choices.getByRole("option", { name: "PIDGEY :L4" })).toHaveCount(
0,
);
// B leaves the party list, then Bill's PC.
await page.keyboard.press("KeyZ");
await page.keyboard.press("KeyZ");
await expect(choices.getByRole("option", { name: "TURN OFF" })).toBeVisible();
await choices
.getByRole("option", { name: /'s PC$/ })
.last()
.click();
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await choices
.getByRole("option", { name: "DEPOSIT ITEM", exact: true })
.click();
const storage = page.locator("#mart");
await expect(storage).toHaveAttribute("aria-label", "Item storage");
await expect(page.locator("#mart-money")).toBeHidden();
await storage.getByRole("button", { name: /POTION/ }).click();
await storage.getByRole("button", { name: "Plus 1", exact: true }).click();
await storage.getByRole("button", { name: "DEPOSIT", exact: true }).click();
await expect(text).toContainText("Deposited 2 POTION(S).");
await page.keyboard.press("KeyX");
await storage.getByRole("button", { name: "BACK", exact: true }).click();
await choices.getByRole("option", { name: "LOG OFF", exact: true }).click();
await choices.getByRole("option", { name: "TURN OFF", exact: true }).click();
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
const { state } = await backup(page);
expect(state.party).toHaveLength(1);
expect(
state.storage.boxes[0].map((mon: { species: number }) => mon.species),
).toEqual([16]);
expect(state.pcItems).toEqual([{ item: 18, quantity: 2 }]);
expect(state.bag.items).toEqual([{ item: 18, quantity: 3 }]);
});
+51
View File
@@ -0,0 +1,51 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
import { generatePartyMon } from "../src/game/party/pokemon";
/** `room` places the player at the bedroom PC or poster, "center" at
* Cherrygrove's Pokémon Center PC with two Pokémon; otherwise outdoors. */
export function foundationFixture(
assets: CrystalAssets,
room: boolean | "poster" | "center",
) {
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
world.state.scenes["24:4"] = 1;
world.state.bag.keyItems.push(OW.BICYCLE);
if (room === "center") {
world.state.party = [155, 16].map((species, i) =>
generatePartyMon(assets.pokemon, {
species,
level: 8 - i * 4,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
);
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
}
const save = world.save();
const [map, x, y] =
room === "center"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
: room
? ["24:7", 2, 2]
: ["24:4", 9, 6];
world.restore({
...save,
map,
x,
y,
direction: "up",
objects: [],
});
return world;
}
+90
View File
@@ -0,0 +1,90 @@
import { emptyDecorations } from "../src/game/world/decorations";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { GameMap } from "../src/game/world/types";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
export class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
/** CollisionPermissionTable in brief: walls, then the water rows
* ($2x, $3x, buoys $cx) apart from ice and the wall-like buoy tiles. */
override permission(collision: number) {
if (collision === 7 || collision === 0x27 || collision === 0x2f) return 15;
if (collision === 0x23 || collision === 0x2b) return 0;
return [0x20, 0x30, 0xc0].includes(collision & 0xf0) ? 1 : 0;
}
override grassCollisions() {
return [];
}
override sprite(id: number) {
return id ? { graphics: new Uint8Array(0), palette: 0, walking: false } : undefined;
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
export function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
world.state.decorations = emptyDecorations();
return { assets, world };
}
export function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
+25 -92
View File
@@ -1,88 +1,8 @@
import { describe, expect, it } from "vitest";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
import { settle } from "./world-driver";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
override permission(collision: number) {
return collision === 7 ? 15 : 0;
}
override grassCollisions() {
return [];
}
override sprite() {
return { graphics: new Uint8Array(0), palette: 0, walking: false };
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return { assets, world };
}
function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
describe("native ledge movement", () => {
it.each<[number, Direction]>([
@@ -105,8 +25,12 @@ describe("native ledge movement", () => {
const [dx, dy] = VECTORS[direction];
assets.tile(3, 3, tile);
assets.tile(3 + dx, 3 + dy, 7);
// Facing the ledge already, so no turn in place precedes the jump.
world.restore({ ...world.save(), direction });
world.tick(direction);
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
// StepFunction_PlayerJump: the map position reaches the tile jumped
// over first, and the landing tile halfway through.
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
x: 48 + dx * 2,
y: 48 + dy * 2,
@@ -114,12 +38,20 @@ describe("native ledge movement", () => {
shadow: dx ? 12 : 14,
});
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.busy).toBe(true);
for (let tick = 1; tick < WALK_TICKS; tick++) world.tick();
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
world.tick();
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = WALK_TICKS + 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeDefined();
world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
// The landing's tile events run on the next tick (PLAYERSTEP_STOP).
expect([world.busy, world.tilesWalked]).toEqual([true, 0]);
world.tick();
expect(world.busy).toBe(false);
expect(world.tilesWalked).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
},
);
@@ -134,17 +66,15 @@ describe("native ledge movement", () => {
expect(world.tilesWalked).toBe(2);
});
it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => {
it("refuses the wrong direction, blocked landings and map-edge jumps", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 2, 7);
expect(world.move("up")).toBe(false);
assets.tile(3, 4, 7);
for (const landing of [7, 0x30, 0x40, 0x60]) {
assets.tile(3, 5, landing);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
}
assets.tile(3, 5, 7);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
assets.tile(3, 6, 0xa3);
assets.tile(3, 7, 7);
world.restore({ ...world.save(), y: 6 });
@@ -262,6 +192,9 @@ describe("native warp movement", () => {
2,
"down",
]);
// WarpToNewMapScript's `end` takes the first tick in the new map.
world.tick("up");
expect([world.x, world.y, world.busy]).toEqual([2, 2, false]);
world.tick("up"); // Forced exit overrides held input.
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
const save = world.save();
@@ -294,7 +227,7 @@ describe("native warp movement", () => {
assets
.map("24:7")
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
world.restore({ ...world.save(), x: 7 });
world.restore({ ...world.save(), x: 7, direction: "right" });
world.tick("right");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
+19 -3
View File
@@ -106,7 +106,10 @@ describe("native ROM formats", () => {
it("decodes movement data and refuses unported movement commands", () => {
const reader = new RomReader(
Uint8Array.from([
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x4c, 0x47,
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x5a, 0x47,
// turn_step, turn_in, set_sliding, step_dig 16, skyfall,
// step_shake $54, fish_got_bite, step_bump, step_wait_end 5
0x05, 0x26, 0x39, 0x4f, 16, 0x4e, 0x55, 0x54, 0x51, 0x50, 0x48, 5,
]),
);
expect(readMovement(reader, 0)).toEqual([
@@ -119,6 +122,17 @@ describe("native ROM formats", () => {
{ kind: "sleep", ticks: 12 },
]);
expect(() => readMovement(reader, 9)).toThrow("not ported");
expect(readMovement(reader, 11)).toEqual([
{ kind: "turn-step", direction: "up", ticks: 3 },
{ kind: "step", direction: "left", ticks: 8, animate: true, spin: true },
{ kind: "sliding", sliding: true },
{ kind: "pose", pose: "spin", ticks: 16 },
{ kind: "pose", pose: "skyfall", ticks: 31 },
{ kind: "shake", displacement: 0x54 },
{ kind: "pose", pose: "fish-bite", ticks: 8 },
{ kind: "bump", ticks: 1 },
{ kind: "wait-end", ticks: 5 },
]);
});
it("reads RAM-backed names and string buffers only from native values", () => {
// text_ram wStringBuffer4, " ", text_buffer 0, done
@@ -192,7 +206,7 @@ describe.skipIf(!existsSync(romPath))(
load(),
() => new Date("2026-09-22T12:00:00"),
);
world.restore({ ...world.save(), x: 3, y: 2, direction: "down" });
world.restore({ ...world.save(), x: 3, y: 2, direction: "right" });
const ticks: number[] = [];
for (let tick = 1; tick <= 40 && world.x < 7; tick++) {
const x = world.x;
@@ -415,11 +429,13 @@ describe.skipIf(!existsSync(romPath))(
objects: [],
});
world.tick(direction);
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
// The first half of the jump occupies the tile jumped over.
expect([world.x, world.y]).toEqual([x + dx, y + dy]);
expect(
world.poses().find((pose) => pose.id === 0)?.lift,
).toBeLessThan(0);
settle(world);
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
expect(world.notice).toBe("");
checked.add(collision);
}
+445
View File
@@ -0,0 +1,445 @@
import { initialDecorations } from "../src/game/world/decorations";
import { isDeepStrictEqual } from "node:util";
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import type { Direction } from "../src/game/world/types";
import type { ReferenceTick } from "./reference-overworld";
import { TilesetAnimator } from "../src/game/rendering/tileset-animation";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
/** Labels the reference driver reads or calls. */
const LABELS = [
"ResetWRAM",
"NewBarkTown_TeacherRunsToYouMovement1",
"FinishContinueFunction",
"wDefaultSpawnpoint",
"wMapGroup",
"wMapNumber",
"wXCoord",
"wYCoord",
"hMapEntryMethod",
"wPlayerMapX",
"wPlayerMapY",
"wPlayerDirection",
"wMapStatus",
"wOverworldDelay",
"wStatusFlags",
"wTimeOfDay",
"wNewBarkTownSceneID",
"wPlayersHouse1FSceneID",
"wPlayerStruct",
"wTileAnimationTimer",
"hTileAnimFrame",
"wTileAnimBuffer",
"wEventFlags",
"wCelebiEvent",
"wTinTower1FSceneID",
];
function referenceSymbols() {
const table: Record<string, { bank: number; address: number }> = {};
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (match && LABELS.includes(match[3]))
table[match[3]] = {
bank: parseInt(match[1], 16),
address: parseInt(match[2], 16),
};
}
return table;
}
type Inputs = (Direction | undefined)[];
/** Hold `direction` (or nothing) for `ticks` overworld ticks. */
const run = (direction: Direction | undefined, ticks: number): Inputs =>
Array.from({ length: ticks }, () => direction);
interface Scenario {
name: string;
start: { map: string; x: number; y: number };
inputs: Inputs;
/** Maps entered on the way that are not enabled in normal play. */
extraMaps?: string[];
/** Scene variables set in both engines, keyed by the owning map and its
* wram label, so that coordinate events stay quiet. */
scenes?: { map: string; label: string; value: number }[];
/** Map objects whose positions are compared too (a scene's actors). */
objects?: number[];
patchMovement?: number[];
}
function nativeTrace(bytes: Uint8Array, scenario: Scenario, timeOfDay: number) {
const assets = new CrystalAssets(bytes);
const ported = assets.isPorted.bind(assets);
const extra = new Set(scenario.extraMaps ?? []);
assets.isPorted = (id) => ported(id) || extra.has(id);
// The original's clock decides which timed objects appear; match it.
const hour = [7, 12, 22][timeOfDay] ?? 12;
const now = () =>
new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`);
const world = new GameWorld(assets, now, () => 0);
// _ResetWRAM clears every event and engine flag before this warp.
world.state = emptyScriptState();
world.state.decorations = initialDecorations();
for (const scene of scenario.scenes ?? [])
world.state.scenes[scene.map] = scene.value;
world.warpTo(scenario.start.map, scenario.start.x, scenario.start.y);
return scenario.inputs.map((held) => {
world.tick(held);
if (world.notice) throw new Error(`${world.notice} ${world.diagnostic}`);
const objects: Record<number, [number, number]> = {};
for (const id of scenario.objects ?? []) {
const object = world.objects().find((candidate) => candidate.id === id);
if (object) objects[id] = [object.x, object.y];
}
const poses: ReferenceTick["poses"] = {};
// Blocking text is sampled before ApplyObjectFacing in the original
// and after it natively. Compare poses only while movement is running.
if (scenario.patchMovement && world.activity === "scene" && !world.dialogue)
for (const id of scenario.objects ?? []) {
const object = world.objects().find((o) => o.id === id);
const pose = world.poses().find((p) => p.id === id);
if (object)
poses[id] = {
direction: object.direction,
visible: Boolean(pose),
lift: pose?.lift ?? 0,
};
}
// The original samples a tick that waits in text before the text, so
// native state is taken before pressing A through the pages; the rest
// of the script then runs within the same tick in both engines.
const sample = {
map: world.mapId,
x: world.x,
y: world.y,
facing: world.direction,
objects,
poses,
events: [...world.liveState.events].sort((a, b) => a - b),
};
for (let page = 0; world.dialogue && page < 64; page++) {
if (world.dialogue.holdTicks !== undefined) break;
if (world.dialogue.choice) world.interact();
else world.advanceDialogue();
}
return sample;
});
}
async function referenceTrace(
page: Page,
bytes: Uint8Array,
scenario: Scenario,
) {
const symbols = referenceSymbols();
return page.evaluate(
async ({ rom, symbols, scenario }) => {
const driver = await import("/tests/reference-overworld.ts");
const start = await driver.bootReference(
new Uint8Array(rom),
symbols,
scenario.start,
(scenario.scenes ?? []).map(({ label, value }) => ({ label, value })),
);
const trace = driver.traceReference(scenario.inputs);
driver.disposeReference();
return { start, trace };
},
{ rom: [...bytes], symbols, scenario },
) as Promise<{
start: ReferenceTick & { timeOfDay: number };
trace: ReferenceTick[];
}>;
}
/** A tick that began a warp or connection is still sampled on the old map
* in the original, before map setup runs; native enters the map at once.
* Those ticks are skipped; every other tick must match exactly. */
function compare(
native: ReturnType<typeof nativeTrace>,
reference: ReferenceTick[],
) {
const differences: string[] = [];
reference.forEach((original, tick) => {
if (original.status === 1) {
// Map setup blocks inside the original tick. Compare its arrival at
// the first sample after setup with the native transition result.
const arrival = reference
.slice(tick + 1)
.find((sample) => sample.status !== 1);
if (!arrival)
throw new Error("Trace ends before a map transition completes.");
if (native[tick].map !== arrival.map)
differences.push(
`transition ${tick + 1}: native ${native[tick].map}, original destination ${arrival.map}`,
);
return;
}
const {
status: _status,
objects: all,
poses: allPoses,
...player
} = original;
const expected = {
...player,
poses: Object.fromEntries(
Object.keys(native[tick].poses).map((id) => {
const pose = allPoses[Number(id)];
return [id, { ...pose, lift: pose.visible ? pose.lift : 0 }];
}),
),
objects: Object.fromEntries(
Object.keys(native[tick].objects).map((id) => [id, all[Number(id)]]),
),
};
if (!isDeepStrictEqual(native[tick], expected))
differences.push(
`tick ${tick + 1}: native ${JSON.stringify(native[tick])}, original ${JSON.stringify(expected)}`,
);
});
return differences;
}
const QUIET_TOWN = { map: "24:4", label: "wNewBarkTownSceneID", value: 1 };
const QUIET_HOUSE = { map: "24:6", label: "wPlayersHouse1FSceneID", value: 1 };
const SCENARIOS: Scenario[] = [
{
name: "New Bark Town: turning, tapping, walking and bumping",
start: { map: "24:4", x: 9, y: 6 },
inputs: [
...run("right", 14),
...run(undefined, 6),
...run("left", 1),
...run(undefined, 6),
...run("up", 2),
...run(undefined, 3),
...run("down", 10),
...run("left", 9),
...run(undefined, 12),
...run("up", 20),
...run("right", 3),
...run(undefined, 4),
],
},
{
name: "New Bark Town to Route 29 and back across the connection",
start: { map: "24:4", x: 4, y: 9 },
scenes: [QUIET_TOWN],
inputs: [
...run("left", 60),
...run(undefined, 3),
...run("right", 50),
...run(undefined, 10),
],
},
{
name: "Route 29 ledge jump and walking on",
start: { map: "24:3", x: 25, y: 6 },
inputs: [
...run("down", 44),
...run(undefined, 20),
...run("up", 12),
...run(undefined, 6),
],
},
{
name: "Interior: the house's stairs, the bedroom and the front door",
start: { map: "24:6", x: 8, y: 5 },
scenes: [QUIET_HOUSE, QUIET_TOWN],
inputs: [
...run("up", 40),
...run("right", 6),
...run("up", 12),
...run(undefined, 30),
...run("down", 6),
...run("left", 12),
...run("up", 20),
...run("right", 30),
...run(undefined, 30),
...run("left", 6),
...run("down", 40),
...run("left", 10),
...run("down", 40),
...run(undefined, 30),
],
},
{
name: "Scene: the teacher stops the player at New Bark Town's west edge",
start: { map: "24:4", x: 4, y: 8 },
objects: [2],
inputs: [...run("left", 30), ...run(undefined, 60)],
},
{
name: "Cave: Ice Path slides, bumps and turns on ice",
start: { map: "3:61", x: 15, y: 14 },
extraMaps: ["3:61"],
inputs: [
...run("up", 30),
...run("left", 40),
...run(undefined, 10),
...run("down", 20),
...run("right", 30),
...run(undefined, 10),
...run("up", 3),
...run(undefined, 20),
],
},
];
for (const [name, movement] of [
["scripted spin", [0x4f, 16, 0x47]],
["teleport out and in", [0x4c, 0x4d, 0x47]],
["skyfall", [0x4e, 0x47]],
["skyfall departure", [0x59, 0x47]],
["tree shake", [0x56, 0x47]],
["fishing", [0x51, 0x52, 0x47]],
["flicker", [0x57, 8, 0x47]],
["spin flicker", [0x58, 8, 0x47]],
] as const)
SCENARIOS.push({
name: `Scene movement: ${name}`,
start: { map: "24:4", x: 4, y: 8 },
objects: [2],
patchMovement: [...movement],
inputs: [...run("left", 30), ...run(undefined, 140)],
});
test.describe("overworld traces match the original engine", () => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
for (const scenario of SCENARIOS)
test(scenario.name, async ({ page }, info) => {
test.skip(
info.project.name !== "chromium",
"Engine traces are browser-independent.",
);
await page.goto("/reference.html");
const bytes = new Uint8Array(readFileSync(romPath));
if (scenario.patchMovement) {
// Replace only a five-byte movement list in the in-memory test ROM.
// Both engines execute the same scene and movement bytecode.
const symbol = referenceSymbols().NewBarkTown_TeacherRunsToYouMovement1;
bytes.set(
scenario.patchMovement,
symbol.bank * 0x4000 + symbol.address - 0x4000,
);
}
const { start, trace: reference } = await referenceTrace(
page,
bytes,
scenario,
);
expect([start.map, start.x, start.y]).toEqual([
scenario.start.map,
scenario.start.x,
scenario.start.y,
]);
const native = nativeTrace(bytes, scenario, start.timeOfDay);
const differences = compare(native, reference);
if (differences.length && process.env.TRACE)
console.log(
reference
.map(
(original, tick) =>
`${tick + 1} ${JSON.stringify(scenario.inputs[tick] ?? "-")} ref ${original.x},${original.y},${original.facing} ${JSON.stringify(Object.fromEntries((scenario.objects ?? []).map((id) => [id, original.objects[id]])))} nat ${native[tick].x},${native[tick].y},${native[tick].facing} ${JSON.stringify(native[tick].objects)}`,
)
.join("\n"),
);
expect(differences.slice(0, 8)).toEqual([]);
});
});
test.describe("tile animation matches the original's VRAM", () => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
for (const start of [
{ name: "Johto water and flowers", map: "24:4", x: 9, y: 6 },
{ name: "Ice Path's scrolling cave water", map: "3:61", x: 15, y: 14 },
{
name: "Ilex Forest restless trees",
map: "3:52",
x: 10,
y: 10,
restless: true,
},
{ name: "Tin Tower pillars", map: "3:5", x: 9, y: 7 },
])
test(start.name, async ({ page }, info) => {
test.skip(
info.project.name !== "chromium",
"Engine traces are browser-independent.",
);
await page.goto("/reference.html");
const bytes = new Uint8Array(readFileSync(romPath));
const symbols = referenceSymbols();
const reference = await page.evaluate(
async ({ rom, symbols, start }) => {
const driver = await import("/tests/reference-overworld.ts");
await driver.bootReference(
new Uint8Array(rom),
symbols,
start,
start.restless
? [{ label: "wCelebiEvent", value: 4 }]
: start.map === "3:4"
? [{ label: "wTinTower1FSceneID", value: 1 }]
: [],
);
const peek = driver.peek();
const state = {
frame: peek(symbols.hTileAnimFrame.address),
timer: peek(symbols.wTileAnimationTimer.address),
buffer: Array.from({ length: 16 }, (_, i) =>
peek(symbols.wTileAnimBuffer.address + i),
),
tiles: driver.readTiles(),
};
const frames: number[][] = [];
for (let frame = 0; frame < 96; frame++) {
driver.stepFrame();
frames.push(driver.readTiles());
}
driver.disposeReference();
return { state, frames };
},
{ rom: [...bytes], symbols, start },
);
const assets = new CrystalAssets(bytes);
const animator = new TilesetAnimator(
assets,
assets.map(start.map),
Uint8Array.from(reference.state.tiles),
);
// Start from the original's animation state at the same moment.
Object.assign(animator, {
frame: reference.state.frame,
timer: reference.state.timer,
buffer: Uint8Array.from(reference.state.buffer),
});
const mismatches: number[] = [];
reference.frames.forEach((tiles, frame) => {
animator.advance(1, false, start.restless ?? false);
if (!tiles.every((value, index) => animator.graphics[index] === value))
mismatches.push(frame);
});
expect(mismatches).toEqual([]);
// Something actually animated.
expect(reference.frames[0]).not.toEqual(reference.frames[47]);
});
});
+455
View File
@@ -0,0 +1,455 @@
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 { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle, walkTo } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
const EVENT_HOLE_IN_BURNED_TOWER = 818;
/** A world on maps that normal play does not enable yet. */
function fixture(maps: string[]) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || maps.includes(id);
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0);
world.state = emptyScriptState();
return { assets, world };
}
function hold(world: GameWorld, direction: Direction | undefined, ticks: number) {
for (let tick = 0; tick < ticks; tick++) world.tick(direction);
}
describe.skipIf(!existsSync(romPath))("overworld foundation with the local ROM", () => {
it("changeblock in Burned Tower's TILES callback closes the hole until the event is set", () => {
const { assets, world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2; // SCENE_BURNEDTOWER1F_NOOP
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.permission(assets.collision(world.map, 10, 9))).toBe(0);
expect(assets.collision(world.map, 10, 9)).not.toBe(0x60);
// The ROM's own block data still has the hole; only the working copy changed.
expect(assets.collision(assets.map(BURNED_TOWER_1F), 10, 9)).toBe(0x60);
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.collision(world.map, 10, 9)).toBe(0x60);
});
it("falls through a pit with FallIntoMapScript's skyfall and earthquake", () => {
const { world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2;
world.state.scenes[BURNED_TOWER_B1F] = 1;
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
// Turn, then walk up onto the hole.
hold(world, "up", 4);
hold(world, "up", 8);
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_1F, 10, 9]);
// The arrival tick falls through; the next starts the rest of the script.
hold(world, undefined, 2);
expect(world.notice, world.diagnostic).toBe("");
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_B1F, 10, 9]);
expect(world.activity).toBe("scene");
// Invisible above the screen at first, then falling into view.
expect(world.poses().some((pose) => pose.id === 0)).toBe(false);
let shook = false;
for (let tick = 0; tick < 200 && world.busy; tick++) {
world.tick();
if (world.screenShake) shook = true;
}
expect(world.busy).toBe(false);
expect(shook).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ x: 160, y: 144 });
});
it("rides the Goldenrod Dept. Store elevator through its menu and -1 warps", () => {
const [store1F, store3F, elevator] = ["11:11", "11:13", "11:17"];
const { world } = fixture([store1F, store3F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
expect([world.mapId, world.x, world.y]).toEqual([elevator, 1, 3]);
// The arrival warp is numbered -1, so the entrance became the backup.
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
walkTo(world, 3, 1);
world.move("up");
world.interact();
const menu = world.dialogue!;
const shown = settle(world, [3]);
expect(shown.join("\n")).toContain("Now on: 1F");
expect(menu.choice).toMatchObject({ kind: "menu" });
expect((menu.choice as { items: string[] }).items).toEqual([
"B1F", "1F", "2F", "3F", "4F", "5F", "6F",
]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store3F, warp: 3 });
walkTo(world, 2, 3);
world.move("down");
settle(world);
// Arrival at warp 3 (2, 0), then the doorway's forced step down.
expect([world.mapId, world.x, world.y]).toEqual([store3F, 2, 1]);
expect(world.notice, world.diagnostic).toBe("");
});
it("keeps the floor when the elevator menu is cancelled or the same floor is chosen", () => {
const [store1F, elevator] = ["11:11", "11:17"];
const { world } = fixture([store1F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
walkTo(world, 3, 1);
world.move("up");
for (const answer of [-1, 1]) {
world.interact();
settle(world, [answer]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
}
});
describe("wild encounters (CanEncounterWildMon and ChooseWildEncounter)", () => {
const UNION_CAVE_1F = "3:37";
const cave = () => {
const { assets, world } = fixture([UNION_CAVE_1F]);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155, level: 20, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
}),
];
return { assets, world };
};
/** Walk back and forth until a battle starts or the steps run out. */
const pace = (world: GameWorld, steps: number, directions: Direction[]) => {
for (let step = 0; step < steps && !world.battle; step++) {
world.move(directions[step % directions.length]);
for (let tick = 0; tick < 4 && !world.battle; tick++) world.tick();
}
return world.battle?.engine;
};
it("starts on any cave floor after the five-step cooldown", () => {
const { world } = cave();
world.warpTo(UNION_CAVE_1F, 9, 6);
const battle = pace(world, 12, ["right", "left"]);
expect(world.notice, world.diagnostic).toBe("");
expect(battle).toBeDefined();
expect(world.tilesWalked).toBe(5);
});
it("stays off with wildoff", () => {
const { world } = cave();
world.state.noWildEncounters = true;
world.warpTo(UNION_CAVE_1F, 9, 6);
expect(pace(world, 12, ["right", "left"])).toBeUndefined();
});
it("uses the water table's rate and slots while surfing", () => {
const { assets, world } = cave();
const sea = assets.waterWildMons(UNION_CAVE_1F)!;
expect(sea).toBeDefined();
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map: UNION_CAVE_1F, x: 7, y: 4, direction: "right", objects: [] });
const battle = pace(world, 12, ["right", "left"]);
expect(battle).toBeDefined();
// Random is always 0: the first slot, with no level variety.
expect(battle!.enemy.mon).toMatchObject({
species: sea.slots[0].species,
level: sea.slots[0].level,
});
});
});
it("draws overworld Pokémon from their party menu icons (GetMonSprite)", () => {
const { assets } = fixture([]);
const unown = assets.sprite(0x80)!; // SPRITE_UNOWN
expect(unown.walking).toBe(true);
expect(unown.palette).toBe(0);
expect(unown.graphics).toHaveLength(8 * 16);
expect(assets.sprite(0x80 + 35)).toBeUndefined();
expect(assets.sprite(103)).toBeUndefined();
});
it("surfing onto a whirlpool spins the player back with Script_ForcedMovement", () => {
const { assets, world } = fixture([]);
// Find a whirlpool with water on the side a surfer comes from.
let found: { map: string; x: number; y: number } | undefined;
for (let group = 1; group <= 26 && !found; group++)
for (let number = 1; number <= 40 && !found; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1 && !found; y++)
for (let x = 0; x < map.width && !found; x++)
if (
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
assets.permission(assets.collision(map, x, y + 2)) === 1 &&
!map.objects.some((o) => o.x === x && (o.y === y + 1 || o.y === y + 2))
)
found = { map: map.id, x, y };
}
expect(found).toBeDefined();
const { map, x, y } = found!;
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === map;
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map, x, y: y + 2, direction: "up", objects: [] });
hold(world, "up", 8);
expect([world.x, world.y]).toEqual([x, y + 1]);
hold(world, "up", 9);
expect([world.x, world.y]).toEqual([x, y]);
expect(world.activity).toBe("scene");
const directions = new Set<string>();
for (let tick = 0; tick < 120 && world.busy; tick++) {
world.tick();
directions.add(world.direction);
}
expect(world.notice, world.diagnostic).toBe("");
// .MovementData_up: spin, turn_in DOWN back off the whirlpool, spin,
// then face down.
expect([world.x, world.y, world.direction]).toEqual([x, y + 1, "down"]);
expect(directions.size).toBe(4);
});
});
describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
const TOTODILE = 158;
const partyWith = (assets: CrystalAssets, moves: number[], species = TOTODILE) => {
const mon = generatePartyMon(assets.pokemon, {
species, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
mon.pp = [10, 10, 10, 10];
mon.nickname = "SWIMMY";
return [mon];
};
/** Find a tile on some map, searching every group. */
const find = (
assets: CrystalAssets,
test: (map: ReturnType<CrystalAssets["map"]>, x: number, y: number) => boolean,
) => {
for (let group = 1; group <= 26; group++)
for (let number = 1; number <= 60; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++) if (test(map, x, y)) return { map: map.id, x, y };
}
throw new Error("No suitable tile.");
};
it("surfs from New Bark Town's shore through AskSurfScript and lands again", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]); // SURF
world.state.flags.add(30); // ENGINE_FOGBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSURF!");
expect(world.state.playerState).toBe(4);
expect([world.x, world.y]).toEqual([18, 7]);
// The surfer sprite (SPRITE_SURF) replaces Chris.
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(0x53);
// Water saves and reloads with the surfer on it.
world.restore(world.save());
expect(world.state.playerState).toBe(4);
world.move("left");
expect([world.x, world.y, world.state.playerState]).toEqual([17, 7, 0]);
});
it("does not offer Surf without the Fog Badge or a Pokémon that knows it", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
expect(world.dialogue).toBeUndefined();
world.state.flags.add(30);
world.state.party = partyWith(assets, [0x0f]);
world.interact();
expect(world.dialogue).toBeUndefined();
});
it("cuts a tree with AskCutScript until the map loads again", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]); // CUT
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).toBe(0);
world.move("up");
expect([world.x, world.y]).toEqual([spot.x, spot.y]);
world.warpTo(spot.map, spot.x, spot.y + 1);
expect(world.map.blocks).toEqual(before);
});
it("explains a Cut tree without the move", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x21]);
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown.length).toBeGreaterThan(0);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).not.toBe(0);
});
it("uses Strength through AskStrengthScript and pushes a boulder", () => {
const { assets, world } = fixture([]);
// A Strength boulder object with floor below it and two floor tiles above.
const spot = find(assets, (map, x, y) => {
const boulder = map.objects.find(
(o) => o.x === x && o.y === y && o.movement === 25 && o.eventFlag === 0xffff,
);
return Boolean(
boulder &&
map.callbacks.length === 0 &&
[y + 1, y - 1, y - 2].every(
(row) =>
assets.permission(assets.collision(map, x, row)) === 0 &&
!map.objects.some((o) => o !== boulder && o.x === x && o.y === row),
),
);
});
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x46]); // STRENGTH
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
for (let tick = 0; tick < 20; tick++) world.tick("up");
const boulder = world.objects().find((o) => o.movement === 25 && o.x === spot.x);
expect(boulder?.y).toBe(spot.y - 1);
// Talking to it again: "Boulders may now be moved!"
settle(world);
});
it("rides the Bicycle on a route and gets off indoors", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x21]);
world.state.bag.keyItems.push(7); // BICYCLE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
expect(world.useBicycle()).toBe(true);
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("got on the\nBICYCLE.");
expect(world.state.playerState).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(2); // SPRITE_CHRIS_BIKE
// Turn left (four ticks), then four ticks per tile on the bike.
for (let tick = 0; tick < 4; tick++) world.tick("left");
world.tick("left");
expect(world.x).toBe(12);
for (let tick = 0; tick < 4; tick++) world.tick("left");
expect(world.x).toBe(11);
// Entering the house (indoors) gets the player off (CheckUpdatePlayerSprite).
settle(world);
walkTo(world, 13, 5);
expect(world.mapId).toBe("24:6");
expect(world.state.playerState).toBe(0);
expect(world.useBicycle()).toBe(false);
});
it("offers Whirlpool and Waterfall only with the move and badge", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
const whirlpool = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
!map.objects.some((o) => o.x === x && o.y === y + 1),
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === whirlpool.map;
world.state.playerState = 4;
world.restore({ ...world.save(), map: whirlpool.map, x: whirlpool.x, y: whirlpool.y + 1, direction: "up", objects: [] });
world.interact();
const mighty = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(mighty.length).toBeGreaterThan(0);
world.state.party = partyWith(assets, [0x39, 250]); // SURF, WHIRLPOOL
world.state.flags.add(33); // ENGINE_GLACIERBADGE
const before = world.map.blocks.slice();
world.interact();
const used = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(used).toContain("used\nWHIRLPOOL!");
expect(world.map.blocks).not.toEqual(before);
});
});
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
it("drops a boulder pushed into Ice Path B1F's hole through its stone table script", () => {
const ICE_PATH_B1F = "3:62";
const EVENT_BOULDER_IN_ICE_PATH_2A = 1806;
const { assets, world } = fixture([ICE_PATH_B1F]);
const mon = generatePartyMon(assets.pokemon, {
species: 158, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
world.state.party = [mon];
world.warpTo(ICE_PATH_B1F, 4, 5);
world.state.flags.add(24); // ENGINE_STRENGTH_ACTIVE
world.state.events.add(EVENT_BOULDER_IN_ICE_PATH_2A);
world.restore({
...world.save(),
x: 4,
y: 5,
direction: "down",
objects: [{ id: 3, x: 4, y: 6, direction: "down" }],
});
world.state.flags.add(24);
expect(world.objects().some((o) => o.id === 3)).toBe(true);
world.tick("down");
const shown: string[] = [];
for (let tick = 0; tick < 40 && world.activity !== "scene"; tick++) world.tick();
expect(world.activity).toBe("scene");
shown.push(...settle(world));
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/fell\s+through/i);
expect(world.objects().some((o) => o.id === 3)).toBe(false);
expect(world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { test, expect } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test("all tileset palettes match the original LoadMapPals routine", async ({
page,
}, info) => {
test.skip(
info.project.name !== "chromium" ||
!existsSync(romPath) ||
!existsSync(symbolsPath),
"Requires the local ROM/reference; engine comparison runs in Chromium.",
);
const symbols: Record<string, { bank: number; address: number }> = {};
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
const m = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (m)
symbols[m[3]] = { bank: parseInt(m[1], 16), address: parseInt(m[2], 16) };
}
const bytes = new Uint8Array(readFileSync(romPath));
const cases = Array.from({ length: 36 * 4 }, (_, i) => ({
tileset: 1 + Math.floor(i / 4),
time: i % 4,
environment: 3,
group: 24,
}));
for (const environment of [1, 2, 4, 7])
for (const time of [0, 1, 2, 3])
for (const tileset of [1, 29, 30])
cases.push({ tileset, time, environment, group: 24 });
await page.goto("/reference.html");
const original = await page.evaluate(
async ({ rom, symbols, cases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const emu = new BinjgbEmulator();
await emu.load(new Uint8Array(rom), document.createElement("canvas"));
const write = (name: string, value: number) =>
emu.writeMemory(symbols[name].address, value);
const target = symbols.LoadMapPals;
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
target.bank,
0xe0,
0x9d,
0xea,
0,
0x20,
0xcd,
target.address & 255,
target.address >> 8,
0x3e,
0x42,
0xea,
0xff,
0xc6,
0x18,
0xfe,
];
const output = cases.map((c) => {
emu.writeMemory(0xff70, 1);
write("wMapTileset", c.tileset);
write("wEnvironment", c.environment);
write("wTimeOfDayPal", c.time);
write("wMapGroup", c.group);
emu.writeMemory(0xc6ff, 0);
program.forEach((v, i) => emu.writeMemory(0xc000 + i, v));
emu.setProgramCounter(0xc000);
emu.advanceFrames(2);
if (emu.readMemory(0xc6ff) !== 0x42)
throw new Error("LoadMapPals did not return");
emu.writeMemory(0xff70, symbols.wBGPals1.bank);
return Array.from({ length: 32 }, (_, i) => {
const p = symbols.wBGPals1.address + i * 2;
const value = emu.readMemory(p) | (emu.readMemory(p + 1) << 8);
return [0, 5, 10].map((shift) =>
Math.round((((value >> shift) & 31) * 255) / 31),
);
});
});
emu.dispose();
return output;
},
{ rom: [...bytes], symbols, cases },
);
const assets = new CrystalAssets(bytes),
base = assets.map("24:6");
cases.forEach((c, i) =>
expect(
assets.palettes({ ...base, ...c }, c.time).flat(),
JSON.stringify(c),
).toEqual(original[i]),
);
});
+511
View File
@@ -0,0 +1,511 @@
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 {
calcStats,
generatePartyMon,
validPartyMon,
} from "../src/game/party/pokemon";
import {
MAX_PC_ITEMS,
emptyStorage,
receivePCItem,
toBoxMon,
validStorage,
} from "../src/game/party/storage";
import { removeFromSlot } from "../src/game/party/bag";
import { C } from "../src/game/battle/constants";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const POKEMON_CENTER = "26:5",
CYNDAQUIL = 155,
PIDGEY = 16,
POTION = 18,
MYSTERY_EGG = 69;
describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** In front of Cherrygrove's PC (COLL_PC at 9,1). */
function fixture() {
let seed = 5;
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
const data = world.assets.pokemon;
const mon = (species: number, level: number) =>
generatePartyMon(data, {
species,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.party = [mon(CYNDAQUIL, 8), mon(PIDGEY, 4)];
world.state.bag.items = [{ item: POTION, quantity: 5 }];
world.restore({
...world.save(),
map: POKEMON_CENTER,
x: 9,
y: 2,
direction: "up",
objects: [],
});
return world;
}
/** Acknowledge pages and timed messages up to the next choice; returns
* the pages seen. */
function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 500; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
/** Choose the menu item matching `label` (the whole label, or a regex). */
function pick(world: GameWorld, label: string | RegExp) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu")
throw new Error(`Expected a menu, got ${choice?.kind}`);
const index = choice.items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(
index,
`${label} in ${choice.items.join(", ")}`,
).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
function menuItems(world: GameWorld) {
const choice = world.dialogue?.choice;
return choice?.kind === "menu" ? choice.items : [];
}
function open(world: GameWorld) {
world.interact();
expect(world.notice, world.diagnostic).toBe("");
return until(world);
}
/** Leave every menu with B until the script ends. */
function close(world: GameWorld) {
for (let i = 0; i < 20 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
until(world);
}
settle(world);
expect(world.notice, world.diagnostic).toBe("");
}
it("lists Bill's PC and the player's PC, and Oak's PC after the Pokédex", () => {
const world = fixture();
const pages = open(world);
expect(pages).toEqual([
`${world.name} turned on the PC.`,
"Access whose PC?",
]);
expect(menuItems(world)).toEqual([
"BILL's PC",
`${world.name}'s PC`,
"TURN OFF",
]);
expect(pick(world, "TURN OFF")).toContain("… Link closed…");
settle(world);
world.state.flags.add(ENGINE_FLAGS.pokedex);
open(world);
expect(menuItems(world)).toEqual([
"BILL's PC",
`${world.name}'s PC`,
"PROF.OAK's PC",
"TURN OFF",
]);
});
it("refuses to start without a Pokémon", () => {
const world = fixture();
world.state.party = [];
world.interact();
expect(settle(world).join(" ").replace(/\n/g, " ")).toContain(
"Bzzzzt! You must have a POKéMON to use this!",
);
expect(world.notice, world.diagnostic).toBe("");
});
it("deposits, keeps the last Pokémon and withdraws with recalculated stats", () => {
const world = fixture();
const data = world.assets.pokemon;
const pidgey = world.state.party[1];
pidgey.hp = 1;
pidgey.status = 0x08;
pidgey.pp = [0, 0, 0, 0];
pidgey.statExp = [0xffff, 0xffff, 0xffff, 0xffff, 0xffff];
open(world);
expect(pick(world, "BILL's PC")).toEqual([
"BILL's PC accessed.",
"POKéMON Storage System opened.",
"What?",
]);
expect(menuItems(world)).toEqual([
"WITHDRAW POKéMON",
"DEPOSIT POKéMON",
"CHANGE BOX",
"MOVE POKéMON W/O MAIL",
"SEE YA!",
]);
pick(world, /^DEPOSIT/);
expect(world.dialogue?.pages[0]).toBe("PARTY POKéMON\nChoose a POKéMON.");
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY", "CANCEL"]);
const choice = world.dialogue?.choice;
expect(choice?.kind === "menu" && choice.details).toEqual([
":L8",
":L4",
"",
]);
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]);
expect(pick(world, "DEPOSIT")).toContain("Stored PIDGEY!");
// A mid-PC save holds the committed game.
expect(world.save().party).toHaveLength(2);
expect(world.liveState.party).toHaveLength(1);
const [stored] = world.liveState.storage.boxes[0];
expect(stored).not.toHaveProperty("hp");
// RestorePPOfDepositedPokemon restores PP; status and HP are dropped.
expect(stored.pp).toEqual(
stored.moves.map((move) => (move ? data.move(move).pp : 0)),
);
// BillsPC_CheckMail_PreventBlackout keeps the last Pokémon.
expect(menuItems(world)).toEqual(["CYNDAQUIL", "CANCEL"]);
pick(world, "CYNDAQUIL");
expect(pick(world, "DEPOSIT")).toContain("It's your last POKéMON!");
expect(pick(world, "CANCEL")).toContain("What?");
pick(world, /^WITHDRAW/);
expect(world.dialogue?.pages[0]).toBe("BOX1\nChoose a POKéMON.");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual([
"WITHDRAW",
"STATS",
"RELEASE",
"CANCEL",
]);
expect(pick(world, "WITHDRAW")).toContain("Got PIDGEY!");
pick(world, "CANCEL");
close(world);
const back = world.state.party[1];
expect(world.state.storage.boxes[0]).toEqual([]);
// SendGetMonIntoFromBox: CalcMonStats with stat experience, full HP.
expect(back.status).toBe(0);
expect(back.hp).toBe(back.stats[0]);
expect(back.stats).toEqual(
calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp),
);
expect(back.stats[0]).toBeGreaterThan(pidgey.stats[0]);
expect(validPartyMon(data, back)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("refuses to withdraw into a full party or deposit into a full box", () => {
const world = fixture();
const party = world.state.party;
world.state.party = Array.from({ length: 6 }, (_, i) =>
structuredClone(party[i % 2]),
);
world.state.storage.boxes[0] = [toBoxMon(party[1])];
open(world);
pick(world, "BILL's PC");
pick(world, /^WITHDRAW/);
pick(world, "PIDGEY");
const full = pick(world, "WITHDRAW");
expect(full).toContain("The party's full!");
expect(full.at(-1)).toBe("What's up?");
pick(world, "CANCEL");
pick(world, "CANCEL");
world.liveState.storage.boxes[0] = Array.from({ length: 20 }, () =>
toBoxMon(party[1]),
);
pick(world, /^DEPOSIT/);
pick(world, "PIDGEY");
expect(pick(world, "DEPOSIT")).toContain("The BOX is full.");
close(world);
expect(world.state.party).toHaveLength(6);
});
it("releases after confirmation and says goodbye by species", () => {
const world = fixture();
const nicknamed = toBoxMon(world.state.party[1]);
nicknamed.nickname = "BIRDY";
world.state.storage.boxes[0] = [nicknamed];
open(world);
pick(world, "BILL's PC");
pick(world, /^WITHDRAW/);
pick(world, "BIRDY");
expect(pick(world, "RELEASE")).toEqual(["Release POKéMON?"]);
world.choose(0);
expect(until(world)).toContain("What's up?");
pick(world, "RELEASE");
world.choose(1);
expect(until(world)).toEqual([
"Released POKéMON.",
"Bye, PIDGEY!",
"BOX1 Choose a POKéMON.",
]);
close(world);
expect(world.state.storage.boxes[0]).toEqual([]);
});
it("switches and names boxes", () => {
const world = fixture();
world.state.storage.boxes[1] = [toBoxMon(world.state.party[1])];
open(world);
pick(world, "BILL's PC");
pick(world, "CHANGE BOX");
expect(world.dialogue?.pages[0]).toBe("CURRENT\nBOX1\nChoose a BOX.");
expect(menuItems(world)).toHaveLength(14);
const choice = world.dialogue?.choice;
expect(choice?.kind === "menu" && choice.details?.slice(0, 2)).toEqual([
"0/20",
"1/20",
]);
pick(world, "BOX2");
expect(menuItems(world)).toEqual(["SWITCH", "NAME", "PRINT", "QUIT"]);
expect(pick(world, "SWITCH")).toEqual([
"When you change a POKéMON BOX, data will be saved. OK?",
]);
world.choose(1);
until(world);
pick(world, "BOX3");
const naming = pick(world, "NAME");
expect(naming).toEqual(["BOX NAME?"]);
world.choose("FAVS");
until(world);
pick(world, "FAVS");
pick(world, "NAME");
// InitString: a blank name keeps the box's name.
world.choose(" ");
until(world);
pick(world, "FAVS");
expect(pick(world, "PRINT")).toContain("There's no POKéMON.");
close(world);
expect(world.state.storage.current).toBe(1);
expect(world.state.storage.names[2]).toBe("FAVS");
open(world);
pick(world, "BILL's PC");
pick(world, /^WITHDRAW/);
expect(world.dialogue?.pages[0]).toBe("BOX2\nChoose a POKéMON.");
expect(menuItems(world)).toEqual(["PIDGEY", "CANCEL"]);
close(world);
});
it("stores, withdraws and tosses items in the player's PC", () => {
const world = fixture();
world.state.bag.keyItems = [MYSTERY_EGG];
open(world);
expect(pick(world, `${world.name}'s PC`)).toEqual([
"Accessed own PC.",
"Item Storage System opened.",
"What do you want to do?",
]);
expect(menuItems(world)).toEqual([
"WITHDRAW ITEM",
"DEPOSIT ITEM",
"TOSS ITEM",
"MAIL BOX",
"LOG OFF",
]);
world.choose(1);
let list = world.dialogue?.choice;
expect(
list?.kind === "martlist" && list.entries.map((e) => e.name),
).toEqual(["POTION", "MYSTERY EGG"]);
world.choose(0);
const quantity = world.dialogue?.choice;
expect(quantity?.kind === "martquantity" && quantity.prompt).toBe(
"How many do you\nwant to deposit?",
);
world.choose(3);
expect(until(world)).toContain("Deposited 3 POTION(S).");
// Key items cannot be tossed, so they move one at a time.
world.choose(1);
expect(until(world)).toContain("Deposited 1 MYSTERY EGG(S).");
world.choose(-1);
until(world);
expect(world.liveState.pcItems).toEqual([
{ item: POTION, quantity: 3 },
{ item: MYSTERY_EGG, quantity: 1 },
]);
expect(world.liveState.bag).toMatchObject({
items: [{ item: POTION, quantity: 2 }],
keyItems: [],
});
pick(world, "TOSS ITEM");
world.choose(1);
expect(until(world)).toContain("That's too impor- tant to toss out!");
world.choose(0);
world.choose(2);
expect(until(world)).toEqual(["Throw away 2 POTION(S)?"]);
world.choose(1);
expect(until(world)).toContain("Discarded POTION(S).");
world.choose(-1);
until(world);
pick(world, "WITHDRAW ITEM");
world.choose(1);
expect(until(world)).toContain("Withdrew 1 MYSTERY EGG(S).");
world.choose(0);
world.choose(1);
expect(until(world)).toContain("Withdrew 1 POTION(S).");
world.choose(-1);
until(world);
expect(pick(world, "MAIL BOX")).toContain("There's no MAIL here.");
close(world);
expect(world.state.pcItems).toEqual([]);
expect(world.state.bag).toMatchObject({
items: [{ item: POTION, quantity: 3 }],
keyItems: [MYSTERY_EGG],
});
});
it("rates the Pokédex on Oak's PC", () => {
const world = fixture();
world.state.flags.add(ENGINE_FLAGS.pokedex);
for (const species of [1, 2, 3, 4, 5])
world.state.pokedex.seen.add(species);
world.state.pokedex.caught.add(1);
open(world);
expect(pick(world, "PROF.OAK's PC")).toEqual([
"PROF.OAK's PC accessed.",
"POKéDEX Rating System opened.",
"Want to get your POKéDEX rated?",
]);
world.choose(1);
const pages = until(world).join(" ");
expect(pages).toContain("Current POKéDEX completion level:");
expect(pages).toContain("5 POKéMON seen 1 POKéMON owned");
expect(pages).toContain("Look for POKéMON in grassy areas!");
expect(pages).toContain("The link to PROF. OAK's PC closed.");
close(world);
});
it("validates and migrates stored Pokémon and items", () => {
const world = fixture();
const before = world.save();
const box = toBoxMon(world.state.party[1]);
for (const [storage, pcItems] of [
[{ ...before.storage, current: 14 }, before.pcItems],
[
{
...before.storage,
boxes: before.storage.boxes.map((_, i) =>
i ? [] : Array.from({ length: 21 }, () => box),
),
},
before.pcItems,
],
[
{
...before.storage,
boxes: before.storage.boxes.map((_, i) =>
i ? [] : [{ ...box, hp: 10 }],
),
},
before.pcItems,
],
[
{ ...before.storage, names: before.storage.names.map(() => " ") },
before.pcItems,
],
[before.storage, [{ item: C.POKE_BALL, quantity: 100 }]],
[
before.storage,
Array.from({ length: 51 }, () => ({ item: POTION, quantity: 1 })),
],
] as const) {
expect(() => world.restore({ ...before, storage, pcItems })).toThrow(
/Invalid native save/,
);
expect(world.save()).toEqual(before);
}
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
delete old.storage;
delete old.pcItems;
world.restore(old);
expect(world.state.storage.boxes.every((box) => !box.length)).toBe(true);
expect(world.state.storage.names.every((name) => name === null)).toBe(true);
expect(world.state.pcItems).toEqual([]);
});
});
describe("PC storage rules", () => {
it("removes from the selected stack, or else the first match", () => {
const list = [
{ item: 1, quantity: 99 },
{ item: 2, quantity: 5 },
{ item: 1, quantity: 3 },
];
expect(removeFromSlot(list, 2, 1, 3)).toBe(true);
expect(list).toEqual([
{ item: 1, quantity: 99 },
{ item: 2, quantity: 5 },
]);
expect(removeFromSlot(list, 1, 1, 9)).toBe(true);
expect(list[0]).toEqual({ item: 1, quantity: 90 });
expect(removeFromSlot(list, 1, 2, 6)).toBe(false);
expect(removeFromSlot(list, 0, 3, 1)).toBe(false);
});
it("holds fifty stacks, filling existing stacks first", () => {
const items = Array.from({ length: MAX_PC_ITEMS }, (_, i) => ({
item: i + 1,
quantity: 98,
}));
expect(receivePCItem(items, 7, 1)).toBe(true);
expect(items[6].quantity).toBe(99);
expect(receivePCItem(items, 7, 1)).toBe(false);
expect(receivePCItem(items, 99, 1)).toBe(false);
});
it("accepts only complete storage", () => {
const storage = emptyStorage();
expect(validStorage(storage)).toBe(true);
expect(validStorage({ ...storage, boxes: storage.boxes.slice(1) })).toBe(
false,
);
expect(validStorage({ ...storage, names: [...storage.names, null] })).toBe(
false,
);
expect(
validStorage({
...storage,
names: ["LONGNAME9", ...storage.names.slice(1)],
}),
).toBe(false);
expect(
validStorage({
...storage,
names: ["GOOD ONE", ...storage.names.slice(1)],
}),
).toBe(true);
expect(validStorage({ ...storage, current: -1 })).toBe(false);
});
});
+8
View File
@@ -60,6 +60,14 @@ async function drive(page: Page, name = "") {
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
await page.clock.runFor(500);
} else {
// A scene script starts a tick after the tick that ends a warp, so a
// single idle observation is not the end of the scene.
await page.clock.runFor(200);
if (
(await page.locator("#game-screen").getAttribute("data-activity")) !== "idle" ||
(await page.locator("#native-dialogue").isVisible())
)
continue;
await expect(page.locator("#game-notice")).toHaveText("");
return messages.join("\n");
}
+218
View File
@@ -0,0 +1,218 @@
import { BinjgbEmulator } from "../src/emulator/binjgb";
import type { Direction } from "../src/game/world/types";
/** CPU addresses (and banks for ROM routines) from the private PRET build's
* symbol file, read by the Node side of the test. */
export interface ReferenceSymbols {
[label: string]: { bank: number; address: number };
}
/** One overworld tick of the original engine, sampled after HandleMap ran. */
export interface ReferenceTick {
map: string;
x: number;
y: number;
facing: Direction;
/** wMapStatus: 1 (MAPSTATUS_ENTER) when this tick began a map change. */
status: number;
/** Map objects with an object struct: OBJECT_MAP_X/Y by script object
* number (OBJECT_MAP_OBJECT_INDEX + 1, as object_const_def starts at 2). */
objects: Record<number, [number, number]>;
events: number[];
poses: Record<
number,
{ direction: Direction; visible: boolean; lift: number }
>;
}
const FACINGS: Direction[] = ["down", "up", "left", "right"];
let emulator: BinjgbEmulator | undefined;
let symbols: ReferenceSymbols;
const at = (label: string) => {
const symbol = symbols[label];
if (!symbol) throw new Error(`Missing reference symbol ${label}.`);
return symbol.address;
};
const read = (label: string) => emulator!.readMemory(at(label));
function sample(): ReferenceTick {
const objects: Record<number, [number, number]> = {};
const poses: ReferenceTick["poses"] = {};
// wObjectStructs after the player: OBJECT_SPRITE, OBJECT_MAP_OBJECT_INDEX,
// OBJECT_MAP_X/Y at $10/$11, $28 bytes each.
for (let slot = 1; slot < 13; slot++) {
const base = at("wPlayerStruct") + slot * 0x28;
if (!emulator!.readMemory(base)) continue;
const id = emulator!.readMemory(base + 1) + 1;
const offset = emulator!.readMemory(base + 0x1a);
poses[id] = {
direction: FACINGS[(emulator!.readMemory(base + 8) >> 2) & 3],
visible: emulator!.readMemory(base + 0xb) !== 0,
lift: offset < 128 ? offset : offset - 256,
};
objects[id] = [
emulator!.readMemory(base + 0x10) - 4,
emulator!.readMemory(base + 0x11) - 4,
];
}
return {
map: `${read("wMapGroup")}:${read("wMapNumber")}`,
// OBJECT_MAP_X/Y include the four-tile border offset.
x: read("wPlayerMapX") - 4,
y: read("wPlayerMapY") - 4,
facing: FACINGS[(read("wPlayerDirection") >> 2) & 3],
status: read("wMapStatus"),
objects,
poses,
events: Array.from({ length: 2048 }, (_, event) => event).filter((event) =>
Boolean(
emulator!.readMemory(at("wEventFlags") + (event >> 3)) &
(1 << (event & 7)),
),
),
};
}
/** Advance until HandleMap has run once more. ResetOverworldDelay sets
* wOverworldDelay to 2 at the start of each HandleMap and VBlank counts it
* down, so a sampled 2 marks a new overworld tick. Map setup between ticks
* (warps, connections) takes frames that are not ticks. */
/** Advance to the next time rLY reads 143, just before VBlank: HandleMap's
* work for the tick is done and VBlank has not yet counted wOverworldDelay
* down. Map setup turns the LCD off (rLY stays 0), so the phase is found
* again on every frame rather than once. */
function nextFrame() {
const line = emulator!.readMemory(0xff44);
emulator!.advanceCycles(
(line === 143 ? 154 : (143 - line + 154) % 154) * 456,
);
}
function nextTick() {
let pressing = false;
for (let frame = 0; frame < 3000; frame++) {
// Text waits inside one tick of the original; after a few frames with no
// tick, press A in pulses until the script moves on.
if (frame >= 8 && frame % 2 === 0) {
pressing = !pressing;
emulator!.setButton("a", pressing);
}
nextFrame();
const status = read("wMapStatus");
if (read("wOverworldDelay") === 2 && (status === 2 || status === 1)) {
emulator!.setButton("a", false);
return;
}
}
throw new Error("The reference overworld did not advance.");
}
function hold(held: Direction | undefined) {
for (const direction of FACINGS)
emulator!.setButton(direction, direction === held);
}
/** Boot the user's ROM, then run the original ResetWRAM and enter a map
* through FinishContinueFunction with MAPSETUP_WARP and SPAWN_N_A, from a
* WRAM trampoline on the stack page (which _ResetWRAM leaves alone). */
export async function bootReference(
bytes: Uint8Array,
table: ReferenceSymbols,
start: { map: string; x: number; y: number },
/** Work RAM written after ResetWRAM, such as scene variables. */
writes: { label: string; value: number }[] = [],
) {
emulator?.dispose();
symbols = table;
emulator = new BinjgbEmulator();
await emulator.load(bytes, document.createElement("canvas"));
emulator.advanceFrames(400);
const [group, number] = start.map.split(":").map(Number);
const routine = (label: string) => {
const { bank, address } = table[label];
return [0x3e, bank, 0x21, address & 255, address >> 8, 0xcf]; // rst FarCall
};
const store = (value: number, label: string) => {
const address = at(label);
return [0x3e, value, 0xea, address & 255, address >> 8];
};
const hram = (value: number, label: string) => [
0x3e,
value,
0xe0,
at(label) & 255,
];
const program = [
0xf3, // di
0x3e,
0x01,
0xe0,
0x70, // rSVBK = 1
0x31,
0xff,
0xc0, // ld sp, wStackTop
0xfb, // ei
...routine("ResetWRAM"),
...store(0xff, "wDefaultSpawnpoint"), // SPAWN_N_A
...store(group, "wMapGroup"),
...store(number, "wMapNumber"),
...store(start.x, "wXCoord"),
...store(start.y, "wYCoord"),
// STATUSFLAGS_NO_WILD_ENCOUNTERS_F: traces must not start battles.
...store(1 << 5, "wStatusFlags"),
...writes.flatMap(({ label, value }) => store(value, label)),
...hram(0xf1, "hMapEntryMethod"), // MAPSETUP_WARP
...routine("FinishContinueFunction"),
0x18,
0xfe,
];
// The trampoline shares the stack page, whose top the game uses.
if (program.length > 0x80) throw new Error("Trampoline too long.");
program.forEach((value, index) =>
emulator!.writeMemory(0xc000 + index, value),
);
emulator.setProgramCounter(0xc000);
hold(undefined);
nextTick();
// Let the map name sign and any entry scene settle before tracing.
for (let tick = 0; tick < 8; tick++) nextTick();
return { ...sample(), timeOfDay: read("wTimeOfDay") };
}
/** Hold the given direction (or nothing) for one tick each. */
export function traceReference(
inputs: (Direction | undefined)[],
): ReferenceTick[] {
return inputs.map((held) => {
hold(held);
nextTick();
return sample();
});
}
/** Development aid: read reference memory directly. */
export function peek() {
return (address: number) => emulator!.readMemory(address);
}
/** One video frame, sampled just before VBlank like the ticks. */
export function stepFrame() {
nextFrame();
}
/** vTiles2 in VRAM bank 0 as the original has animated it: 128 tiles. */
export function readTiles(): number[] {
const bank = emulator!.readMemory(0xff4f);
emulator!.writeMemory(0xff4f, 0);
const tiles = Array.from({ length: 0x800 }, (_, index) =>
emulator!.readMemory(0x9000 + index),
);
emulator!.writeMemory(0xff4f, bank);
return tiles;
}
export function disposeReference() {
emulator?.dispose();
emulator = undefined;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { drawTile } from "../src/game/rendering/pixels";
import { TilesetAnimator } from "../src/game/rendering/tileset-animation";
import { CrystalAssets } from "../src/game/rom/assets";
import { OW } from "../src/game/world/constants";
import type { Palette } from "../src/game/world/types";
it("grass priority uses background indices, preserving transparent sprite pixels", () => {
const palette: Palette = [
[255, 255, 255],
[80, 160, 80],
[32, 64, 32],
[0, 0, 0],
];
const target = new Uint8ClampedArray(8 * 8 * 4);
const indices = new Uint8Array(8 * 8);
const background = new Uint8Array(16);
background[0] = 0x80; // Colored grass at x=0; color zero elsewhere.
drawTile(target, 8, 8, background, 0, 0, 0, palette, { indices });
const sprite = new Uint8Array(16);
sprite[1] = 0xc0; // Sprite color 2 at x=0 and x=1, transparent at x=2.
drawTile(target, 8, 8, sprite, 0, 0, 0, palette, {
indices,
behind: true,
transparent: true,
});
expect([...target.slice(0, 12)]).toEqual([
80, 160, 80, 255, 32, 64, 32, 255, 255, 255, 255, 255,
]);
expect([...indices.slice(0, 3)]).toEqual([1, 0, 0]);
drawTile(target, 8, 8, sprite, 0, 0, 0, palette, {
indices,
transparent: true,
});
expect([...target.slice(0, 4)]).toEqual([32, 64, 32, 255]);
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"overworld rendering with the local ROM",
() => {
it("runs every tileset animation without changing the cached ROM graphics", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const base = assets.map("24:4");
for (let tileset = 1; tileset <= OW.NUM_TILESETS; tileset++) {
const map = { ...base, tileset };
const graphics = assets.tileset(map).graphics;
const original = graphics.slice();
const animator = new TilesetAnimator(assets, map, graphics);
animator.advance(1024, false);
animator.advance(1024, true);
expect(graphics, `tileset ${tileset}`).toEqual(original);
expect(animator.graphics.length).toBe(original.length);
}
});
it("keeps special indoor palettes fixed and uses normal palettes for the Hall of Fame", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const base = assets.map("24:6");
for (const tileset of [
OW.TILESET_POKECOM_CENTER,
OW.TILESET_BATTLE_TOWER_INSIDE,
OW.TILESET_HOUSE,
OW.TILESET_RADIO_TOWER,
OW.TILESET_MANSION,
]) {
const map = { ...base, tileset };
expect(assets.palettes(map, 0)).toEqual(assets.palettes(map, 2));
expect(assets.palettes(map, 0)).toHaveLength(8);
}
const ice = {
...base,
tileset: OW.TILESET_ICE_PATH,
environment: OW.CAVE,
};
expect(assets.palettes(ice, 0)).toEqual(assets.palettes(ice, 2));
const hall = { ...ice, environment: OW.INDOOR };
expect(assets.palettes(hall, 1)).not.toEqual(assets.palettes(ice, 1));
expect(assets.palettes(hall, 1)).toEqual(
assets.palettes({ ...hall, tileset: OW.TILESET_POKECENTER }, 1),
);
});
},
);
+2 -1
View File
@@ -104,7 +104,8 @@ export function walkTo(
targetY: number,
answers: (number | string)[] = [],
): string[] {
const shown: string[] = [];
// Finish anything in progress first, such as the tick after a warp.
const shown: string[] = settle(world, answers);
for (let attempt = 0; attempt < 20; attempt++) {
let blocked = false;
for (const direction of findPath(world, targetX, targetY)) {