From 451464f7c6e131e5743aad6a1a4aaad20c927341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Fri, 25 Sep 2026 16:47:29 +0200 Subject: [PATCH 1/2] Complete the overworld foundation Port forced movement (turn delays, bumps, ice, currents, pits), dynamic return warps, elevators, Surf, Cut, Strength and cycling rules, working map blocks with changeblock/changemapblocks, and the remaining NPC movement types and movement commands. Add tileset animation from the ROM's command tables, grass sprite priority and special map palettes; room decorations with ownership, the bedroom PC and save migration; and Key Items access to the Bicycle. Fourteen original-engine traces compare positions, facing, actors, event flags and scripted poses. Tile animation matches original VRAM for four tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now keeps the $60 sprite offset its step function leaves behind. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/ARCHITECTURE.md | 23 +- docs/NATIVE_PORT.md | 10 +- docs/PLAN.md | 55 +- docs/SESSION_HANDOFF.md | 102 ++ scripts/check-reference.mjs | 19 +- src/emulator/binjgb.ts | 18 + src/game/rendering/minimap.ts | 2 +- src/game/rendering/pixels.ts | 15 +- src/game/rendering/renderer.ts | 45 +- src/game/rendering/tileset-animation.ts | 173 +++ src/game/rendering/town-map.ts | 44 + src/game/rom/assets.ts | 102 +- src/game/rom/profile.ts | 107 ++ src/game/runtime.ts | 19 +- src/game/scripts/interpreter.ts | 657 ++++++++- src/game/scripts/movement.ts | 113 +- src/game/world/constants.ts | 111 ++ src/game/world/decorations.ts | 100 ++ src/game/world/world.ts | 1797 +++++++++++++++++++---- src/main.ts | 261 +++- src/ui/shell.ts | 2 + src/ui/styles.css | 51 + tests/battle-browser.spec.ts | 4 +- tests/decorations.test.ts | 166 +++ tests/forced-movement.test.ts | 400 +++++ tests/foundation-browser.spec.ts | 118 ++ tests/foundation-fixture.ts | 26 + tests/movement-fixture.ts | 90 ++ tests/movement.test.ts | 117 +- tests/native.test.ts | 22 +- tests/overworld-parity.spec.ts | 445 ++++++ tests/overworld-rom.test.ts | 455 ++++++ tests/overworld-routines.spec.ts | 101 ++ tests/quest-browser.spec.ts | 8 + tests/reference-overworld.ts | 218 +++ tests/rendering.test.ts | 87 ++ tests/world-driver.ts | 3 +- 38 files changed, 5600 insertions(+), 490 deletions(-) create mode 100644 src/game/rendering/tileset-animation.ts create mode 100644 src/game/rendering/town-map.ts create mode 100644 src/game/world/constants.ts create mode 100644 src/game/world/decorations.ts create mode 100644 tests/decorations.test.ts create mode 100644 tests/forced-movement.test.ts create mode 100644 tests/foundation-browser.spec.ts create mode 100644 tests/foundation-fixture.ts create mode 100644 tests/movement-fixture.ts create mode 100644 tests/overworld-parity.spec.ts create mode 100644 tests/overworld-rom.test.ts create mode 100644 tests/overworld-routines.spec.ts create mode 100644 tests/reference-overworld.ts create mode 100644 tests/rendering.test.ts diff --git a/README.md b/README.md index 538b0a6..06757cf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ 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 bedroom PC lets you arrange owned decorations, and the poster opens the Town Map. + +Route 31 and beyond, PC storage, full party and bag menus, other items in battle 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. ## Run diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7898183..61d7531 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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,24 @@ 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. +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. diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 605d8ad..b214fcf 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -25,12 +25,12 @@ 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, the decoration PC and Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | | `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. | | 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. | diff --git a/docs/PLAN.md b/docs/PLAN.md index 7698490..1860710 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 | @@ -124,7 +124,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli 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. -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, PC storage, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the bedroom PC supports 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 +134,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 diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 367d7a2..91ae35e 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1,5 +1,8 @@ # Recovered Claude session +Latest continuation: see **Overworld foundation completed: 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,102 @@ 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. diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index dda95ba..977fcc7 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -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 and OVERWORLD entries are keyed by their PRET labels. +for (const table of ["BATTLE", "OVERWORLD"]) { + 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); diff --git a/src/emulator/binjgb.ts b/src/emulator/binjgb.ts index aac9820..5fbdd0e 100644 --- a/src/emulator/binjgb.ts +++ b/src/emulator/binjgb.ts @@ -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, diff --git a/src/game/rendering/minimap.ts b/src/game/rendering/minimap.ts index 6160917..731b852 100644 --- a/src/game/rendering/minimap.ts +++ b/src/game/rendering/minimap.ts @@ -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; diff --git a/src/game/rendering/pixels.ts b/src/game/rendering/pixels.ts index 78bd300..2069c2f 100644 --- a/src/game/rendering/pixels.ts +++ b/src/game/rendering/pixels.ts @@ -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]; diff --git a/src/game/rendering/renderer.ts b/src/game/rendering/renderer.ts index 6fe256b..7600082 100644 --- a/src/game/rendering/renderer.ts +++ b/src/game/rendering/renderer.ts @@ -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), }, ); } diff --git a/src/game/rendering/tileset-animation.ts b/src/game/rendering/tileset-animation.ts new file mode 100644 index 0000000..d65380f --- /dev/null +++ b/src/game/rendering/tileset-animation.ts @@ -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); + } + } +} diff --git a/src/game/rendering/town-map.ts b/src/game/rendering/town-map.ts new file mode 100644 index 0000000..8c606ff --- /dev/null +++ b/src/game/rendering/town-map.ts @@ -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}`); +} diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index 7316e25..ff39ece 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -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 = { diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 321e758..aca49ef 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -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,111 @@ 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; + /** 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 = { diff --git a/src/game/runtime.ts b/src/game/runtime.ts index d88bd90..e4aab3e 100644 --- a/src/game/runtime.ts +++ b/src/game/runtime.ts @@ -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 + -

From New Bark Town to Mr. Pokémon

Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.

Move
Arrow keys
Interact / Continue
X / Z or A / B

Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.

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.

Choose
Up / Down, then X or A. Z or B answers No.

An optional walking companion may support egg progress after party, eggs and hatching are complete.

`; +

From New Bark Town to Mr. Pokémon

Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.

Move
Arrow keys
Interact / Continue
X / Z or A / B

Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.

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.

Choose
Up / Down, then X or A. Z or B answers No.

An optional walking companion may support egg progress after party, eggs and hatching are complete.

`; } diff --git a/tests/catch-tutorial.test.ts b/tests/catch-tutorial.test.ts index 0f564fe..8b0a3fd 100644 --- a/tests/catch-tutorial.test.ts +++ b/tests/catch-tutorial.test.ts @@ -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); }); }); diff --git a/tests/decorations.test.ts b/tests/decorations.test.ts index 56dfc79..248940c 100644 --- a/tests/decorations.test.ts +++ b/tests/decorations.test.ts @@ -37,7 +37,10 @@ function pc(world: GameWorld) { }); world.interact(); expect(world.notice, world.diagnostic).toBe(""); + // _PlayersHousePC: " 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; diff --git a/tests/foundation-browser.spec.ts b/tests/foundation-browser.spec.ts index b11dee8..90489d5 100644 --- a/tests/foundation-browser.spec.ts +++ b/tests/foundation-browser.spec.ts @@ -9,7 +9,7 @@ test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."), ); -async function start(page: Page, room: boolean | "poster" = false) { +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(); @@ -67,8 +67,15 @@ test("the bedroom PC puts away a decoration and saves the result", async ({ await start(page, true); await page.keyboard.press("KeyX"); await expect(page.locator("#game-notice")).toHaveText(""); - await expect(page.locator("#dialogue-text")).toHaveText("DECORATION"); + 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 }) @@ -116,3 +123,62 @@ test("the Town Map poster opens a native map and closes with B", async ({ 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 }]); +}); diff --git a/tests/foundation-fixture.ts b/tests/foundation-fixture.ts index 94ba805..ae91e93 100644 --- a/tests/foundation-fixture.ts +++ b/tests/foundation-fixture.ts @@ -1,10 +1,13 @@ 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", + room: boolean | "poster" | "center", ) { const world = new GameWorld( assets, @@ -13,12 +16,34 @@ export function foundationFixture( ); 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: room ? "24:7" : "24:4", - x: room === "poster" ? 6 : room ? 2 : 9, - y: room === "poster" ? 1 : room ? 2 : 6, + map, + x, + y, direction: "up", objects: [], }); diff --git a/tests/pc.test.ts b/tests/pc.test.ts new file mode 100644 index 0000000..35a9496 --- /dev/null +++ b/tests/pc.test.ts @@ -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>; + 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); + }); +});