diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 95de865..5d5918f 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -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 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 @@ -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. 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. diff --git a/docs/design/macros.md b/docs/design/macros.md index d4aac9a..085a8d8 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1032,3 +1032,88 @@ rather than argued: `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}`. 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`.