# Macro palette: the WRAM it reads Agent A's half of `docs/design/macros.md` section 8, built 2026-09-16. `macros.md` is the binding contract; this file is the interface agent B codes against and the evidence behind it: every byte the palette reads, its address at the pinned pokered commit, how it is encoded, and how the reading was verified. Sources: pret/pokered at **0cd19d3b877b7dc66d12c7050bed9a7f38154d4b**, the commit `docs/design/ladder.md` pins and `symbols::POKERED_COMMIT` records. Addresses come from that checkout's `pokered.sym` through `services/flysim/tools/gen_symbols.py`, which refuses to run against any other revision and refuses to take a hand-written address; 30 names were added to its `EXTRA_RAM` allowlist for this work, taking the table from 28 addresses to 58, and nothing else in it moved — no event flag, no milestone, no existing address — so no compatibility string changes. **2026-09-16, `GO ITEM` and the exit ledger:** three more names, taking the table from 58 addresses to 61 (`wNumSigns`, `wSignCoords`, `wSignTextIDs`), regenerated the same way and again with nothing else moved; `flysim --print-compatibility` is byte-identical across the change, 648 bytes, checked on the WSL box. Encodings come from the `.asm` that writes them, cited per row. Where it lives: | file | what | | --- | --- | | `pokemon_red/macros/state.rs` | `Scene`, every value type, and `trait GameState`. Self-contained: no imports, nothing from the rest of this work, so B can read and compile it alone. | | `pokemon_red/scene.rs` | `detect`, and `Scene` re-exported so `pokemon_red::scene::Scene` is the path `macros.md` section 2 promises. | | `pokemon_red/state.rs` | the accessors, twice: free functions over a `&mut dyn MemoryReader`, and `PokeState` implementing `GameState` on top of them. | | `pokemon_red/fake_wram.rs` | synthetic WRAM for the tests (test-only). | | `tests/rom_scene.rs` | the ROM-gated checks, `FLY_ROM`. | ## 1. The scene ```rust pub fn detect(memory: &mut dyn MemoryReader) -> Scene; pub fn why_unknown(memory: &mut dyn MemoryReader) -> String; // diagnostics, not a decision ``` Pokémon Red has no "which screen am I on" byte. It has a font flag, a text box id, a list menu id, one shared menu cursor and a screen buffer, and `detect` is an **ordered** sequence of tests over those, because the ordering is the safety property `macros.md` asks for: | order | test | reads | scene | | ---: | --- | --- | --- | | 1 | the game has not started | `wStatusFlags6` bit 0 | `Title` | | 2 | the map header or the party count is out of range | `wCurMap`, `wCurMapWidth`, `wCurMapHeight`, `wPartyCount` | `Unknown` | | 3 | a battle is running | `wIsInBattle`, `wBattleType` | `Battle { own_turn, forced_switch }`, or `Unknown` | | 4 | a PC is open | `wMiscFlags` bit 3 | `Pc` | | 5 | a mart is open | `wTextBoxID`, `wListMenuID` | `Shop` | | 6 | a text display is open | `wFontLoaded` bit 0, then the drawn box in `wTileMap` | `Dialog`, `Menu`, else `Unknown` | | 7 | everything else | `wJoyIgnore`, `wSimulatedJoypadStatesIndex`, `wStatusFlags5`, `wStatusFlags6`, `wMovementFlags`, the coordinates | `Overworld` | Battle is tested before anything on screen because a battle is *made* of text boxes and menus and every frame of it must still read as a battle. The overworld is last and is the only branch with a positive requirement on every gate. Anything left over is `Unknown`, which the doctrine treats like `Dialog` — advance only. `Unknown` is not a bug to drive to zero. The Pokédex, the trainer card, a naming screen and a mid-warp frame are none of them pinned down by the bytes above, and reporting them as `Unknown` is the design working. It is also what a half-written frame reads as. ### Two findings that shaped it - **The reward adapter's scripted-frame mask cannot be reused whole.** `PokemonRedReward::sample` rejects a frame with `wMovementFlags & 0xc7`, and bits 0, 1 and 2 of that byte are `BIT_STANDING_ON_DOOR`, `BIT_EXITING_DOOR` and `BIT_STANDING_ON_WARP` (`constants/ram_constants.asm`). `docs/design/room-escape.md` section 3 is the story of a fly that spends its life on doormats: masking those would report `Unknown` on the four tiles that matter most in the game's first hour. The scene gate keeps `0xc0` — the ledge hop and the spin tile, which genuinely are not the fly's to act on — and drops the door bits. - **A menu is recognised from the box the game drew, not from the cursor it parked.** `HandleMenuInput`'s state (`wTopMenuItemY`, `wTopMenuItemX`, `wCurrentMenuItem`, `wMaxMenuItem`, `wMenuWatchedKeys`) survives the menu closing, so the start menu's geometry is still in WRAM while the fly walks around. Every menu test therefore requires `wFontLoaded` **and** the drawn box **and** the geometry. A test for that is in `scene/tests.rs` (`the_cursor_geometry_alone_does_not_open_a_menu`). ## 2. The state table Addresses are at the pinned commit. "Verified" is one of: - **survey** — walked on a real cartridge with real button presses on throwaway emulators, the method `docs/design/room-escape.md` section 3 used (`tests/rom_scene.rs`). - **ROM** — asserted against the running cartridge in `tests/rom_scene.rs`. - **trace** — a synthetic WRAM trace in `scene/tests.rs` or `state/tests.rs`, built from the disassembly rather than from the implementation. - **decomp** — read out of the `.asm` named in the row. Every row has this; the others are what a synthetic trace or a cartridge added. ### Scene detection inputs | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | the game has started | `wStatusFlags6` | `$d732` | bit 0 `BIT_GAME_TIMER_COUNTING`. `MainMenu` is the only writer (`engine/menus/main_menu.asm:334`) and nothing clears it, so it is "a save is running", not "the timer ticks". Same gate the adapter calls `active`, so `Title` and the adapter's `BOOT` agree by construction. | ROM (3,000 idle frames on the title screen are one scene, and the adapter says `BOOT`), trace | | in a battle | `wIsInBattle` | `$d057` | 0 none, 1 wild, 2 trainer, `$ff` the frame a battle is lost | ROM, trace | | which kind of battle | `wBattleType` | `$d05a` | 0 normal, 1 the old man's tutorial, 2 Safari. Both non-zero values have their own menus, so they read as `Unknown`. | trace | | a text display is open | `wFontLoaded` | `$cfc4` | bit 0 `BIT_FONT_LOADED`. `DisplayTextIDInit` sets it for *every* text display, the start menu included; `CloseTextDisplay` clears it (`engine/menus/display_text_id_init.asm:33`, `home/text_script.asm:130`). | ROM, trace | | which box is drawn | `wTileMap` | `$c3a0` | 20x18 screen tile ids. `DisplayTextIDInit` draws the dialogue box at screen (0, 12), four rows by eighteen columns, so rows 12 to 17 full width; the start menu's box at (10, 0), fourteen rows with the Pokédex and twelve without. Recognised by all four `TextBoxBorder` corners — `┌ ┐ └ ┘` are `$79 $7b $7d $7e` (`constants/charmap.asm`) — because a map tile can hold one frame tile id and not two. | ROM, trace | | which text box template | `wTextBoxID` | `$d125` | `constants/menu_constants.asm`: `BATTLE_MENU_TEMPLATE` `$0b`, `BUY_SELL_QUIT_MENU` `$15`. | ROM, trace | | which list menu | `wListMenuID` | `$cf94` | `constants/list_constants.asm`: `PRICEDITEMLISTMENU` `$02` (a mart's buy list), `ITEMLISTMENU` `$03` (the bag), `SPECIALLISTMENU` `$04` (an elevator). Zeroed by `DisplayTextIDInit` at the start of every text display, so a stale value cannot outlive one. | trace | | a PC is open | `wMiscFlags` | `$cd60` | bit 3 `BIT_USING_GENERIC_PC`. `ActivatePC` sets it, `LogOff` clears it (`engine/menus/pc.asm`), covering Bill's, the player's and Oak's. | trace | | the buttons reach the player | `wJoyIgnore` | `$cd6b` | non-zero means the joypad is being ignored | trace | | ” | `wSimulatedJoypadStatesIndex` | `$cd38` | non-zero means the game is walking the player itself; `CollisionCheckOnLand` skips collision entirely then | trace | | ” | `wStatusFlags5` | `$d730` | mask `$a1`: bits 0, 5, 7 = scripted NPC movement, joypad disabled, scripted movement state | trace | | ” | `wStatusFlags6` | `$d732` | mask `$5c`: bits 2, 3, 4, 6 = fly, dungeon and escape warps in flight | trace | | ” | `wMovementFlags` | `$d736` | mask `$c0`: bit 6 a ledge hop, bit 7 a spin tile. **Not** bits 0 to 2, see above. | trace | | where the player is | `wCurMap`, `wXCoord`, `wYCoord` | `$d35e`, `$d362`, `$d361` | map id (`$f7` is the highest real one), tile coordinates. Y is *before* X in WRAM. | ROM, trace | | the map's size | `wCurMapWidth`, `wCurMapHeight` | `$d369`, `$d368` | in blocks; one block is two tiles each way, so tiles = byte × 2 — the conversion the reward adapter already makes | ROM (Red's rooms 8×8, Pallet Town 20×18), trace | ### Party, the battler and the enemy | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | party size | `wPartyCount` | `$d163` | 0 to 6. **Leads the structs it counts:** `AddPartyMon` writes the count first and fills the 44 bytes over the frames after it. Measured on the cartridge: the count became 1 3,245 frames before the starter's species was written, because Oak's gift is spread across a script. A caller that cares must require a non-zero species. | ROM (the gap is asserted), trace | | species list | `wPartySpecies` | `$d164` | six bytes then `$ff` | decomp | | party member | `wPartyMon1` | `$d16b` | `party_struct`, `PARTYMON_STRUCT_LENGTH` = `$2c` = 44 bytes, six of them (`wPartyMon2` is `$d197` = `$d16b + $2c`). Offsets: species 0, HP 1 (big-endian), status 4, moves 8 to 11, PP 29 to 32, level 33, max HP 34 (big-endian). | ROM (the starter: Charmander `$b0`, level 5, 19/19, SCRATCH 35 PP, GROWL 40 PP), trace | | species numbering | — | — | the cartridge's **internal index**, not the Pokédex number: Bulbasaur `$99`, Charmander `$b0`, Squirtle `$b1` (`constants/pokemon_constants.asm`). The reward adapter's `wPokedexOwned` bitset is by Pokédex number; the two numberings are different and nothing converts between them. | ROM | | status | — | — | `constants/battle_constants.asm`: `SLP_MASK` `%111` is sleep turns, then bit 3 poison, 4 burn, 5 freeze, 6 paralysis | trace (every byte) | | PP | — | — | one byte per slot: bits 0 to 5 remaining PP, bits 6 and 7 the number of PP Ups | ROM, trace | | the Pokémon that is out | `wBattleMonSpecies` … `wBattleMonPP` | `$d014`, HP `$d015`, status `$d018`, moves `$d01c`, level `$d022`, max HP `$d023`, PP `$d02d` | the battle engine's own copy of a party entry, and the copy it damages, so this is "own HP" in a battle | ROM (it matches the party entry it was copied from), trace | | which slot is out | `wPlayerMonNumber` | `$cc2f` | 0-based party slot | ROM, trace | | the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace | | how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace | | **a ball kept this one** | `wCapturedMonSpecies` | `$d11c` | **new 2026-09-22** (the catch reward, `docs/rewards-learning.md`). `ram/wram.asm`'s own comment is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw (`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon; `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way out of the battle. It is therefore non-zero for the hundreds of frames the catch's text and Pokédex screen take, and zero everywhere else. The value is the **internal** species index, like `wEnemyMonSpecies` and unlike `wPokedexOwned`'s bit index. Address resolved by `services/flysim/tools/resolve_wram.py`, bracketed by `wFontLoaded` and `wForcePlayerToChooseMon`. | survey (`tests/rom_catch.rs`: a real wild battle from a rung-9 checkpoint, balls thrown by the `THROW BALL` macro, the byte read out of the running game), trace (`pokemon_red/tests.rs`) | `wBattleResult` (`$cf0b`) is the second half of that row and is worth its own sentence: it is 0 for a win, 1 for a loss, and 2 on exactly two paths in the whole game -- `.returnAfterCapturingMon` and a *link* battle whose opponent ran (`engine/battle/core.asm`), which this cartridge never has. So "the captured-species byte was non-zero during the battle **and** the result is 2" is a catch and nothing else. `InitBattleVariables`, `ResetStatusAndHalveMoneyOnBlackout` and `HandleFlyWarpOrDungeonWarp` all clear it, so a stale 2 cannot survive into the next battle. Not used for the catch, and why: `wPartyCount` (`$d163`) rises on a catch **only** when the party has room -- a full party sends the Pokémon to `wBoxCount` instead -- and it also rises for a gift, a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need a second rule to tell those apart. The cartridge's own flag needs none, which is why the row above is the one the adapter reads. ### Battle menu and cursor, own turn against forced switch `HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it parked its cursor. All five bytes are contiguous: `wTopMenuItemY` `$cc24`, `wTopMenuItemX` `$cc25`, `wCurrentMenuItem` `$cc26`, `wMaxMenuItem` `$cc28`, `wMenuWatchedKeys` `$cc29`. | menu | signature | cursor | verified | | --- | --- | --- | --- | | the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace | | the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace | | the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace | | **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace | `own_turn` is the top-level menu being open. `forced_switch` is the party list a fainted Pokémon forces. A battle that is neither — text, an animation, the turn resolving — is `Battle { own_turn: false, forced_switch: false }`, which is most of a battle's frames. ### Text box, start menu, mart, PC | state | how | verified | | --- | --- | --- | | text box open / waiting | `open` is `wFontLoaded` bit 0; `waiting` is the full-width dialogue box drawn at rows 12 to 17. "Waiting" is honest about what it can know: the game is either printing into that box or waiting for A, and A is the button that advances it either way. Pokered has no "the text has stopped and wants a button" flag. | ROM (a dialogue in Oak's lab), trace | | start menu | the box at (10, 0) with its bottom at row 15 (with the Pokédex) or 13 (without), plus `wTopMenuItemY` = 2 and `wTopMenuItemX` = 11 and `wMaxMenuItem` 6 or 7 (`DrawStartMenu`). `DrawStartMenu` stores the item *count* in `wMaxMenuItem`, not the highest index, and `DisplayStartMenu` wraps at one less. | trace (both with and without the Pokédex) | | a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace | | mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace | | PC | `wMiscFlags` bit 3, above. | trace | | a two-option YES/NO box | **new 2026-09-22** (`docs/design/macros.md` section 12.12). `wFontLoaded` bit 0, plus the border `DisplayTwoOptionMenu` draws at (11, 6)-(19, 11), plus the shared cursor parked at `wTopMenuItemY` 8, `wTopMenuItemX` 12 with `wMaxMenuItem` 1 and `wMenuWatchedKeys` = A\|B. **Both halves are load-bearing**: the cursor bytes survive the box closing, so all forty-six frames of a Pokémon Center nurse's conversation carry that geometry while the box is drawn on exactly one of them. It does **not** answer "is a choice open" in general — Red places a two-option menu where the script asking for it says, and a prompt drawn elsewhere reads `false`. | ROM (the rung-10 Pokémon Center checkpoint, surveyed one raw A pulse at a time: `examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse`) | ### Money and bag | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | money | `wPlayerMoney` | `$d347` | three bytes of big-endian BCD, two digits each. A nibble above 9 is not BCD and reads as 0 rather than as a plausible number. | ROM (3,000 on a fresh save), trace | | bag | `wNumBagItems`, `wBagItems` | `$d31d`, `$d31e` | a count capped at `BAG_ITEM_CAPACITY` = 20, then `(id, quantity)` pairs, then `$ff`. The terminator wins over the count. | ROM (empty on a fresh save), trace | ### NPC sprites | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | sprite slots | `wSpriteStateData1`, `wSpriteStateData2` | `$c100`, `$c200` | 16 slots of 16 bytes each (`NUM_SPRITESTATEDATA_STRUCTS`). Slot 0 is the player. In `wSpriteStateData1`: byte 0 picture id, byte 2 image index (`$ff` = not on screen, which is what `LoadMapSpriteData` writes into the unused slots), byte 9 facing (`SPRITE_FACING_DOWN` 0, `UP` 4, `LEFT` 8, `RIGHT` 12). In `wSpriteStateData2`: byte 4 map Y, byte 5 map X, **both plus four**, because `MACRO object_event` emits `db \2 + 4` then `db \1 + 4` (`macros/scripts/maps.asm:16`). | survey + ROM (Mom, `object_event 5, 4` in `RedsHouse1F.asm`, is reported at (5, 4) and is exactly the tile three surveyed presses cannot enter), trace | | how many | `wNumSprites` | `$d4e1` | sprites on the current map, bounding the walk | trace | | the player's facing | `wSpriteStateData1 + 9` | `$c109` | slot 0's facing byte | ROM, trace | | a person or an object | — | — | `FIRST_STILL_SPRITE` = `SPRITE_POKE_BALL` = `$3d` (`constants/sprite_constants.asm`): the sprite list is ordered and everything from there up is a four-tile still sprite — a ball, a fossil, a boulder, a Pokédex on a table, a sleeping Snorlax. `engine/overworld/map_sprites.asm:101` uses exactly this comparison to tell one from a walker. `Npc::person` is the predicate; it is an *appearance* test, which is all a picture id can carry. | ROM (Oak's lab reports three `$3d` sprites where `OaksLab.asm` declares `object_event 6, 3` / `7, 3` / `8, 3` with `SPRITE_POKE_BALL`), trace | ### Signs | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | sign count | `wNumSigns` | `$d4b0` | capped at `MAX_BG_EVENTS` = 16 | trace | | sign table | `wSignCoords` | `$d4b1` | two bytes each, `Y, X`. `MACRO bg_event x, y, text` emits `db \2, \1, \3`, so **Y comes first** and — unlike `object_event` — there is **no +4 bias**: `home/overworld.asm`'s loader copies the pair straight across, and `IsSpriteOrSignInFrontOfPlayer` compares them against what `GetTileAndCoordsInFrontOfPlayer` returns, which is raw tile coordinates. A sign is therefore in the same space as `wXCoord` / `wYCoord`, exactly as the warp table is. | trace | | sign text ids | `wSignTextIDs` | `$d4d1` | one byte each, parallel to the coordinates; a sign's identity on this map and nothing more | trace | Signs are `bg_event`s: signposts, bookshelves, televisions, notice boards, the map on a gym wall. They are not sprites and their tiles are not walkable, so nothing about them reaches `npcs()` or `walkable()`, and `GO ITEM` is the only thing that reads them. Most indoor maps and every route have none — Oak's lab has none either, which is why the `GO ITEM` ROM test there is about the balls. ### Warps and connections | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | warp count | `wNumberOfWarps` | `$d3ae` | capped at `MAX_WARP_EVENTS` = 32 | ROM, trace | | warp table | `wWarpEntries` | `$d3af` | four bytes each, `Y, X, destination warp id, destination map id` (`ram/wram.asm:1829`'s own comment). `MACRO warp_event x, y, map, warp` emits `db \2, \1, \4 - 1, \3`, so **Y comes first** and the destination warp id is stored **zero-based**: Red's bedroom declares `warp_event 7, 1, REDS_HOUSE_1F, 3` and the bytes read 1, 7, 2, 37. A destination map of `$ff` is `LAST_MAP`, "back the way you came", which is what both of the ground floor's doormats carry. | ROM (all three warps of Red's ground floor and the bedroom's one), trace | | connections | `wCurMapConnections` | `$d370` | bitmask, `shift_const EAST, WEST, SOUTH, NORTH` = 1, 2, 4, 8 (`constants/map_data_constants.asm`) | ROM (Pallet Town is north and south only; an indoor map has none), trace | ### The walkable predicate and its window ```rust pub fn walkable(memory: &mut dyn MemoryReader, x: u8, y: u8) -> Walkable; // Yes | No | Unknown ``` It mirrors `CheckTilePassable` (`home/overworld.asm:1259`): take the tile id and look it up in the current tileset's list of passable tiles, walking it until it matches or hits `$ff`. - **the tile id** comes out of `wTileMap` at the offset `_GetTileAndCoordsInFrontOfPlayer` (`engine/overworld/player_state.asm:260`) would use. That routine reads screen (8, 9) for the tile the player stands on and (8, 11), (8, 7), (6, 9), (10, 9) for its four neighbours, which pins the mapping exactly: one map tile is two screen tiles each way and the player is always at (8, 9). `wOverworldMap` carries three blocks of border around the real map so the view can centre even on a map smaller than the screen, which is what makes "always" true. - **the list** is at `wTilesetCollisionPtr` (`$d530`), a little-endian pointer. Every `*_Coll` label at this commit resolves inside `00:172f`..`00:17f0` — ROM **bank 0**, which is always mapped — so a `MemoryReader` over the CPU bus can follow it. A pointer outside `$0100`..`$4000` is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank switch left mapped. This is the only ROM read in the module and the reason it is allowed. **Section 9 is the same predicate over the whole map** (2026-09-22): the window below is what the walks fall back to on a frame the map cannot be decoded, and no longer what they plan over. Three things bound it, all reported as `Unknown` rather than guessed: 1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and `y - 4 ..= y + 4`. A house is answerable whole from the middle of it and only in part from a corner; a town or a route never is. An A\* over this has to treat `Unknown` as impassable and re-plan as it moves, which is what the per-step check in `macros.md` section 4 already requires. 2. **The screen buffer is the map only while the map is on screen.** In a battle or under a text box it holds the battle or the box, so both read as `Unknown`. 3. **`wTileMap` is the current view only on a running machine.** Read straight after `Emulator::import_state`, with no frame in between, it holds the view from wherever the state was taken and the predicate answers about the wrong tiles — measured: four tiles of Red's ground floor disagreed exactly that way. The sim loop is safe by construction (the adapter samples after a completed frame); anything that restores a snapshot must run a frame first, and `tests/rom_scene.rs` gives every restored state twenty. What it does **not** model, and what the per-step "did the player move" check is for: NPCs standing in the way (`npcs()` reports those separately, and the survey found exactly three presses blocked that way, all of them into Mom), ledges, the tile-pair rules that stop a player walking between certain tiles (only CAVERN and FOREST have any, `data/tilesets/pair_collision_tile_ids.asm`), and warps that fire the instant they are stepped on. ## 3. The accessors, exactly Agent B holds `trait GameState`; `PokeState` implements it over live WRAM. Every method takes `&mut self` because reads are memoized per frame underneath (the emulator caches each address for the current frame, and the adapter caches again per sample), and nothing here mutates the game. No raw byte, address, mask or terminator crosses the trait. ```rust // pokemon_red/macros/state.rs — self-contained: no imports, nothing from the rest of this work. pub enum Scene { Title, Overworld, Dialog, Menu, Battle { own_turn: bool, forced_switch: bool }, Shop, Pc, Unknown, } impl Scene { pub fn playable(self) -> bool; // false only for Title pub fn label(self) -> &'static str; // "OVERWORLD", "BATTLE TURN", … at most 14 characters } pub enum Facing { Down, Up, Left, Right } impl Facing { pub fn delta(self) -> (i16, i16); } pub struct Player { pub map: u8, pub x: u8, pub y: u8, pub facing: Facing } pub struct MapSize { pub width: u8, pub height: u8 } pub enum Status { Healthy, Sleep(u8), Poison, Burn, Freeze, Paralysis } pub struct Move { pub id: u8, pub pp: u8, pub pp_up: u8 } pub struct Mon { pub slot: u8, pub species: u8, // internal index, not the Pokédex number pub level: u8, pub hp: u16, pub max_hp: u16, pub status: Status, pub moves: [Option; 4], } impl Mon { pub fn fainted(self) -> bool; pub fn hp_fraction(self) -> f64; } pub struct Party { pub mons: Vec, pub active: Option } impl Party { pub fn healthiest_reserve(&self) -> Option<&Mon>; // macros.md section 3's "healthiest" pub fn active_mon(&self) -> Option<&Mon>; } pub struct EnemyMon { pub species: u8, pub level: u8, pub hp: u16, pub max_hp: u16 } pub enum BattleKind { Wild, Trainer } pub enum BattleMenu { None, Main { cursor: u8 }, // 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN Moves { cursor: Option, count: u8 }, // 0-based move slot Party { cursor: u8 }, // 0-based party slot } pub struct Battle { pub kind: BattleKind, pub own_turn: bool, pub forced_switch: bool, pub menu: BattleMenu, pub own: Option, pub enemy: Option, } pub struct TextBox { pub open: bool, pub waiting: bool } pub struct Cursor { pub current: u8, pub max: u8, pub top_y: u8, pub top_x: u8, pub watched_keys: u8, } impl Cursor { pub fn cancellable(self) -> bool; } // whether B closes this menu pub struct StartMenu { pub cursor: Cursor, pub items: u8 } pub enum ShopScreen { BuySellQuit, Buying, Selling } pub struct Shop { pub screen: ShopScreen, pub cursor: Cursor } pub struct Pc { pub cursor: Cursor } pub struct BagItem { pub id: u8, pub count: u8 } pub struct Npc { pub slot: u8, pub picture: u8, pub x: u8, pub y: u8, pub facing: Facing } impl Npc { pub fn person(self) -> bool; } // picture < FIRST_STILL_SPRITE pub struct Sign { pub x: u8, pub y: u8, pub text_id: u8 } pub enum Walkable { Yes, No, Unknown } impl Walkable { pub fn is_walkable(self) -> bool; } // Yes only pub struct Warp { pub x: u8, pub y: u8, pub destination_warp: u8, pub destination_map: u8 } pub struct Connections { pub north: bool, pub south: bool, pub east: bool, pub west: bool } impl Connections { pub fn any(self) -> bool; } pub trait GameState { fn scene(&mut self) -> Scene; fn player(&mut self) -> Option; fn map_size(&mut self) -> Option; fn party(&mut self) -> Party; fn battle(&mut self) -> Option; fn text_box(&mut self) -> TextBox; fn start_menu(&mut self) -> Option; fn shop(&mut self) -> Option; fn pc(&mut self) -> Option; fn money(&mut self) -> u32; fn bag(&mut self) -> Vec; fn npcs(&mut self) -> Vec; // people and objects alike fn signs(&mut self) -> Vec; fn walkable(&mut self, x: u8, y: u8) -> Walkable; fn warps(&mut self) -> Vec; fn connections(&mut self) -> Connections; } ``` The same rules as free functions, for callers that already hold a reader — `detect` uses these, so there is one implementation of each rule and not two: ```rust // pokemon_red/state.rs pub struct PokeState<'a>; impl<'a> PokeState<'a> { pub fn new(memory: &'a mut dyn MemoryReader) -> Self; // empty ledger pub fn with_exits(memory: &'a mut dyn MemoryReader, exits: &'a dyn ExitLedger) -> Self; } impl GameState for PokeState<'_> { … } pub fn started(memory: &mut dyn MemoryReader) -> bool; // the game has begun at all pub fn controllable(memory: &mut dyn MemoryReader) -> bool; // the buttons reach the player pub fn map_size(memory: &mut dyn MemoryReader) -> Option; pub fn player(memory: &mut dyn MemoryReader) -> Option; pub fn party(memory: &mut dyn MemoryReader) -> Party; pub fn cursor(memory: &mut dyn MemoryReader) -> Cursor; pub fn battle(memory: &mut dyn MemoryReader) -> Option; pub fn text_box(memory: &mut dyn MemoryReader) -> TextBox; pub fn start_menu(memory: &mut dyn MemoryReader) -> Option; pub fn submenu(memory: &mut dyn MemoryReader) -> bool; pub fn shop(memory: &mut dyn MemoryReader) -> Option; pub fn pc(memory: &mut dyn MemoryReader) -> Option; pub fn money(memory: &mut dyn MemoryReader) -> u32; pub fn bag(memory: &mut dyn MemoryReader) -> Vec; pub fn npcs(memory: &mut dyn MemoryReader) -> Vec; pub fn signs(memory: &mut dyn MemoryReader) -> Vec; pub fn walkable(memory: &mut dyn MemoryReader, x: u8, y: u8) -> Walkable; pub fn warps(memory: &mut dyn MemoryReader) -> Vec; pub fn connections(memory: &mut dyn MemoryReader) -> Connections; pub mod poke { … } // the disassembly's own constants: pad bits, box tiles, template ids, masks ``` ### The exploration ledger (2026-09-16) `MacroState::exit_visited` is no longer on its default. `PokeState::with_exits` carries a `crate::macros::ExitLedger` — one question, `exit_visited(MapExit) -> bool`, asked across `crate::adapter`'s seam — and `GameAdapter::exit_visited` answers it from the `boundary` keys the reward rule already writes into its lifetime `seen` set: | the palette asks | the adapter looks up | | --- | --- | | `ExitId::Warp(i)` | `boundary::::on` or `:near`, with ``/`` from `warps()[i]` | | `ExitId::Edge(e)` | `boundary::edge::on` or `:near` | Three things about it are deliberate. It is a **read**: `&self`, no payout, no ledger write, so `REWARD_ADAPTER` and the compatibility string do not move. A warp is named by its **tile** on the adapter's side and by its **index** on the palette's, because a warp index is only stable while the map is loaded and the ledger is lifetime state. And "visited" means **either half** of the key: the `:on` half is what an interior warp records on arrival, but a town door fires on the step *onto* it, so the adapter never samples a stable frame there and `:on` alone would call every house door in the game unvisited forever. Either half means "this run has been paid for finding this exit", which is the question `GO EXIT` is actually asking. `pokemon_red/state.rs` deliberately does **not** reach the cartridge's ROM tables: the type chart, base powers and item prices are in banks 1 and up, where a `MemoryReader` reads whatever bank the last switch left mapped. So `macros.md` section 3's "best damaging move, type effectiveness applied from the ROM's type chart" is agent B's, and it needs a source this module cannot be. What B has from here is the move ids, their PP, the enemy's species and level, and the party. ## 4. Tests `cargo test -p flybrain-gb` — 146 lib tests in the crate against 98 before this work, so 48 new, and none of them needs a cartridge: - **every scene from a synthetic trace** (`scene/tests.rs`, 22 tests), including the four the brief names: a battle never reads as overworld even with the dialogue box drawn over it, a dialog never reads as overworld, an unreadable frame reads as `Unknown`, and standing on a doormat is still the overworld. Plus `every_scene_the_enum_declares_has_a_synthetic_trace`, which is one assertion over all nine. - **every accessor encoding** (`state/tests.rs`, 26 tests): big-endian HP, every status byte, packed PP, BCD money and a nibble that is not BCD, the bag's terminator winning over its count, sprite coordinates biased by four, the one-based move list, the battle menu's four positions, the party list with and without a way out, `MAX_WARP_EVENTS` bounding the table, each connection bit, the window edges of the walkable predicate, and a collision pointer outside bank 0. - `the_live_implementation_answers_the_whole_trait` walks every method of `GameState` through `PokeState`. `FLY_ROM=… cargo test --release -p flybrain-gb --test rom_scene` — four ROM-gated tests, nine seconds, skipped cleanly without the variable. There are no archived save states in the repository (`.gitignore` excludes `*.state` with the ROM), so they produce their own the way `examples/room_escape.rs` does: boot once, then drive stage by stage with a fixed-seed walk, each stage ending on a condition. Observed frames, for reproducibility: | state | how | frame | | --- | --- | ---: | | title | 600 idle frames past the boot logo, then 3,000 more | 600 | | bedroom | Start and A through the intro, then B until the adapter reports a settled overworld sample | 3,845 | | Red's ground floor | uniform random walk | 5,233 | | Pallet Town | uniform random walk | 9,593 | | a dialogue in Oak's lab | Oak's script, triggered by reaching `wYCoord == 1` in Pallet Town | 12,825 | | the starter | A on a ball and A on YES; species written at | 18,148 | | a trainer battle | the rival challenges the player where he stands | 19,468 | | the battle menu | B advances the opening text; A would overshoot into the move list | 20,100 | What each one asserts: - **title** — 3,000 idle frames are `Title` and nothing else, the adapter says `BOOT`, and no player, party or walkable tile is readable. - **bedroom, ground floor, Pallet Town** — `Overworld` at each, the map sizes, the bedroom's one warp with its zero-based destination warp id, an empty party, a 3,000 wallet, an empty bag, Pallet Town's north and south connections, and `Unknown` for a tile across the town. - **the walkable predicate against a survey** — the survey reproduces `docs/design/room-escape.md` section 3 exactly: 48 reachable tiles and the same six presses that leave Red's ground floor. Then the predicate is compared against it from all 48 tiles and all four directions — 171 comparisons, no disagreements — plus the three presses the survey cannot make because Mom is standing on a passable tile, which `npcs()` reports at exactly `object_event 5, 4`. This is the assertion that catches a wrong screen origin or a wrong stride, because both of those still answer, and answer plausibly. - **Oak's lab** — the dialogue is a `Dialog` with an open, waiting box over a still-loaded map whose tiles read `Unknown`; the starter is one of the three internal species ids at level 5 with full HP and a level-1 learnset; the battle is a `Trainer` battle whose combatants are not written on its first frame, then a `Main { cursor: 0 }` menu that RIGHT moves to ITEM and DOWN to RUN. Two facts about the emulator came out of building these and are worth keeping: - a state exported while a button was held does not respond to that button, or any other, being held again after the import — measured, all four directions, 64 frames each, no movement. Every state these tests keep is exported after twenty released frames. - a press the player is not already facing turns it first and steps second, and the pair took 53 frames from the ground floor's staircase, so `examples/room_escape.rs`'s 48-frame window would read that as a wall. The survey here holds for 120. ## 5. What could not be verified - **The mart and the PC have synthetic traces only.** Neither is reachable from a fresh cartridge inside a test's worth of frames — Viridian City's mart is rungs away — so `Shop` and `Pc` are built from the disassembly and checked against traces. `BUY_SELL_QUIT_MENU` having exactly one user in the game is the strongest thing said about the mart, and it is a grep, not a run. - **`Menu` is verified for the start menu and three submenus**, and those three are a floor, not a ceiling: the Pokédex, the trainer card, OPTION and the naming screens read as `Unknown`. That is safe (advance only) and it is not complete. - **A forced switch has no ROM test.** It needs a fainted Pokémon with a second one in the party, which is two battles away from anything a fixed-seed walk reaches quickly. The trace is built from `ChooseNextMon` and `PartyMenuInit`, and the distinguishing byte (`wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU`) is asserted both ways. - **`yes_no_prompt` is "*this* two-option box is drawn", not "a choice is open".** The claim in earlier revisions of this file — that pokered has no observable for a choice — stands for the general question and is now narrowed rather than withdrawn: the box the Pokémon Center nurse's offer is drawn in has been surveyed on the cartridge and is readable, and every other two-option menu in the game is not, because `DisplayTwoOptionMenu` takes its coordinates from the script that calls it. A prompt this reading misses is a plain dialog, which is the pad it had before the reading existed. - **`text_box().waiting` is "the dialogue box is drawn", not "the game wants a button".** Pokered has no flag for the second thing; A is the right press either way, so the distinction has no consequence for the palette, but it is not what the field's name might suggest. - **The walkable predicate is tile-only**, with the three bounds above. Ledges, tile pairs, and warps that fire on the step onto them are not modelled at all. - **Nothing here is verified against a second cartridge revision.** The adapter is pinned to one SHA-256 and so is this. ## 6. For agent B - Copy `pokemon_red/macros/state.rs` verbatim; it compiles alone. `PokeState::new(memory)` is the implementation. - `Scene::Unknown` and `Scene::Dialog` share a palette (`macros.md` section 2), and `Unknown` is an ordinary reading, not an error: every menu the detector does not name, the frames a warp passes through, and the `$ff` frame a lost battle passes through all land there. - Battle and menu macros navigate by cursor, never by counting presses (`macros.md` section 4). The cursors here are already translated out of pokered's own indexing: `Main` is 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN with LEFT and RIGHT changing column, and `Moves` is 0-based. - A macro that reads the party immediately after a gift or a catch can see a member with species 0: `wPartyCount` leads the struct. Require a non-zero species. - `walkable` answers about a ten-by-nine window that moves with the player, and `Unknown` is not `No`. An A\* has to re-plan as it walks, and the per-step movement check is not optional. ## 7. Shops, Pokémon Centers and the counter (2026-09-17, `docs/design/macros.md` section 13) Two more names through `services/flysim/tools/gen_symbols.py`, taking the table from 61 addresses to **63**, regenerated the same way and again with nothing else moved — no event flag, no milestone, no existing address. `flysim --print-compatibility` is byte-identical across the change, 648 bytes, checked on the WSL box. | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so **an item's position in this list is its cursor index** in the buy menu — which is what lets a purchase be navigated by reading the cursor rather than by counting presses. The terminator wins over the count, as it does for the bag, and the sixteen-byte buffer bounds both. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace | | the tileset's counter tiles | `wTilesetTalkingOverTiles` | `$d532` | three tile ids from the tileset header (`data/tilesets/tileset_headers.asm`), `$ff` for a tileset with fewer. Mart and Pokecenter are both `$18 $19 $1e`; the overworld and an ordinary house have none. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch (`home/overworld.asm:1115`) walks exactly this list and doubles the talking range from `$10` to `$20` pixels — one tile to two — when the tile in front of the player is one of them. | ROM (the Viridian mart's column 1 and the centre's (3, 2) read as counters and the floor either side does not), trace | **Why the second one is load-bearing.** A mart clerk is `object_event 0, 5, SPRITE_CLERK` behind a counter running down column 1; a Pokémon Center nurse is `object_event 3, 1, SPRITE_NURSE` behind the counter tile at (3, 2). **None of the four tiles around either of them is standable** — they are wall, or floor behind the desk. An approach that only knew how to stand beside somebody could never reach a counter at all, and `GO SHOP` would refuse `no route` once per hold for ever. The counter rule is what makes the tile *two* away, facing in, a place to talk from. New accessors in `pokemon_red/state.rs`, all three over the same reader: ```rust pub fn shop_stock(memory: &mut dyn MemoryReader) -> Vec; pub fn counter_tiles(memory: &mut dyn MemoryReader) -> [u8; 3]; pub fn map_tile_id(memory: &mut dyn MemoryReader, x: u8, y: u8) -> Option; pub fn counter_tile(memory: &mut dyn MemoryReader, x: u8, y: u8) -> bool; ``` `map_tile_id` is `walkable`'s own first half, lifted out so that "is this tile passable" and "is this tile a counter" read one byte by one rule and not two: the same ten-by-nine window, the same screen origin, and the same three bounds — off the map, off the window, or a screen holding a battle or a text box instead of the map. `PokeState`'s `shop_stock` is **gated on the mart scene being up**, and that gate is the whole of its accuracy: `wItemList` is a scratch buffer that nobody clears, so off the counter it holds whatever the last list was — a previous mart's stock, or a `MonsterNames` list from a battle. Answering `[]` everywhere else is the narrowing the rest of this seam is made of. ### The errand ledger and the area `MacroState::area_visited(kind, area)` is section 13's `areaVisited`: session state in the executor layer beside the talked, blocked, reached and stood ledgers, never checkpointed, written by `PokemonPalette::observe` on the frame the fly is seen standing on a mart's or a centre's own map. "Which area" is `macros::geography::area_of`: outdoors a map is its own area, indoors it is the outdoor map the front door opens onto, so standing in a Viridian house, in the mart or in the gym are all "in Viridian". A building the graph has no row for has no area, and therefore no errand. The mart and centre **map ids** are in `pokemon_red/maps.rs`, each counted out between two ids that module already pinned, which is the only way one of them could be wrong without a test noticing: `VIRIDIAN_POKECENTER` `$29` immediately precedes `VIRIDIAN_MART` `$2a`; `PEWTER_MART` `$38` and `PEWTER_POKECENTER` `$3a` lie in the five ids between `PEWTER_GYM` `$36` and `MT_MOON_1F` `$3b`; `CERULEAN_POKECENTER` `$40` and `CERULEAN_MART` `$43` lie around `CERULEAN_GYM` `$41`. ### 7.1 The in-battle bag list (2026-09-17, `docs/design/macros.md` section 14) `BattleMenu` gains a fourth member, and it closes a gap rather than adding a feature. | menu | signature | cursor | verified | | --- | --- | --- | --- | | the bag, in a battle | `wIsInBattle` non-zero, none of the three geometries above, and `wListMenuID` = `ITEMLISTMENU` (`$03`) | `wCurrentMenuItem`, with the bag's own entry count as the bound | trace | It is **not** the fly's own turn: the bag is a list the fly opened *during* its turn, and the pad that belongs to it is the two answers any list has, which is what the between-turns row deals (`NEXT`, `BACK`, section 13.1). Reporting it as the own turn would put the four move buttons on a screen they cannot press. Before this the bag read as `BattleMenu::None`, so `palette::listing` answered `None` for it, and `ITEM` could open the bag and then had nothing to read: it waited out `CURSOR_WAIT` — 180 frames, pressing nothing, which is the correct behaviour for a list with no cursor — and reported `Blocked`. Every potion the fly ever chose ended that way. `THROW BALL` needs the same list. ## 8. What section 14 removed The move table's base power and type, the ROM's type chart and the opposing Pokémon's types are **gone from the seam**, not defaulted. They existed for `ATTACK`'s "highest base power move with PP, type effectiveness applied from the ROM's type chart", and section 14 replaced `ATTACK` with one button per move slot: which move is used is the fly's choice and the mushroom body's to learn. Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods shorter and `pokemon_red/state.rs` never needed them. ## 9. The whole map, not the window (2026-09-22, `docs/design/macros.md` section 15) The walkable predicate of section 2 answers about ten tiles by nine because that is how much map the screen buffer holds. Every tile of the loaded map follows the same rule, decoded from the tables the cartridge has loaded. **Four more names through the same door.** `services/flysim/tools/gen_symbols.py`'s `EXTRA_RAM` takes the table from 63 addresses to **67**, and nothing else in it moves — no event flag, no milestone, no existing address. `flysim --print-compatibility` is byte-identical across the change: 648 bytes, `0d9bfde7…707fa`. The prototype checkout `gen_symbols.py` reads is not on this box, so the four addresses were resolved the way it would have resolved them, by a second tool that reads the disassembly directly: `services/flysim/tools/resolve_wram.py` walks `ram/wram.asm` at the pinned commit with a byte cursor that is **only ever live while it is anchored on an address `symbols.rs` already pins**, and emits an address only when a pinned address *after* it agrees as well. It re-derives 40 of the 63 addresses the table already carries with no disagreement, and each of the four new ones is bracketed by two of them. A declaration form it cannot size exactly kills the cursor rather than being guessed at, so an unanchored region cannot produce a number at all. | state | symbol | address | encoding | verified | | --- | --- | ---: | --- | --- | | the loaded map's blocks | `wOverworldMap` | `$c6e8` | one byte per 4x4-tile block. `LoadTileBlockMap` (`home/overworld.asm`) fills it from the map's own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself `MAP_BORDER` = 3 rows and columns in, so the border can hold strips of the connected maps. The map's own blocks are therefore a WRAM read. | survey + ROM (Pallet Town and Viridian Forest, below), trace | | which tileset | `wCurMapTileset` | `$d367` | tileset id (`constants/tileset_constants.asm`, `OVERWORLD` 0 … `FOREST` 3 … `CAVERN` 17). Keys the tile-pair lists, which is the only thing this work reads it for. | ROM, trace | | the blockset's bank | `wTilesetBank` | `$d52b` | the tileset header's `db BANK(\1)` (`data/tilesets/tileset_headers.asm`). Not bank 0, which is the whole reason the seam grew a bank-aware read. | ROM (the overworld tileset's blockset reads from bank `$19`), trace | | blocks to tiles | `wTilesetBlocksPtr` | `$d52c` | little-endian pointer, 16 bytes per block id, four rows of four screen tile ids. `DrawTileBlock` (`home/overworld.asm`) indexes it as `block * $10` and walks four rows of four, which pins the layout exactly. | ROM, trace | ### The one ROM read that needed a bank `MemoryReader` gains `read_rom(bank, address) -> Option`, defaulted to `None`. The bus read cannot reach the blockset — banks 1 and up are whatever the cartridge's last switch left mapped, and the only way to change that would be to *write* the mapper's bank register, which the doctrine forbids (`docs/design/macros.md` section 12: the joypad register is the only write). So the emulator implements it over **the cartridge image the process already holds**: below `$4000` it is bank 0 whatever the bank says, `$4000..$8000` is the banked window, and an offset past the end of the image is `None`. Nothing is written, no bank is switched, and the emulator's state does not move. Every other reader — the synthetic WRAM of the tests, the sim loop's stubs — keeps the default, and `None` there means the grid narrows to the window predicate rather than decoding a map out of whatever bytes were to hand. ### The corner, which was measured A map tile is 2x2 screen tiles and `CheckTilePassable` matches **one** id, so a decode has to pick the same one the cartridge picks. It is the **lower left** of the four. The upper left is the plausible guess: the view is centred so that the player's own 2x2 begins at screen row 8 and `_GetTileAndCoordsInFrontOfPlayer` reads `(8, 9)`, which is its lower half. Measured on the cartridge rather than argued: Viridian Forest's (4, 32) reads `$23` on the screen, which is the second row of its block, where the first row holds `$04`. On a town most quadrants hold one tile id four times over, so an upper-left decode reads correctly there and falls apart in a forest — which is exactly the shape of mistake the cross-check below exists for. ### Two gates, because a wrong decode answers plausibly - **Against the screen, before the grid is trusted.** The decoded ids are compared with `map_tile_id` over the fly's own tile and its four neighbours; a frame where the window can answer for none of them is refused. `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own `UNION`), so a battle is precisely when the blocks under it are somebody else's. - **Against the screen again, whenever a cached grid is served.** A warp writes `wCurMap` before the header and the blocks: measured on Oak's lab's doormat, where `wCurMap` reads `PALLET_TOWN` while the header still reads the lab's ten-by-twelve. The decode and the screen agree on such a frame — both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does the cached grid still agree with the screen about the tile the fly is standing on. ### The survey, on two maps `services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of `docs/design/room-escape.md` section 3: walk the map with real button presses on throwaway emulators, 120 frames of held direction per step and twenty released frames before each state is kept, and compare the grid against what the cartridge did. | map | size | walkable | reachable | unknown | window tiles compared | tiles surveyed | refused presses | a sprite was in the way | a battle or a script answered | | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | Pallet Town `$00` | 20x18 | 221 | 207 | 0 | 90, no disagreement | 120 | 58, all explained | 4 | 0 | | Viridian Forest `$33` | 34x48 | 719 | 719 | 0 | 90, no disagreement | 120 | 74, all explained | 2 | 10 | "All explained" is the assertion that matters: every press the cartridge refused is a tile the grid calls a wall, a directed wall out of that tile, or a tile a sprite was standing on **in the frame the press was made in** — Pallet Town's two villagers walk, so reading the sprite list from the state the survey started in would not do. And every step the cartridge made is one the grid would have planned. Both halves are needed: the first catches a decode that is too permissive, the second one that is too strict. Three things are still not modelled, and none of them is new: a sprite in the way (the sprite list answers that, and the executor's per-step moved check covers the rest), a warp that fires on the step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The water half of the tile-pair lists is deliberately absent: it is the list `CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.