docs: macros.md section 15 and the WRAM table behind it

Section 15 is the contract for map-aware walks: what is decoded, the one ROM
read and why it is a read of the cartridge image, what the window fallback is
for and how it says so, and the proof. macros-wram.md section 9 is the bytes --
four addresses with their encodings, the bank-aware read on the seam, the corner
the cartridge settled, the two gates against a plausible-but-wrong decode, and
the survey of Pallet Town and Viridian Forest.
This commit is contained in:
claude 2026-09-22 04:21:54 +00:00
parent c8de2e42e9
commit b9e01dea5b
2 changed files with 177 additions and 0 deletions

View file

@ -205,6 +205,9 @@ current tileset's list of passable tiles, walking it until it matches or hits `$
is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank 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. 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: 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 1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and
@ -573,3 +576,92 @@ PP, type effectiveness applied from the ROM's type chart", and section 14 replac
one button per move slot: which move is used is the fly's choice and the mushroom body's to learn. 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 Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods
shorter and `pokemon_red/state.rs` never needed them. 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<u8>`, 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.

View file

@ -1032,3 +1032,88 @@ rather than argued:
`LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box `LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box
moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`. moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`.
Nothing in the layout differs from main. Nothing in the layout differs from main.
## 15. Map-aware walks: one plan over the whole map (the operator, 2026-09-22: "the frontier and
## warp macros need to be map aware: A* over walkable tiles.")
Section 4 has said "A* over the current map's walkable tiles" since the first draft, and it was
never that. The walkable predicate answers for the ten tiles by nine of the screen buffer and
`Walkable::Unknown` for everything else (`docs/design/macros-wram.md`), so every walk in the game
planned through guesses at eight times the price of a known tile, re-planned at every window edge,
and `GO FRONTIER` aimed at whatever unstood ground was on screen -- never the far side of a town,
which nothing could see.
What changes is where the tile ids come from. The rule does not change at all: a tile is walkable
when the current tileset's collision list holds its id, which is `CheckTilePassable`.
- **The whole map is decoded** from the tables the cartridge has already loaded: the block ids out
of `wOverworldMap`, the block-to-tiles blockset out of the tileset header, the collision list as
before, and the `TilePairCollisionsLand` pairs as **directed walls**. `MapGrid` is every tile of
the loaded map with those walls, and `docs/design/macros-wram.md` section 9 is the byte-level
evidence, the new addresses and the verification.
- **One read of a ROM bank was needed, so `MemoryReader` gained one method.** The blockset does
not live in bank 0, and the only way to reach another bank through the CPU bus would be to
*write* the mapper's bank register. `MemoryReader::read_rom(bank, address)` reads the cartridge image the process
already holds instead: the same bytes, addressed the way the disassembly addresses them, and no
write into a running game. The joypad register is still the only write (section 12).
- **`path::route` and `path::frontier` plan over the grid**, and the plan is the same A*: one step
per tile, the multi-goal heuristic, the committed route of section 12.3, re-planned only on a
refusal or a displacement. `GO WARP`, `GO OUT` and `GO ROUTE` route to their warp or connection
tile across the whole map; `GO OBJECTIVE` takes the exit that is the first hop; the frontier is
the nearest unstood walkable tile *anywhere on the map*, by the stood ledger of section 12.7.
- **The window stays as the fallback, and it says when.** A frame with no grid falls back to the
ten-by-nine reading exactly as before, and `GridRefusal` names which of the five reasons it is:
no map header, no player, no collision list, no blockset (which is what a reader with no
cartridge behind it answers), the map not on screen, or the decode disagreeing with the screen.
Nothing is guessed and nothing silently degrades.
- **The grid is checked against the screen before it is trusted, and again whenever it is served.**
The decode is compared with the window predicate over the tile the fly is standing on and its
four neighbours, and a frame where the window can answer for none of them is refused — the block
data shares its bytes with the picture buffer, so a battle is exactly when it belongs to somebody
else. Serving a cached grid re-checks the fly's own tile, because a warp writes the map id before
the header and the blocks: for a frame or two on a doormat, `wCurMap` is the map the fly is
arriving on and the blocks are still the map it is leaving.
- **Cached per map, decoded once on arrival**, dropped when the map or its size changes. Session
state beside the talked, blocked, reached, stood and errand ledgers; never checkpointed, so a
restored run decodes the map again on its first overworld frame.
- **The probes report it.** `examples/scene_probe.rs` prints the grid's size, its walkable count,
the count reachable from where the fly stands and the count never stood on, draws the ground with
the reading it used, and names the refusal when there is none; `examples/trap_hunt.rs` carries the
same line into every trace line and into the summary table. Three numbers read a stalled walk at
a glance: a fly with forty walkable tiles and four reachable ones is fenced in, and no amount of
re-planning will help it.
**Nothing about the choice moves.** The scene deals the same buttons, the readout presses them, and
what changed is what a chosen walk knows about the ground — which is where section 12 puts
knowledge. The decoder, the reward catalog, the adapter version and the compatibility string are
untouched: 648 bytes, `0d9bfde7…707fa`, byte-identical across the change.
Two things the cartridge settled rather than the design, both measured and both written up with
their bytes in `docs/design/macros-wram.md` section 9: the collision id of a map tile is the
**lower left** of its four screen tiles and not the upper left, and **a warp writes the map id
before the map**, which is what the per-serve check above is for.
### 15.1 The proof
- **Unit tests, no cartridge.** The decoder against a made-up tileset: a block's four quadrants,
a collision list over a map wider than the window, a tile-pair collision as a wall in both
directions and not in another tileset, a block id past the end of the blockset staying `Unknown`,
and the reachable count over a fenced region. The reader against synthetic WRAM with a synthetic
blockset in a synthetic bank: the whole map decoded, a screen that disagrees refused, a reader
with no cartridge refused, a battle frame refused, and the cache holding one map. The search over
a grid: one plan across a map larger than the window where the window's own plan walks into a
wall it cannot see, a frontier beyond the window where the window's frontier is empty, a
tile-pair wall planned around, and a connection whose walls are not goals.
- **ROM-gated, from the release container's own checkpoints** (`tests/rom_map_grid.rs`, `FLY_ROM`
plus a checkpoint, skipped cleanly without either). On **Pallet Town** — 20x18, 221 walkable
tiles, 207 of them reachable, none unknown — and on **Viridian Forest** — 34x48, 719 walkable,
all reachable, none unknown: the decode agrees with the window predicate on all ninety tiles the
window can answer for, and it agrees with a **survey of real presses** on every one of the first
120 tiles the walk can stand on (58 refused presses on the town, 74 in the forest, every one of
them a wall, a directed wall or a tile a sprite was standing on; 10 presses in the forest that
the cartridge answered with a battle, which say nothing about the ground either way).
`GO FRONTIER` in the forest planned to ground outside the window and walked there in 215 frames
(615 of its frontier tiles are outside the window); every way out of the forest is one plan away,
27 to 149 steps, with no guessed tile in any of them.
- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, before and after, is in
`infra/docs/macros-traps.md`.