Compare commits

..

No commits in common. "928d66bd8cc905f10cb83aff996416aef5e8cce0" and "592c2640a3ddc0e8ae55af093cf888ed319d3b34" have entirely different histories.

26 changed files with 113 additions and 3847 deletions

View file

@ -205,9 +205,6 @@ 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
@ -576,92 +573,3 @@ 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<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

@ -859,61 +859,6 @@ reproduction and the numbers; two macros, and section 12.2's one rule between th
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.10 No two buttons on a battle pad undo each other (2026-09-22, rung 9, seventy-one hours)
Thirty-five minutes after v0.4.2 deployed, the watchdog flagged the same rung again. Rank 9
(VIRIDIAN FOREST, next PEWTER CITY), seventy-one hours on it, and since the restart the macro
starts were `NEXT` **1264**, `BACK` **1241**, `THROW BALL` 5 and `GO WARP` 3 -- the event log
alternating `NEXT start/done, BACK start/done` every hold, on map 51.
Reproduced from the live checkpoint with the real cartridge and the real brain
(`infra/docs/macros-traps.md` has the numbers): twenty brain minutes, **71,673 frames of them all
in one battle**, 73 of 73 windows flagged, one distinct tile, and the two counts that name the
mechanism -- `BACK` **739 starts on the move list** and `NEXT` **739 on the top-level menu**.
- **The pair was split across two sub-states of one turn, which is why 12.9 did not catch it.**
`NEXT`'s script is one press of A. On a frame of battle text that A advances the text, which is
what the button is for. On the *top-level battle menu* the same A press **confirms whatever the
cursor is sitting on**, and the cursor sits on FIGHT, so `NEXT` opened the move list -- whose
`BACK` closed it again. Each button was legitimate where it stood: 12.9's rule is about `BACK`
and a list, and there *was* a list to leave. What is a trap is the **pair**: two buttons that
undo each other with nothing else changing, so the roll lands on one of them nearly every hold
and the turn never resolves. Section 12.2's rule, stated at the pad instead of at one macro:
**no pair of buttons on any battle pad may undo each other with nothing else changing.**
- **`NEXT` is the A that advances text, so it belongs only on a frame with no cursor accepting
input.** It is off every own-turn pad: the top-level menu, the move list, the party list and the
bag. The between-turns row keeps it alone, and the forced switch keeps it as row 8's backstop --
the one arm with a cursor and no `BACK` at all, because a forced switch cannot be cancelled.
- **`MOVE 1` is the backstop the top-level menu keeps.** `NEXT` was there for the turn where every
other button drops (row 7), and it was the wrong button for the job twice over: it does not end
a turn, and what it does instead is reopen the list `BACK` had just closed. `MOVE 1`'s
precondition over that menu is 12.8's -- "is there a move list to open", and FIGHT always opens,
because `CheckPlayerHasUsableMoves` sets Struggle without opening it -- so it is bound there
whatever the seam makes of `wBattleMon*`, and its script over that menu is "confirm FIGHT and
stop", which reads no move at all. A battler the seam cannot read is not a reason to take the
turn's one ending button away.
- **The bag is the fly's turn, and its pad is the bag's own answers.** `Battle::own_turn` answered
`false` for it, which was the last frame in the game with a cursor accepting input and no own
turn -- so it landed on the between-turns row, and that row's `NEXT` on an open bag is the A
press that *uses* whatever the cursor holds. The invariant is now whole: **a battle frame with a
cursor accepting input is the fly's turn**, the forced switch excepted because it has a pad of
its own. The bag's row is `ITEM` (use the thing) / `THROW BALL` / `BACK`; `CONFIRM` is gone from
it, because it was the same blind A press under another name, and both scripts already navigated
this list by reading its cursor (7.1 of `macros-wram.md`).
- **`wListMenuID` is not stale, which is why the bag reading stands.** It is zeroed by
`DisplayTextIDInit` at the start of every text display (`macros-wram.md`), so a battle's text
frames cannot inherit an `ITEMLISTMENU` from a bag the fly closed. Checked because a stale byte
there would have put the bag's pad on every frame of battle text.
- **What the harness now holds, rather than the pads alone.** The ROM-gated run from this
checkpoint asserts that no battle pad deals both buttons, that `NEXT` is on no frame with a
cursor accepting input, that the longest `NEXT`/`BACK` alternation is under four, and that every
battle it enters *ends* -- 25 of 25, worst 275 macros -- where v0.4.2 spent a whole hunt inside
one that never did. The battle rules are asked of the **scene the pad was dealt for** and not of
`wIsInBattle`: the `$ff` frame a lost battle passes through reads `Unknown`, whose pad is
`NEXT, BACK` by contract (row 9), and it is not under a battle's rules.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.")
@ -991,12 +936,12 @@ observe is not a precondition, it is a guess.
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged. The cursor is the fly's to move with the **raw** D-pad, which still reaches the cartridge in macros mode; a SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for |
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu |
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
| Battle, own turn, party list | SWITCH, BACK | unchanged |
| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it |
| Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text |
| Battle, the bag | NEXT, **BACK** | **BACK added**, and the list now reports a *cursor* (`macros-wram.md` 7.1) — before this `ITEM` could open the bag and had nothing to read |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8) |
| Battle, between turns | NEXT | `BACK` was added here for the bag (the row above reads as nobody’s turn) and **taken back out by section 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2 |
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
| Title | nothing | unchanged, by contract: the readout's boot variant applies |
@ -1038,7 +983,6 @@ measured where it cannot.
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens |
And because "closed where a macro can close it" is not "closed":
`game.padEmptyMs` publishes how long a **playable** scene has had nothing on the pad, in brain
@ -1141,94 +1085,3 @@ 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, against `main` at v0.4.2:
**286 distinct (map, tile) become 489**, the median flagged window holds 55 tiles instead of 16,
`GO FRONTIER` runs 64 walks worth up to twelve net tiles instead of four worth one, and one walk
spends its cap where six did. It costs battle: flagged windows go 61 to 70 and windows under four
tiles 5 to 14, every one of them a window spent inside a battle, because a fly that covers more
ground walks into more grass. `infra/docs/macros-traps.md` has both runs whole and says so at
length; this is the one measurement in this file where more ground and fewer flags do not both
hold.

View file

@ -615,21 +615,3 @@ then purge; then the stale-doc pass.
the party already held. Fixed: BACK only where a list is open; THROW BALL skips held species.
Trap hunt: tiles 216 -> 286, listless BACK 175 -> 0, dialog frames 23,548 -> 0. Ethos check
held. Row 41 named: a nurse box answered YES 474 times on one tile.
- 2026-09-22 (for v0.4.3): map-aware walks merged (section 15): the whole loaded map is decoded
into a walkability grid from the map and tileset data (read-only, ROM bank reads over the
cartridge image), A* plans over the map, the frontier is the nearest unstood tile anywhere,
the window reader is the fallback. Verified against real presses on two maps. Trap hunt: 286 ->
489 tiles, timeouts 6 -> 1; flagged windows rose 61 -> 70, all battle windows (the battle pad
review in flight).
- 2026-09-22 (v0.4.3, loop review, auto): the watchdog flagged NEXT/BACK repeating 373 times in
ten brain minutes after v0.4.2. NEXT on the main battle menu confirmed FIGHT and opened the move
list, whose BACK closed it: a pair that undoes itself. NEXT is now off every pad with an
input-accepting cursor, MOVE 1 is the main menu backstop, the bag is the fly's turn. ROM test
fails on v0.4.2 and passes here. Trap hunt: 1 -> 260 tiles, windows under four tiles 73 -> 2.
Ships with map-aware walks. Ethos check held.
- 2026-09-22 (v0.4.3, loop review, auto): the watchdog flagged NEXT/BACK repeating 373 times in
ten brain minutes after v0.4.2. NEXT on the main battle menu confirmed FIGHT and opened the move
list, whose BACK closed it: a pair that undoes itself. NEXT is now off every pad with an
input-accepting cursor, MOVE 1 is the main menu backstop, the bag is the fly's turn. ROM test
fails on v0.4.2 and passes here. Trap hunt: 1 -> 260 tiles, windows under four tiles 73 -> 2.
Ships with map-aware walks. Ethos check held.

View file

@ -1359,233 +1359,3 @@ Four things about it that are not improvements, recorded rather than buried.
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter
version / roles untouched.
## 2026-09-22, section 15: the walkable window becomes the whole map
The operator: "the frontier and warp macros need to be map aware: A* over walkable tiles."
`docs/design/macros.md` section 15 is the contract, `docs/design/macros-wram.md` section 9 the
bytes, and this is the measurement. Nothing about the *choice* moves: the scene deals the same
buttons and what changed is what a chosen walk knows about the ground.
**Row 35 has no source any more.** That row — "the screen-buffer tile read disagrees with itself on
a map smaller than the screen", worked around by taking a counter's reach from the map id — was a
fact about reading tiles out of a view that cannot centre on an 8x8 map. The grid reads the map's
own block data, so it answers the same way from every tile of every map. The counter rule is left
exactly as it is: this branch changes the ground, not the amenity, and a workaround that is no
longer needed is not the same thing as one that was wrong.
### The trap hunt, before and after
20 brain minutes, the hunt's own seed, 4 sweep threads, the same connectome, the same cartridge and
the same rung-9 checkpoint in both runs. "Before" is `main` at v0.4.2, which is the branch's own
merge base, so the two rows differ by this work and nothing else:
```sh
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-rank9-20260922T0254.checkpoint \
FLY_TRAP_MINUTES=20 FLY_TRAP_MODE=macros cargo run --release -p flysim --example trap_hunt
```
| measure | before (`main`, v0.4.2) | after (this branch) |
| --- | ---: | ---: |
| distinct (map, tile) over 20 brain minutes | 286 | **489** |
| median distinct tiles in a flagged window | 16 | **55** |
| most tiles in any one window | 94 | **180** |
| macros started | 830 | 912 |
| done / blocked / timeout / refused | 726 / 97 / 6 / 0 | 820 / 90 / **1** / 8 |
| windows flagged | 61 of 73 | 70 of 73 |
| windows under 4 distinct tiles | 5 | 14 |
| frames in a battle (longest run) | 48,756 (9,549) | 50,413 (13,411) |
| frames in the overworld | 22,149 | 18,972 |
| `GO FRONTIER` done / timeout / mean net / max net tiles | 4 / 1 / 0.5 / 1 | **64** / 0 / 1.6 / **12** |
| `GO WARP` done / timeout / mean frames / max net | 14 / 2 / 403 / 30 | 4 / **0** / 751 / 26 |
| `GO ROUTE` done / timeout / mean frames / max net | 9 / 2 / 483 / 22 | 11 / 1 / 410 / 8 |
| `GO OBJECTIVE` done / timeout / max net | 3 / 1 / 8 | 5 / **0** / **42** |
| wall seconds on the development box | 1,390 | 932 |
**The ground is the measure and the ground moved.** 286 distinct tiles became 489; the median
flagged window holds 55 of them instead of 16 and the widest holds 180 instead of 94. `GO FRONTIER`
went from four walks worth a net tile each to sixty-four worth up to twelve, `GO OBJECTIVE`'s best
walk from eight net tiles to forty-two, and the only walk that spent its cap is one `GO ROUTE` that
gained forty-two tiles while doing it. Nothing timed out that used to arrive.
**The cost, named rather than buried: the flag count went the wrong way**, 61 windows to 70, and
the windows holding fewer than four tiles went 5 to 14. Every one of those is a battle. The fly
covers more ground, so it walks into more grass and more trainers: battle frames 48,756 to 50,413,
the longest single battle 9,549 frames to 13,411, overworld frames 22,149 down to 18,972. A
two-minute window spent inside one battle is a window with one tile in it, and the worst window of
the after-run is 83 macros on one tile with **no repeated sequence at all** — which is a battle,
not a loop. This file has recorded battle text as check 10's known false-positive shape since its
first section, and the `NEXT` x12 to x14 runs that flag 43 of the 70 windows are exactly it.
So the two halves of the usual reading disagree here for the first time, and this is the honest
statement of it: **more ground, more battle, more flags.** What the flag counts cannot show and the
tile counts can is that the fly is walking across maps instead of round the tile it is standing on.
### Gates
`cargo test --workspace`, `cargo clippy --all-targets` and `infra/tests/lint.sh` on the development
box. `flysim --print-compatibility` is byte-identical across the change: 648 bytes,
`0d9bfde7…707fa`, so the live checkpoint carries over.
## 2026-09-22, rows 42 and 43: the pair that undid itself
Thirty-five minutes after v0.4.2 deployed, the release watchdog flagged the same rung. Rank 9
(VIRIDIAN FOREST, next PEWTER CITY), seventy-one hours on it, and since the restart the macro
starts were `NEXT` **1264**, `BACK` **1241**, `THROW BALL` 5 and `GO WARP` 3, with the event log
alternating
```
NEXT start, NEXT done, BACK start, BACK done
```
every hold on map 51. It is the residual the previous review named and left -- "a `NEXT, BACK`
2-cycle x75 closes the run inside one battle... worth watching" -- at full scale, and the reason
that review's rule did not catch it is that **each of the two buttons was legitimate where it
stood**. `docs/design/macros.md` section 12.10 is the contract this closed against.
### The mechanism, measured
Twenty brain minutes from the release container's own rung-9 checkpoint, real cartridge, real
brain as the readout. The whole run -- **71,673 frames, all of them `battle`** -- was one battle,
on one tile, with 73 of 73 windows flagged and every window reading `NEXT, BACK` x74 or x75. The
two rows of the hunt's own sub-state table are the mechanism:
| macro start | battle sub-state | n |
| --- | --- | ---: |
| `BACK` | move list | **739** |
| `NEXT` | **main menu** | **739** |
| `NEXT` | move list, no cursor | 8 |
| `BACK` | party list | 1 |
| `MOVE 1` | main menu | 1 |
| `THROW BALL` | main menu | 1 |
1. **`NEXT` on the top-level battle menu is not the press that advances text.** `NEXT`'s script is
one press of A. Between turns that A advances the text box, which is the button's whole
purpose. On the top-level menu the same A press **confirms whatever the cursor sits on**, and
the cursor sits on FIGHT -- so `NEXT` *opened the move list*. It was put there as row 7's
backstop for a turn where every other button drops out, and it is the wrong button for that
job twice over: it does not end a turn, and what it does instead is reopen a list.
2. **`BACK` on the move list closed it again**, which is 12.9's contract and correct: backing out
of a list is one of exactly two answers to one. So neither rule was broken and the loop was a
*pair*: main menu -> `NEXT` -> move list -> `BACK` -> main menu, 739 times each, at 2.4 macros
per brain second, with the turn never resolving and the battle never ending. Section 12.2's
rule restated at the pad: **no pair of buttons on any battle pad may undo each other with
nothing else changing.**
3. **The bag was the last frame in the game with a cursor accepting input and no own turn.**
`Battle::own_turn` answered `false` for it (12.6 had no observable for the bag at all; 13.1 gave
it a cursor), so it landed on the between-turns row -- whose `NEXT` on an open bag is the A
press that *uses* whatever the cursor holds. Not what the live loop was made of, and the same
defect: an A press dealt where A does not advance text.
### What changed, all of it inside the macros
- **`NEXT` is off every own-turn pad**: the top-level menu, the move list, the party list and the
bag. It stays alone on the between-turns row and stays as row 8's backstop on the forced switch,
which is the one arm with a cursor up and no `BACK` at all, because it cannot be cancelled.
- **`MOVE 1` is the top-level menu's backstop instead**, and it is bound there whatever the seam
makes of `wBattleMon*`: the question over that menu is 12.8's "is there a move list to open",
FIGHT always opens, and `MOVE 1`'s script over that menu is "confirm FIGHT and stop", which reads
no move at all. A battler the seam cannot read is not a reason to take the turn's one ending
button away. Over an **open list** the per-slot PP rule is unchanged (row 30a, row 34).
- **The bag is the fly's turn** (`state::battle`), and its pad is the bag's own three answers:
`ITEM` (use what the cursor is on), `THROW BALL`, `BACK`. `CONFIRM` is gone from it -- the same
blind A press under another name -- and both scripts already navigated this list by reading its
cursor (`macros-wram.md` 7.1). The invariant is now whole: **a battle frame with a cursor
accepting input is the fly's turn**, the forced switch excepted because it has a pad of its own.
- **`wListMenuID` was checked for staleness rather than assumed sound**: it is zeroed by
`DisplayTextIDInit` at the start of every text display (`macros-wram.md`), so a battle's text
frames cannot inherit an `ITEMLISTMENU` from a bag the fly closed, and the bag reading stands.
- **The harness asks the scene the pad was dealt for, not `wIsInBattle`.** The `$ff` frame a lost
battle passes through reads `Unknown`, whose pad is `NEXT, BACK` by contract (row 9); the first
version of the new assertion accused that row of a battle rule it is not under.
| # | trap | trigger | test | fix, or why it is left |
| ---: | --- | --- | --- | --- |
| 42 | two buttons on a battle pad undo each other with nothing else changing: `NEXT` on the top-level menu opens the move list, `BACK` on the move list closes it | every wild battle, every turn -- 739 starts each in twenty brain minutes, one battle, 73 of 73 windows flagged | `no_battle_pad_holds_both_next_and_back`, `the_battle_row_is_the_move_buttons_switch_item_and_never_next`, `each_battle_menu_deals_its_own_pad`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated: it **fails on v0.4.2** with `NEXT` dealt on `battle/main`) | **fixed**: `NEXT` is off every pad with a cursor accepting input, and `MOVE 1` is the top-level menu's backstop, bound there whatever the battler reads as. `NEXT` keeps the between-turns row and the forced switch |
| 43 | the battle bag reads as nobody's turn, so its pad is the between-turns `NEXT` -- an A press that *uses* what the cursor holds | choosing ITEM in a battle; rare, because `ITEM` needs a potion and a hurt Pokemon | `a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn`, `the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses`, `back_is_on_a_battle_pad_only_where_a_list_is_open` | **fixed**: `own_turn` is true for the bag, and its pad is `ITEM` / `THROW BALL` / `BACK`. This is row 30c closed -- it was left in 12.6 for want of an observable, given a cursor in 13.1, and given the right *turn* here |
### The ROM-gated run, before and after
Same test, same checkpoint, sixty-seven brain minutes of the game-blind rotation, `v0.4.2`
(`592c264`) against this branch.
| measure | before (v0.4.2) | after |
| --- | --- | --- |
| verdict | **FAILED**: "`NEXT` was on the pad while a battle menu was accepting input" | **passed** |
| `NEXT` dealt on | `battle/between-turns`, **`battle/main`**, `battle/moves-unplaceable` | `battle/between-turns`, `battle/moves-unplaceable` |
| `BACK` dealt on | `battle/moves`, `battle/party` | `battle/bag`, `battle/moves`, `battle/party` |
| maps visited | **[51]** -- never left the forest | 51, 0, 12, 1, 41, 44, 42, 13, 50 (nine) |
| battles entered / ended | 14 / 15 | 26 / 25 |
| worst battle, in macros | not measurable: the run never left the fly's own turn | **275** |
| longest `NEXT`/`BACK` alternation | (the assertion fires first) | **1** |
| `MOVE 1..4` starts | 96 | **718** |
| `NEXT` / `BACK` starts | 1235 / 1315 of 3944 | 526 / 313 of 2090 |
| own-turn frames | 20,914 | 143,180 |
| `GO OBJECTIVE` on an overworld pad | map 51 only | nine maps |
### The trap hunt, before and after
Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge,
from the release container's rung-9 checkpoint, **driven by the brain** rather than by
`FLY_TRAP_STUB`: this branch adds no macro type, so the thirty-one `macro_<type>` populations are
the same function in both arms and `--print-compatibility` is byte-identical at 648 bytes. The two
arms differ in the macro code and in nothing else.
| measure | before (v0.4.2) | after |
| --- | ---: | ---: |
| distinct (map, tile) | **1** | **260** |
| windows flagged | 73/73 | **68/73** |
| windows under 4 tiles | **73** | **2** |
| longest repeated sequence in a window | **`NEXT, BACK` x75** | `NEXT` x20 |
| macros started | 1489 | 797 |
| frames in `battle` | **71,673** (one battle, the whole run) | 50,600 |
| frames in `overworld` | **0** | **20,394** |
| `NEXT` starts on the main menu | **739** | **0** |
| `BACK` starts on the move list | 739 | 263 |
| `MOVE n` starts | **1** | **113** |
| `THROW BALL` starts | 1 | 63 |
| `RUN` starts | 0 | 7 |
The before arm never left the battle it resumed in and never left the tile it stood on. The after
arm fought fifty thousand frames of battle *and* walked twenty thousand frames of overworld across
260 tiles.
Raw reports: `hunt-before-20260922T0459.md` and `hunt-after-20260922T0459.md` in the coordination
state's `runs/` directory.
### Residuals, named rather than worked around
- **`NEXT` on a move list whose cursor the seam cannot place is now the largest source of it** --
142 of the after arm's 248 `NEXT` starts, over 8,687 frames. That frame is *correctly* not the
fly's turn (row 30b: `MoveSelectionMenu`'s coordinates appear before the engine has copied the
active Pokemon into `wBattleMon*`), so `NEXT` there is the between-turns press and it advances.
It is not a 2-cycle -- the ROM-gated run measures the longest `NEXT`/`BACK` alternation at
**1** -- but it is the same unplaceable cursor 12.6 named, and the honest fix is still a WRAM
reading rather than a pad change.
- **`BACK` is 326 of 797 starts**, all of them over an open list: 263 on the move list and 63 on a
party list. Row 34's contract, and where it leads is a menu with `MOVE 1` on it.
- **The 68 windows still flagged are the sequence rule's dominance arm, not a cycle.** They read
`NEXT` x11 to x20 or `BACK` x11 to x14 over windows holding **5 to 86** distinct tiles, where
every window of the before arm read `NEXT, BACK` x74 on **one** tile. Only two windows of the
after arm are under four tiles, against seventy-three before. A window spent in a long battle is
one tile by construction, which is the known false-positive shape of the tile rule and why this
row is proved on the pad and sub-state tables as well.
- **Row 41 is not reproduced here and is not closed.** The after arm's text boxes are 140 frames
at (6, 30) of map 0x33 and smaller counts on seven other tiles, with no Pokemon Center in the
run at all; the nurse's `YES` loop needs its own checkpoint to measure.
- **The road north is still reported rather than asserted.** The ROM-gated run leaves the forest
and reaches maps 50, 13, 12, 1, 41, 44, 42 and 0 with `GO OBJECTIVE` on the overworld pad of all
nine, but it does not reach map 47 -- the forest's north gate -- in sixty-seven brain minutes of
the game-blind rotation. Row 40's note stands.
### Gates
- `cargo test --workspace` with `FLY_ROM` set: green except
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
which fails identically on v0.4.2 on this box with the same assertion (a debug build of the
service does not finish booting inside the test's window here). Pre-existing and unrelated to
the macro layer.
- `cargo clippy --all-targets`: clean.
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1 and
v0.4.2. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -20,37 +20,12 @@ use crate::ratchet::RecoveryPolicy;
/// equivalent of the prototype's `MemoryReader` interface.
pub trait MemoryReader {
fn read8(&mut self, address: u16) -> u8;
/// One byte of a ROM bank, by bank number rather than off the CPU bus.
///
/// [`MemoryReader::read8`] reads the bus, where banks 1 and up are whichever
/// bank the cartridge's last switch left mapped -- so a table in bank 3 is
/// unreadable through it, and the only way to make it readable would be to
/// *write* the mapper's bank register. The joypad is the only write this
/// workspace makes into a running game (`docs/design/macros.md` section 12),
/// so this reads the cartridge image the process already holds instead: the
/// same bytes, addressed the way the disassembly addresses them.
///
/// `address` is a CPU address: below `$4000` it is bank 0 whatever `bank`
/// says, and `$4000..$8000` is the banked window. Anything else, and any
/// offset past the end of the image, is `None`.
///
/// The default is `None`: a reader with no cartridge behind it cannot answer,
/// and every caller of this is written to narrow rather than guess when it
/// does not (`docs/design/macros-wram.md`, the whole-map grid).
fn read_rom(&mut self, _bank: u8, _address: u16) -> Option<u8> {
None
}
}
impl MemoryReader for &mut dyn MemoryReader {
fn read8(&mut self, address: u16) -> u8 {
(**self).read8(address)
}
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
(**self).read_rom(bank, address)
}
}
/// One reward payout in one frame.

View file

@ -26,10 +26,6 @@ pub const CPU_TICKS_PER_SECOND: u64 = 4_194_304;
/// so there is no `NEW_FRAME` event to wait for yet.
const MAX_FRAME_ATTEMPTS: u32 = 120;
/// Bytes in one ROM bank (`$4000`), which is how [`Emulator::read_rom_bank`]
/// turns a bank number and a CPU address into an offset in the cartridge image.
const ROM_BANK_BYTES: usize = 0x4000;
/// Audio frequency flysim runs the emulator at. binjgb resamples internally to
/// whatever is requested, so this is only a default.
pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000;
@ -139,13 +135,6 @@ impl FrameCache {
/// thread. It is deliberately not `Sync`.
pub struct Emulator {
gb: *mut ffi::FlyGb,
/// The cartridge image, for [`MemoryReader::read_rom`].
///
/// The shim owns its own padded copy behind the handle and does not hand it
/// back, so this is a second one. It is read-only from here: nothing in this
/// workspace writes a ROM byte, and the bank-addressed read is the only
/// reason it is kept.
rom: std::sync::Arc<[u8]>,
rom_sha256: [u8; 32],
audio_frequency: u32,
/// Raw binjgb samples drained since the last [`Emulator::take_audio`].
@ -178,7 +167,6 @@ impl Emulator {
debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN);
Ok(Self {
gb,
rom: rom.into(),
rom_sha256: Sha256::digest(rom).into(),
audio_frequency,
pending_audio: Vec::new(),
@ -254,25 +242,6 @@ impl Emulator {
unsafe { ffi::fly_gb_read_mem(self.gb, address) }
}
/// One byte of ROM bank `bank`, from the cartridge image rather than the bus.
///
/// `address` is a CPU address: `$0000..$4000` is bank 0 whatever `bank` says
/// (that is what "always mapped" means) and `$4000..$8000` is the banked
/// window. `None` for any other address and for an offset past the end of the
/// image, which is what a bank a smaller cartridge does not have reads as.
/// No bank register is written and the emulator's state does not move: this
/// is a read of bytes the process already owns.
pub fn read_rom_bank(&self, bank: u8, address: u16) -> Option<u8> {
let offset = match address {
0x0000..=0x3fff => usize::from(address),
0x4000..=0x7fff => {
usize::from(bank) * ROM_BANK_BYTES + usize::from(address) - ROM_BANK_BYTES
}
_ => return None,
};
self.rom.get(offset).copied()
}
/// Sample rate of the raw buffer, as binjgb configured it.
pub fn audio_frequency(&self) -> u32 {
self.audio_frequency
@ -395,10 +364,6 @@ impl MemoryReader for Emulator {
fn read8(&mut self, address: u16) -> u8 {
self.read_wram(address)
}
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
self.read_rom_bank(bank, address)
}
}
#[cfg(test)]

View file

@ -32,22 +32,12 @@ pub const WALL_TILE: u8 = 0x60;
pub struct Wram {
bytes: Vec<u8>,
/// Fake cartridge banks, for the one read that needs one.
///
/// A bank nothing has written answers `None`, which is what a seam with no cartridge behind
/// it answers and what the whole-map grid has to narrow on
/// (`docs/design/macros.md` section 15).
rom: std::collections::HashMap<(u8, u16), u8>,
}
impl MemoryReader for Wram {
fn read8(&mut self, address: u16) -> u8 {
self.bytes[address as usize]
}
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
self.rom.get(&(bank, address)).copied()
}
}
impl Default for Wram {
@ -59,7 +49,7 @@ impl Default for Wram {
impl Wram {
/// All zero: the title screen, since nothing has set the game-timer bit.
pub fn new() -> Self {
Self { bytes: vec![0; 0x1_0000], rom: std::collections::HashMap::new() }
Self { bytes: vec![0; 0x1_0000] }
}
pub fn set(&mut self, address: u16, value: u8) -> &mut Self {
@ -341,78 +331,6 @@ impl Wram {
self
}
/// The ROM bank and address this fake keeps a tileset's blockset at.
///
/// Any non-zero bank: the point of the number is that it is *not* bank 0, because a bank the
/// CPU bus does not have mapped is the whole reason the seam grew
/// [`MemoryReader::read_rom`] (`docs/design/macros.md` section 15).
pub const BLOCKSET_BANK: u8 = 0x11;
pub const BLOCKSET_BASE: u16 = 0x4000;
/// Which tileset the loaded map uses, for the tile-pair collision lists.
pub fn tileset(&mut self, id: u8) -> &mut Self {
self.set(ram::wCurMapTileset, id)
}
/// A tileset header's blockset, written where a cartridge keeps one: sixteen tile ids per
/// block, in block-id order, in a ROM bank that is not bank 0.
pub fn blockset(&mut self, blocks: &[[u8; 16]]) -> &mut Self {
for (id, block) in blocks.iter().enumerate() {
for (offset, tile) in block.iter().enumerate() {
let address = Self::BLOCKSET_BASE + (id * 16 + offset) as u16;
self.rom.insert((Self::BLOCKSET_BANK, address), *tile);
}
}
self.set(ram::wTilesetBank, Self::BLOCKSET_BANK)
.set(ram::wTilesetBlocksPtr, (Self::BLOCKSET_BASE & 0xff) as u8)
.set(ram::wTilesetBlocksPtr + 1, (Self::BLOCKSET_BASE >> 8) as u8)
}
/// The loaded map's block ids, as `LoadTileBlockMap` leaves them in `wOverworldMap`: rows of
/// `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows and three columns in.
///
/// `blocks` is row-major and `wCurMapWidth * wCurMapHeight` long; the border is left as
/// whatever it was, exactly as a map with no connections leaves it.
pub fn map_blocks(&mut self, blocks: &[u8]) -> &mut Self {
let width = u16::from(self.peek(ram::wCurMapWidth));
let height = u16::from(self.peek(ram::wCurMapHeight));
let border = crate::pokemon_red::mapgrid::MAP_BORDER as u16;
let stride = width + border * 2;
for row in 0..height {
for column in 0..width {
let index = usize::from(row * width + column);
let Some(block) = blocks.get(index) else { continue };
self.set(ram::wOverworldMap + (row + border) * stride + column + border, *block);
}
}
self
}
/// Write the screen buffer so that it agrees with the block data, tile for tile.
///
/// The grid reader cross-checks its decode against the window predicate before it trusts it
/// ([`crate::pokemon_red::state::map_grid`]), and on a cartridge the two agree because they
/// are two readings of one map. This is that agreement in a fake: every map tile inside the
/// ten-by-nine window gets the tile id the blockset gives it, and the tiles outside it keep
/// whatever the screen held, which is what makes them `Unknown` to the window and answerable
/// only by the grid.
pub fn screen_from_blocks(&mut self, blocks: &[u8], blockset: &[[u8; 16]]) -> &mut Self {
let width = usize::from(self.peek(ram::wCurMapWidth));
let height = usize::from(self.peek(ram::wCurMapHeight));
for y in 0..height * 2 {
for x in 0..width * 2 {
let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue };
let Some(tiles) = blockset.get(usize::from(*block)) else { continue };
// The lower-left tile of the map tile's own quadrant, which is the one the
// cartridge's collision read uses (`mapgrid::ANCHOR_ROW`).
let tile = tiles[((y % 2) * 2 + 1) * 4 + (x % 2) * 2];
self.map_tile(x as u8, y as u8, tile);
}
}
self
}
/// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk
/// out of the bedroom lands it, every tile a wall until a test opens one.
pub fn overworld() -> Self {

View file

@ -24,7 +24,7 @@
use crate::adapter::PlaceKind;
use super::geography::Amenity;
use super::state::{Facing, GameState, MapGrid};
use super::state::{Facing, GameState};
/// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party.
///
@ -315,23 +315,6 @@ pub trait MacroState: GameState {
false
}
/// The whole loaded map's walkability, when the cartridge's tables can be decoded.
///
/// `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros
/// need to be map aware: A* over walkable tiles." [`GameState::walkable`] answers for the
/// ten-by-nine window of the screen buffer and [`super::state::Walkable::Unknown`] for
/// everything else, so before this every walk planned through guesses, re-planned at every
/// window edge, and `GO FRONTIER` aimed at whatever unstood ground happened to be on screen.
///
/// The default is `None`, which is this trait's usual narrowing and here it is also the
/// documented fallback: [`super::path::route`] and [`super::path::frontier`] use the window
/// predicate when the grid is absent, exactly as they did before, and
/// [`crate::pokemon_red::state::GridRefusal`] is what says why it is absent on a frame the
/// reader could not decode.
fn map_grid(&mut self) -> Option<std::sync::Arc<MapGrid>> {
None
}
/// Whether this run has already been into `area`'s mart or Pokémon Center.
///
/// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is

View file

@ -16,7 +16,6 @@ use crate::macros::{
MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started,
};
use super::super::mapgrid::MapGrids;
use super::super::state::PokeState;
use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile};
use super::geography;
@ -71,14 +70,6 @@ pub struct PokemonPalette {
/// and walks the fly down on every frame it stands there until the Pokédex exists, and a map
/// does not stop being like that ten brain minutes later.
pushed: Pushed,
/// The decoded walkability of the map the fly is on (`docs/design/macros.md` section 15).
///
/// Owned here beside the session ledgers because it is the same shape of thing: built from
/// the cartridge, valid for as long as the map is loaded, dropped on arrival somewhere else,
/// and never checkpointed -- a restored run decodes the map again on its first overworld
/// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the
/// cartridge's own tables say about the ground.
grids: MapGrids,
/// The brain clock of the frame being decided, from [`MacroPalette::clock`].
///
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
@ -102,7 +93,6 @@ impl PokemonPalette {
stood: Stood::default(),
areas: Areas::default(),
pushed: Pushed::default(),
grids: MapGrids::default(),
now_ms: 0.0,
}
}
@ -168,13 +158,11 @@ impl MacroPalette for PokemonPalette {
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
let (scene, bindings, standing) = {
let Self {
machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, ..
} = self;
let Self { machine, mode, palette: cached, talked, targets, stood, areas, pushed, .. } =
self;
let mut state = PokeState::with_ledgers(
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
)
.caching_grid(grids);
);
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
// palette and the scene the feed reports cannot disagree about which frame they are
// for.
@ -239,8 +227,7 @@ impl MacroPalette for PokemonPalette {
&self.stood,
&self.areas,
&self.pushed,
)
.caching_grid(&mut self.grids);
);
self.machine.start(&palette, slot, &mut state)
};
let started = match begun {
@ -280,8 +267,7 @@ impl MacroPalette for PokemonPalette {
&self.stood,
&self.areas,
&self.pushed,
)
.caching_grid(&mut self.grids);
);
self.machine.step(&mut state)
};
// A macro that just finished may have been the press that talked to something; the ledger

View file

@ -504,44 +504,35 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
// `SWITCH` needs a Pokémon to switch *to* and a forced switch with none leaves a menu the
// game will not let the fly cancel and a pad with nothing on it (row 8).
Scene::Battle { forced_switch: true, .. } => vec![Switch, Next],
// The fly's turn, by which menu of it is accepting input (section 12.6). **`NEXT` is on
// no own-turn pad at all** (section 12.10): it is the A that advances *text*, and on a menu
// that is accepting input the same A press opens or confirms whatever the cursor happens to
// be sitting on, which is never one of the answers to that menu.
//
// Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts and
// `BACK` 1241, the log alternating `NEXT start/done, BACK start/done` every hold. `NEXT` on
// the *top-level* menu pressed A on FIGHT and opened the move list; `BACK` on the *move
// list* closed it again; neither spent a turn, and the two of them were half the pad
// between them. Two buttons that undo each other with nothing else changing are section
// 12.2's trap spread over two sub-states of one turn.
// The fly's turn, by which menu of it is accepting input (section 12.6). `NEXT` is on none
// of the two lists: an A press on whatever the cursor happens to be sitting on is what the
// live loop was made of.
Scene::Battle { own_turn: true, .. } => match battle_menu(state) {
BattleMenu::Moves { .. } => vec![Move1, Move2, Move3, Move4, Back],
BattleMenu::Party { .. } => vec![Switch, Back],
// The bag, which is the fly's turn since 12.10. Its three answers: use the thing the
// cursor is on (`ITEM`), throw the ball (`THROW BALL`), or leave the list (`BACK`).
// `CONFIRM` was an A press on whatever the cursor held, which is the same press
// `NEXT` was and reads nothing; the two scripts navigate the list's own cursor.
BattleMenu::Bag { .. } => vec![Item, ThrowBall, Back],
// `None` cannot land here -- `own_turn` is false for it -- and the arm exists because
// `own_turn` is a boolean and this match is over the menu. `MOVE 1` is what keeps this
// pad from ever being one the fly cannot end the turn from: FIGHT is one of these four
// entries and it always opens (section 12.8), which is the backstop `NEXT` was.
// The bag reads as nobody's turn (`state::battle`), so it cannot land here; the arm
// exists because `own_turn` is a boolean and this match is over the menu.
BattleMenu::Bag { .. } => vec![Confirm, Back],
BattleMenu::Main { .. } | BattleMenu::None => {
vec![Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run]
vec![Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run, Next]
}
},
// 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a
// press exactly like a dialog.
// press exactly like a dialog. Section 13.1 added `BACK` here for the bag a battle's ITEM
// entry opens, which is none of `BattleMenu`'s three and reads as nobody's turn -- and
// section 12.9 narrows it back to that list.
//
// **One button, and it is the A that advances text.** Section 13.1 added `BACK` here for
// the bag, because a bag read as nobody's turn; 12.9 narrowed it back to the bag alone;
// 12.10 moves the bag to the own turn where its cursor says it belongs, so nothing is open
// on this row any more and nothing but `NEXT` is on it. A frame that reaches here has no
// cursor accepting input -- text, an animation, a turn resolving -- so there is nothing to
// back out of (`BACK` would be 12.2's trap, the rung-9 loop of 12.9) and nothing for an A
// press to open (`NEXT` here cannot be the press that opened a menu, which is 12.10).
Scene::Battle { .. } => vec![Next],
// **`BACK` is a button only where there is a list to leave.** Live on rung 9, 69 hours in
// Viridian Forest: `BACK` was 135 of 183 macro starts since the restart, `RUN blocked,
// BACK start, BACK done` over and over. Between turns there is nothing open to back out
// of, so the B press changes nothing the `NEXT` beside it does not, the macro completes on
// the tile it started on in a handful of frames, and the roll lands on it most holds while
// the turn does not move. That is section 12.2's trap exactly: a macro that completes
// without moving because its precondition is already satisfied where the fly stands.
Scene::Battle { .. } => match battle_menu(state) {
BattleMenu::Bag { .. } => vec![Next, Back],
_ => vec![Next],
},
// Section 13: the shop's buttons are the four purchases, plus the two answers any list has.
Scene::Shop => vec![BuyPotion, BuyBall, BuyAntidote, BuyRepel, Confirm, Leave],
// Section 13.1: `CONFIRM` as well as `LEAVE`, so a PC the fly opened is a list it can
@ -1588,18 +1579,7 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
/// one at all -- which nothing in the game reaches -- is the one case that still answers `false`.
pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
let Some(index) = move_index(kind) else { return false };
let Some(battle) = state.battle() else { return false };
// Over the **top-level menu** the question is section 12.8's -- "is there a move list to
// open" -- and FIGHT always opens: what is behind it is the cartridge's business, because
// `CheckPlayerHasUsableMoves` prints "has no moves left!" and sets Struggle without opening
// the list. `MOVE 1`'s script over that menu is "confirm FIGHT and stop" and reads no move at
// all, so the button is bound there whatever the seam can make of the battler. That is the
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
// a button that ends the turn, and it is never one that merely reopens a list.
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) {
return true;
}
let Some(own) = battle.own else { return false };
let Some(own) = state.battle().and_then(|battle| battle.own) else { return false };
let holds = |slot: usize| -> Option<&Move> {
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
};

View file

@ -13,29 +13,16 @@
//! minimum Manhattan distance to any goal. That stays admissible because a step costs one and
//! moves one tile.
//!
//! **It plans over the whole map when the map can be decoded** (`docs/design/macros.md` section
//! 15, the operator 2026-09-22: "the frontier and warp macros need to be map aware: A* over
//! walkable tiles"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and
//! directed walls, decoded from the block and collision tables the cartridge has loaded
//! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its
//! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the
//! nearest unstood ground *anywhere on the map* rather than the nearest on screen.
//!
//! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is
//! load-bearing on a frame the grid cannot be decoded — no cartridge behind the seam, a battle or
//! a text box over the map, a header that is not loaded
//! ([`crate::pokemon_red::state::GridRefusal`] says which): the tile ids a walkability test needs
//! live in the screen buffer then, so only the tiles around the player can be answered at all. An
//! unknown tile is *expensive* to path through rather than forbidden ([`UNKNOWN_STEP`]): a
//! known-walkable way round is always preferred, and the search steps into the unknown only when
//! nothing known gets any closer. That is what a map edge six tiles away needed — it is off the
//! screen by definition, so a search that refused every unknown tile could not plan a single step
//! toward Route 1 from the middle of Pallet Town, which was half of why the fly never took it
//! (`infra/docs/macros-bench.md`, 2026-09-16).
//!
//! Either way the walk is re-planned only when the ground says so — a refusal, or the player not
//! where the plan expects it — which is [`super::executor`]'s committed route and not this
//! module's business.
//! **The walkable predicate has a window.** Agent A's [`Walkable::Unknown`] is load-bearing: the
//! tile ids a walkability test needs live in the screen buffer, so only the tiles around the
//! player can be answered at all. An unknown tile is *expensive* to path through rather than
//! forbidden ([`UNKNOWN_STEP`]): a known-walkable way round is always preferred, and the search
//! steps into the unknown only when nothing known gets any closer. That is what a map edge six
//! tiles away needs — it is off the screen by definition, so a search that refused every unknown
//! tile could not plan a single step toward Route 1 from the middle of Pallet Town, which is half
//! of why the fly never took it (`infra/docs/macros-bench.md`, 2026-09-16). The guess is cheap and
//! bounded: [`super::executor`] re-plans after every tile with a per-step check that the player
//! moved, and three failed steps abort as `Blocked`.
//!
//! The search still has its second answer for a goal it cannot reach at all: when no goal is
//! reachable, it returns the route to the reachable tile that gets closest to one.
@ -47,24 +34,7 @@ use super::cartridge::{
Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors,
};
use super::geography;
use super::state::{Facing, MapGrid, Walkable};
/// Whether the player could stand on a tile of the current map: the grid's answer, or the
/// window's when there is no grid.
///
/// One place asks the question so that the search, the exit list and the frontier cannot disagree
/// about which reading they are on (`docs/design/macros.md` section 15).
fn walkable_at(
state: &mut dyn MacroState,
grid: Option<&MapGrid>,
x: u8,
y: u8,
) -> Walkable {
match grid {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
}
}
use super::state::{Facing, Walkable};
/// What one step onto a tile the walkable predicate cannot answer for costs.
///
@ -186,10 +156,6 @@ pub fn route_avoiding(
}
let player = state.player()?;
let size = state.map_size()?;
// One decode per plan, from the cache the sim loop keeps: with a grid the search is over the
// whole map, without one it is over the ten-by-nine window as it always was.
let grid = state.map_grid();
let grid = grid.as_deref();
let start = Tile::new(player.x, player.y);
if let Some(goal) = goals.iter().position(|tile| *tile == start) {
return Some(Route { goal: Some(goal), steps: Vec::new() });
@ -225,12 +191,6 @@ pub fn route_avoiding(
if refused.contains(&(tile, facing)) {
continue;
}
// A step the *tables* refuse: a tile-pair collision, which is passable ground on both
// sides and a wall between them (`mapgrid::TILE_PAIRS_LAND`). The walk used to learn
// each of these by spending a step on it; with the grid the first plan goes round.
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
continue;
}
// **A tile the cartridge pushes the fly off is not a tile to walk through**, either
// (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix
// and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box
@ -240,11 +200,10 @@ pub fn route_avoiding(
if next != start && state.pushed_tile(next.x, next.y) {
continue;
}
let step = match walkable_at(state, grid, next.x, next.y) {
let step = match state.walkable(next.x, next.y) {
_ if next == start => 1,
Walkable::Yes => 1,
// Off the screen buffer, or a block the blockset was read short of: plausible
// ground, priced so that anything known beats it.
// Off the screen buffer: plausible ground, priced so that anything known beats it.
Walkable::Unknown => UNKNOWN_STEP,
Walkable::No => continue,
};
@ -375,8 +334,6 @@ pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option<TalkTarget> {
pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
let Some(size) = state.map_size() else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() };
let grid = state.map_grid();
let grid = grid.as_deref();
let here_outdoors = outdoors(player.map);
let mut out = Vec::new();
for (index, warp) in state.warps().iter().enumerate() {
@ -416,16 +373,13 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
let way = if here_outdoors { Way::Route } else { Way::Exit };
let into = geography::connected(player.map, edge);
for tile in edge_tiles(facing, size.width, size.height) {
// Not `== Yes`: without a grid the walkable predicate's window is the screen, so the
// far edge of an outdoor map reads `Unknown` from anywhere but next to it, and
// filtering on `Yes` left a town's connections out of the exit list entirely -- which
// is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An
// unknown tile is a goal worth walking towards; the route search's own approach answer
// handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one
// blocked walk. With a grid the answer is `Yes` or `No` for every edge tile of the map
// and this rejects the walls, which is what lets one plan reach the right end of a
// connection instead of the nearest of twenty tiles along it.
if walkable_at(state, grid, tile.x, tile.y) != Walkable::No {
// Not `== Yes`: the walkable predicate's window is the screen, so the far edge of an
// outdoor map reads `Unknown` from anywhere but next to it, and filtering on `Yes`
// left a town's connections out of the exit list entirely -- which is half of why
// nothing could ever aim at Route 1 from the middle of Pallet Town. An unknown tile is
// a goal worth walking towards; the route search's own approach answer handles a goal
// it cannot reach yet, and a tile that turns out to be a wall costs one blocked walk.
if state.walkable(tile.x, tile.y) != Walkable::No {
out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into });
}
}
@ -442,12 +396,9 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
/// Each answer is a tile to stand on paired with the direction the new ground lies in, which is
/// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks
/// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has
/// stood on. Both tiles have to be walkable: an unreachable one is not ground.
///
/// **With a grid this is the whole map** (`docs/design/macros.md` section 15): the nearest unstood
/// walkable tile anywhere on it, which is what the operator asked for and what the route search
/// then plans one walk to. Without a grid it is what it always was -- the ten-by-nine window, so
/// the answer is local to the player and the long walk is done by re-planning.
/// stood on. Both tiles have to be walkable: an unreachable one is not ground, and the walkable
/// predicate's window means the answer is always local to the player, which is what makes the
/// re-plan after every tile do the work of a long walk.
///
/// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near
/// frontiers does not depend on iteration order.
@ -464,15 +415,13 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
let Some(size) = state.map_size() else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() };
let grid = state.map_grid();
let grid = grid.as_deref();
let here = Tile::new(player.x, player.y);
let held: Vec<Tile> = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect();
let mut out: Vec<(Tile, Facing)> = Vec::new();
for y in 0..size.height {
for x in 0..size.width {
let tile = Tile::new(x, y);
if tile != here && walkable_at(state, grid, x, y) != Walkable::Yes {
if tile != here && state.walkable(x, y) != Walkable::Yes {
continue;
}
if tile != here && held.contains(&tile) {
@ -483,12 +432,7 @@ pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
if next.x >= size.width || next.y >= size.height || next == here {
continue;
}
if walkable_at(state, grid, next.x, next.y) != Walkable::Yes {
continue;
}
// A step the tables refuse is not a way onto that ground, so the tile it leads to
// is not this tile's frontier -- somebody else's, if anything reaches it.
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
if state.walkable(next.x, next.y) != Walkable::Yes {
continue;
}
if held.contains(&next) {

View file

@ -41,8 +41,7 @@ pub enum Scene {
Dialog,
/// Start menu or one of its submenus, outside battle.
Menu,
/// In a battle. `own_turn` is any battle menu waiting for input -- the top-level
/// FIGHT/PKMN/ITEM/RUN one, the move list, the party list or the bag;
/// In a battle. `own_turn` is the top-level FIGHT/PKMN/ITEM/RUN menu waiting for input;
/// `forced_switch` is the party list the game opens when the active Pokémon has fainted, which
/// cannot be backed out of.
Battle { own_turn: bool, forced_switch: bool },
@ -235,10 +234,10 @@ pub enum BattleMenu {
/// The bag, opened from a battle's ITEM entry: `wListMenuID` is `ITEMLISTMENU`.
///
/// Not one of the three `docs/design/macros.md` section 12.6 named, and the gap was
/// observable twice over. First the list needed a *cursor* the scripts can read, which is what
/// `ITEM` and section 14's `THROW BALL` navigate by (2026-09-17). Then it needed to be the
/// fly's *turn*: a bag reading as nobody's turn landed on the between-turns row, whose `NEXT`
/// is the A that advances text and on an open bag is the A that uses an item (12.10).
/// observable: the bag list is not the top-level menu, so `own_turn` is false and the frame
/// reads as a battle between turns, whose pad is `NEXT` and `BACK` -- and `NEXT` was an A
/// press on whatever the cursor held. What the list needed is a *cursor* the scripts can read,
/// which is what `ITEM` and section 14's `THROW BALL` navigate by (2026-09-17).
Bag { cursor: u8, count: u8 },
}
@ -246,9 +245,8 @@ pub enum BattleMenu {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Battle {
pub kind: BattleKind,
/// A battle menu is open and waiting for the fly: the top-level one, the move list, the bag,
/// or the party list outside a forced switch (`pokemon_red::state::battle`). Every frame with a
/// cursor accepting input is one of these, which is section 12.10's invariant.
/// A battle menu is open and waiting for the fly: the top-level one, the move list, or the
/// party list outside a forced switch (`pokemon_red::state::battle`).
pub own_turn: bool,
/// The party list is open because the active Pokémon fainted; it cannot be cancelled.
pub forced_switch: bool,
@ -394,179 +392,6 @@ impl Walkable {
}
}
/// Every tile of one map, answered: `docs/design/macros.md` section 15.
///
/// [`GameState::walkable`] answers about the ten-by-nine window of the screen buffer and
/// [`Walkable::Unknown`] elsewhere. This is the same predicate over the whole loaded map, decoded
/// from the block and collision tables the cartridge has loaded
/// ([`crate::pokemon_red::mapgrid`]), so a walk can be planned once instead of guessed at and
/// re-planned at every window edge.
///
/// It carries three things and no policy at all:
///
/// - the walkability of every tile, in map-tile coordinates -- the same unit the player's
/// coordinates, the warp table and the sign table are in;
/// - the tile id each answer came from, which is what the cross-check against the window
/// predicate compares and what the tile-pair rules are keyed on;
/// - **directed walls**: one step out of one tile in one direction that the cartridge refuses
/// although both tiles are passable. Pokered has two such rules and the tile-pair lists are the
/// one that can be read ahead of time; a ledge is already [`Walkable::No`] in the collision
/// list, and a person in the way is the sprite list's answer, not the ground's.
///
/// Nothing here is a fact about the run: no ledger, no visit, no target. Those stay where they
/// are, session state in the executor layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MapGrid {
map: u8,
width: u8,
height: u8,
/// Row-major, `width * height`, [`Walkable::Unknown`] until [`MapGrid::set`] says otherwise.
tiles: Vec<Walkable>,
/// Row-major screen tile id per map tile, `None` where the tile was never decoded.
ids: Vec<Option<u8>>,
/// Row-major bitmask of the directions a step out of this tile is refused in
/// ([`MapGrid::wall`]).
walls: Vec<u8>,
}
impl MapGrid {
/// An all-[`Walkable::Unknown`] grid of this size, for a decoder to fill in.
pub fn new(map: u8, width: u8, height: u8) -> Self {
let cells = usize::from(width) * usize::from(height);
Self {
map,
width,
height,
tiles: vec![Walkable::Unknown; cells],
ids: vec![None; cells],
walls: vec![0; cells],
}
}
/// Which map this grid is of. A grid is only ever valid for the loaded map.
pub fn map(&self) -> u8 {
self.map
}
pub fn width(&self) -> u8 {
self.width
}
pub fn height(&self) -> u8 {
self.height
}
fn index(&self, x: u8, y: u8) -> Option<usize> {
(x < self.width && y < self.height)
.then(|| usize::from(y) * usize::from(self.width) + usize::from(x))
}
/// Record a decoded tile: its screen tile id and what the collision list makes of it.
pub fn set(&mut self, x: u8, y: u8, tile: u8, walkable: Walkable) {
if let Some(index) = self.index(x, y) {
self.tiles[index] = walkable;
self.ids[index] = Some(tile);
}
}
/// Record that the cartridge refuses the step out of `(x, y)` in `facing`.
pub fn wall(&mut self, x: u8, y: u8, facing: Facing) {
if let Some(index) = self.index(x, y) {
self.walls[index] |= wall_bit(facing);
}
}
/// Whether the player could stand on this tile. Off the map is [`Walkable::No`], which is the
/// window predicate's own answer for it.
pub fn walkable(&self, x: u8, y: u8) -> Walkable {
match self.index(x, y) {
None => Walkable::No,
Some(index) => self.tiles[index],
}
}
/// The screen tile id this tile's answer came from, or `None` for a tile never decoded.
pub fn tile_id(&self, x: u8, y: u8) -> Option<u8> {
self.index(x, y).and_then(|index| self.ids[index])
}
/// Whether the step out of `(x, y)` in `facing` is one the cartridge refuses.
pub fn walled(&self, x: u8, y: u8, facing: Facing) -> bool {
self.index(x, y).is_some_and(|index| self.walls[index] & wall_bit(facing) != 0)
}
/// How many tiles of the map the player could stand on.
pub fn walkable_count(&self) -> usize {
self.tiles.iter().filter(|tile| tile.is_walkable()).count()
}
/// How many tiles the map has that were never decoded, i.e. [`Walkable::Unknown`].
pub fn unknown_count(&self) -> usize {
self.tiles.iter().filter(|tile| matches!(tile, Walkable::Unknown)).count()
}
/// Every walkable tile, in row-major order, as `(x, y)`.
pub fn walkable_tiles(&self) -> Vec<(u8, u8)> {
let mut out = Vec::with_capacity(self.walkable_count());
for y in 0..self.height {
for x in 0..self.width {
if self.walkable(x, y).is_walkable() {
out.push((x, y));
}
}
}
out
}
/// How many walkable tiles a walk from `(x, y)` could reach, the directed walls respected.
///
/// The starting tile counts whether or not it is walkable, for the same reason the route
/// search treats it as passable: the fly is standing on it. What this number is *for* is
/// reading a stalled walk at a glance -- a fly on a route with 600 walkable tiles and 4
/// reachable ones is fenced in, and no amount of re-planning is going to help it
/// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`).
pub fn reachable_from(&self, x: u8, y: u8) -> usize {
if self.index(x, y).is_none() {
return 0;
}
let mut seen = vec![false; self.tiles.len()];
let mut queue = std::collections::VecDeque::new();
if let Some(index) = self.index(x, y) {
seen[index] = true;
}
queue.push_back((x, y));
let mut count = 0;
while let Some((tx, ty)) = queue.pop_front() {
count += 1;
for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] {
if self.walled(tx, ty, facing) {
continue;
}
let (dx, dy) = facing.delta();
let Ok(nx) = u8::try_from(i16::from(tx) + dx) else { continue };
let Ok(ny) = u8::try_from(i16::from(ty) + dy) else { continue };
let Some(index) = self.index(nx, ny) else { continue };
if seen[index] || !self.tiles[index].is_walkable() {
continue;
}
seen[index] = true;
queue.push_back((nx, ny));
}
}
count
}
}
/// Which bit of a [`MapGrid`] wall mask a direction is.
fn wall_bit(facing: Facing) -> u8 {
match facing {
Facing::Up => 1,
Facing::Down => 2,
Facing::Left => 4,
Facing::Right => 8,
}
}
/// One entry of the current map's warp table: a door, a staircase, a cave mouth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Warp {

View file

@ -924,22 +924,17 @@ fn the_menu_row_is_close_confirm_back() {
}
#[test]
fn the_battle_row_is_the_move_buttons_switch_item_and_never_next() {
fn the_battle_row_is_the_move_buttons_switch_item_and_the_backstop() {
let mut world = World::battle();
world.bag = vec![(item::POTION, 1)];
let scene = world.scene();
// Section 13.1: `RUN` is bound only in a wild battle the fly is *losing*, and `World::battle`
// has an eighteen-of-twenty Pokémon on the bench -- something healthier to send in, so the
// fight is still worth having and the slot is empty.
//
// Section 12.10: **`NEXT` is gone from this row.** It was the backstop for a turn where every
// other button dropped, and it was an A press on the cursor -- which sits on FIGHT, so it
// opened the move list, whose `BACK` closed it again: `NEXT` 1264 starts and `BACK` 1241 on
// rung 9 after v0.4.2. `MOVE 1` is the backstop now, and it ends the turn.
let palette = Palette::for_scene(scene, &mut world);
assert_eq!(
names(&palette),
["MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"],
["NEXT", "MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"],
"three moves with PP, an empty fourth slot, a healthy bench and a potion"
);
@ -948,17 +943,15 @@ fn the_battle_row_is_the_move_buttons_switch_item_and_never_next() {
let scene = world.scene();
assert_eq!(
names(&Palette::for_scene(scene, &mut world)),
["MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"]
["NEXT", "MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"]
);
}
#[test]
fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() {
// Section 14: one button per move slot, asked **over the open move list**, which is where a
// slot is a thing to aim at. `World::battle`'s Pokémon has three moves and an empty fourth
// slot, so three buttons are on the list's pad and the fourth never is.
// Section 14: one button per move slot. `World::battle`'s Pokémon has three moves and an empty
// fourth slot, so three buttons are on the pad and the fourth never is.
let mut world = World::battle();
world.list = List::Moves(3);
assert!(move_slot_bound(&mut world, MacroKind::Move1));
assert!(move_slot_bound(&mut world, MacroKind::Move2));
assert!(move_slot_bound(&mut world, MacroKind::Move3));
@ -982,25 +975,12 @@ fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() {
.is_some()
);
// A Pokémon with no move in slot one at all answers no over the list: there is no slot to aim
// at, and inventing a press for it is what this crate does not do.
// A Pokémon with no move in slot one at all is the one case that answers no. Nothing in the
// game reaches it, and inventing a press for it is what this crate does not do.
world.mons[0] = mon(0, 4, 20, &[]);
assert!(!move_slot_bound(&mut world, MacroKind::Move1));
let scene = world.scene();
assert_eq!(Palette::for_scene(scene, &mut world).slot(MacroId(MacroKind::Move1.slot())), None);
// Over the **top-level menu** the question is a different one -- section 12.8's "is there a
// move list to open" -- and FIGHT always opens, so `MOVE 1` is bound there whatever the seam
// makes of the battler. That is what carries the turn now that `NEXT` is off this row
// (section 12.10): the script over this menu is "confirm FIGHT and stop" and reads no move.
world.list = List::BattleMain;
assert!(move_slot_bound(&mut world, MacroKind::Move1), "FIGHT is always pressable");
assert!(!move_slot_bound(&mut world, MacroKind::Move2), "and it is MOVE 1 that carries it");
world.active = None;
assert!(
move_slot_bound(&mut world, MacroKind::Move1),
"a battler the seam cannot read is not a reason to take the turn's one button away"
);
}
#[test]
@ -1117,21 +1097,16 @@ fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text()
world.list = List::None;
let scene = world.scene();
// Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry
// opens, which read as nobody's turn -- but on a frame of text there is no list to leave, and
// opens, which reads as nobody's turn -- but on a frame of text there is no list to leave, and
// a `BACK` that changes nothing is the trap of section 12.2 (live, rung 9: 135 of 183 macro
// starts).
let palette = Palette::for_scene(scene, &mut world);
assert_eq!(names(&palette), ["NEXT"]);
// Section 12.10: the bag does not land on this arm any more. It is a cursor accepting input,
// so it is the fly's turn, and its `NEXT` would have been the A that *uses* what the cursor
// holds rather than the A that advances text. Nothing is open here, so nothing but `NEXT` is.
// The bag is the one sub-state on this arm with a list open in it, and it keeps `BACK`.
world.list = List::BattleBag;
world.battle = Some((BattleKind::Wild, true, false));
world.scene = Scene::Battle { own_turn: true, forced_switch: false };
world.bag = vec![(item::POTION, 1)];
let palette = Palette::for_scene(world.scene(), &mut world);
assert_eq!(names(&palette), ["BACK", "ITEM"], "a hurt Pokémon, a potion, and no ball");
assert_eq!(names(&palette), ["NEXT", "BACK"]);
}
#[test]
@ -2381,11 +2356,10 @@ fn the_battle_plan_attacks_first_and_switches_only_under_a_quarter() {
let mut healthy = World::battle();
healthy.mons[0] = mon(0, 20, 20, &[(33, 30)]);
assert!(!plan::failing(&mut healthy));
// `MOVE 1` is what keeps the own-turn pad from being empty when every other button drops out:
// FIGHT is one of this menu's four entries and it always opens (12.8), where the `NEXT` that
// used to carry the job merely reopened the list `BACK` had just closed (12.10). `ITEM` needs
// a potion and low HP, so it is absent here; this Pokémon has one move.
assert_eq!(plan(&mut healthy), ["MOVE 1", "SWITCH"]);
// `NEXT` is unconditional: a battle frame always has a press that advances it, which is what
// keeps the own-turn pad from being empty when every other button drops out (2026-09-17).
// `ITEM` needs a potion and low HP, so it is absent here; this Pokémon has one move.
assert_eq!(plan(&mut healthy), ["NEXT", "MOVE 1", "SWITCH"]);
}
#[test]
@ -2398,7 +2372,7 @@ fn the_battle_plan_runs_from_a_wild_battle_only_when_the_whole_party_is_weak() {
// the next hit ends the battle, which is the state section 9 puts `RUN` in.
world.mons = vec![mon(0, 4, 20, &[(33, 30)]), mon(1, 3, 20, &[(33, 30)])];
assert!(plan::party_weak(&mut world));
assert_eq!(plan(&mut world), ["MOVE 1", "SWITCH", "RUN"]);
assert_eq!(plan(&mut world), ["NEXT", "MOVE 1", "SWITCH", "RUN"]);
// A trainer battle has no RUN at all, in the plan or in the palette.
world.battle = Some((BattleKind::Trainer, true, false));
@ -3270,11 +3244,7 @@ fn each_battle_menu_deals_its_own_pad() {
let pad = names(&plan::plan_for(main.scene(), &mut main));
assert!(pad.contains(&"MOVE 1"), "{pad:?}");
assert!(pad.contains(&"SWITCH"), "{pad:?}");
// Section 12.10: the top-level menu is a list accepting input too, so `NEXT` is off it as
// well. Its backstop is `MOVE 1`, which is bound here whatever the battler reads as, because
// FIGHT always opens (12.8) -- and unlike `NEXT` it ends the turn instead of opening the list
// that `BACK` closes again.
assert!(!pad.contains(&"NEXT"), "never NEXT on a menu accepting input: {pad:?}");
assert!(pad.contains(&"NEXT"), "the top-level menu keeps its backstop: {pad:?}");
// `ITEM` and `RUN` have their own preconditions -- a potion, and a party with nothing healthy
// left -- and this fixture satisfies neither; row 7 covers the turn where all four drop.
@ -3351,8 +3321,11 @@ fn back_is_on_a_battle_pad_only_where_a_list_is_open() {
let mut party = World::battle();
party.list = List::BattleParty;
with_back(&mut party);
// The bag, which is the fly's own turn since section 12.10 because its cursor accepts input.
// The bag reads as nobody's turn (`state::battle`), so it lands on the between-turns arm --
// and it is the one sub-state there with a list open in it.
let mut bag = World::battle();
bag.scene = Scene::Battle { own_turn: false, forced_switch: false };
bag.battle = Some((BattleKind::Wild, false, false));
bag.list = List::BattleBag;
with_back(&mut bag);
@ -3371,93 +3344,6 @@ fn back_is_on_a_battle_pad_only_where_a_list_is_open() {
without(&mut forced);
}
/// Section 12.10: **no battle pad deals a pair of buttons that undo each other.**
///
/// Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts, `BACK`
/// 1241, and the event log alternating `NEXT start/done, BACK start/done` every hold on map 51.
/// The pair was split across two sub-states of one turn -- `NEXT` on the top-level menu was an A
/// press on FIGHT, which opened the move list, and `BACK` on the move list closed it again -- so
/// neither the pad rule of 12.9 nor the "no `BACK` without a list" rule caught it: both buttons
/// were legitimate where they stood, and between them they were a 2-cycle that never spent a turn.
///
/// The rule that closes it is about the pair rather than about either button: `NEXT` is the A that
/// advances **text**, so it belongs only on a frame with no cursor accepting input, and `BACK` is
/// the B that leaves a **list**, so it belongs only on a frame that has one. The two conditions are
/// exclusive, so no pad can hold both -- and the forced switch, which keeps `NEXT` as row 8's
/// backstop, is the one arm with a cursor and no `BACK` at all, because it cannot be cancelled.
#[test]
fn no_battle_pad_holds_both_next_and_back() {
// Every battle sub-state the seam can report, on a turn where every precondition is satisfied
// (a potion, a ball, a hurt Pokémon, a bench) and on one where none is.
let sub_states = [
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleMain),
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::Moves(3)),
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleParty),
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleBag),
(Scene::Battle { own_turn: false, forced_switch: false }, false, false, List::None),
(Scene::Battle { own_turn: false, forced_switch: true }, false, true, List::BattleParty),
];
for stocked in [false, true] {
for (scene, own_turn, forced, list) in sub_states {
let mut world = World::battle();
world.scene = scene;
world.battle = Some((BattleKind::Wild, own_turn, forced));
world.list = list;
if stocked {
world.bag = vec![(item::POTION, 1), (item::POKE_BALL, 3)];
world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 });
}
let pad = names(&plan::plan_for(world.scene(), &mut world));
let next = pad.contains(&"NEXT");
let back = pad.contains(&"BACK");
assert!(
!(next && back),
"{list:?} (stocked {stocked}) deals a pair that undoes itself: {pad:?}"
);
// And the pair is not the only way to waste a hold: a pad of one button that cannot
// end the turn is the shape row 34 had, so every sub-state is checked for having one.
assert!(
!pad.is_empty(),
"{list:?} (stocked {stocked}) deals nothing: {pad:?}"
);
// `NEXT` is on a frame with no cursor accepting input, or on the forced switch that
// has no other answer (row 8). Nowhere else.
if next {
assert!(
forced || list == List::None,
"NEXT on a menu accepting input: {list:?} deals {pad:?}"
);
}
}
}
}
/// Section 12.10: the bag inside a battle is the fly's turn, and its pad is the bag's answers.
///
/// `NEXT` on an open bag is the A press that **uses** whatever the cursor is sitting on, which is
/// not one of the answers to a list, and `CONFIRM` beside `BACK` was the same press by another
/// name. The two scripts that navigate this list by reading its cursor are `ITEM` and
/// `THROW BALL`, and they are what the row deals.
#[test]
fn the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses() {
let mut world = World::battle();
world.list = List::BattleBag;
world.bag = vec![(item::POTION, 2), (item::POKE_BALL, 4)];
world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 });
// `World::battle` is a wild battle with the active Pokémon on 4 of 20, so `ITEM`'s two facts
// hold and `THROW BALL`'s four do: a wild battle, a ball, room in a party of three, and an
// enemy species the party does not hold.
assert_eq!(
names(&plan::plan_for(world.scene(), &mut world)),
["BACK", "ITEM", "THROW BALL"]
);
// Nothing in the bag: leaving the list is the press, and there is no `NEXT` to use a thing
// that is not there.
world.bag.clear();
let pad = names(&plan::plan_for(world.scene(), &mut world));
assert_eq!(pad, ["BACK"]);
}
#[test]
fn a_spent_slot_is_off_the_pad_and_a_slot_with_pp_is_on_it_over_an_open_list() {
// The live state row 34 came from: slot 0 is TACKLE with 0 of 40 PP and the cursor is on it.
@ -3937,15 +3823,6 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
cornered.mons = vec![mon(0, 20, 20, &[])];
cornered.battle = Some((BattleKind::Trainer, true, false));
worst(&mut cornered);
// And every battle sub-state of that same cornered turn, which since section 12.10 includes
// the bag: an empty bag deals `BACK` and nothing else, which is row 34a's answer -- there is
// nothing to choose, so leaving the list is the press, and what it leaves to is a menu with
// `MOVE 1` on it.
for list in [List::BattleMain, List::Moves(1), List::BattleParty, List::BattleBag] {
cornered.list = list;
worst(&mut cornered);
}
}
/// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk.
@ -4233,5 +4110,4 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
);
}
mod map_aware;
mod shop_purchase;

View file

@ -1,344 +0,0 @@
//! Walks planned over the whole map, and the same walks with the window as the only reading.
//!
//! `docs/design/macros.md` section 15. [`super::super::path`] has two readings of the ground now
//! and the interesting tests are the ones that tell them apart: a map bigger than the ten-by-nine
//! window, a frontier on the far side of it, and a wall the collision list cannot predict. The
//! fake here is deliberately not [`super::World`] — it implements the seam and nothing else, so a
//! failure is about the search rather than about a script's frames.
use std::collections::BTreeSet;
use std::sync::Arc;
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, Tile};
use crate::pokemon_red::macros::path::{self, Way};
use crate::pokemon_red::macros::state::{
BagItem, Battle, Connections, Facing, GameState, MapGrid, MapSize, Mon, Npc, Party, Pc, Player,
Scene, Shop, Sign, StartMenu, TextBox, Walkable, Warp,
};
/// A passable tile id and a wall tile id, for a grid built by hand.
const FLOOR: u8 = 0x01;
const WALL: u8 = 0x60;
/// The ground, and nothing else: the seam's questions about a map and the fly standing on it.
struct Ground {
map: u8,
size: MapSize,
player: Tile,
/// Tiles that are not walkable; everything else inside `size` is.
walls: BTreeSet<Tile>,
/// Steps the cartridge refuses although both tiles are passable, as the grid records them.
pair_walls: Vec<(Tile, Facing)>,
/// Ground the run has stood on, which is what makes a tile not a frontier.
stood: BTreeSet<Tile>,
warps: Vec<Warp>,
connections: Connections,
npcs: Vec<Npc>,
/// Whether the whole map is decoded, or only the window can answer.
decoded: bool,
}
impl Ground {
/// A map `wide` by `high` tiles with the fly at `player` and every tile walkable.
fn new(wide: u8, high: u8, player: (u8, u8)) -> Self {
Self {
map: 0,
size: MapSize { width: wide, height: high },
player: Tile::new(player.0, player.1),
walls: BTreeSet::new(),
pair_walls: Vec::new(),
stood: BTreeSet::new(),
warps: Vec::new(),
connections: Connections::default(),
npcs: Vec::new(),
decoded: true,
}
}
fn wall(mut self, x: u8, y: u8) -> Self {
self.walls.insert(Tile::new(x, y));
self
}
/// A column of wall with one gap in it, which is the shape that tells the two readings apart.
fn wall_column(mut self, x: u8, gap: u8) -> Self {
for y in 0..self.size.height {
if y != gap {
self.walls.insert(Tile::new(x, y));
}
}
self
}
/// Everything within `distance` of the fly counts as stood on, which is what a fly that has
/// been walking around one corner of a route has.
fn stood_around(mut self, distance: u32) -> Self {
for y in 0..self.size.height {
for x in 0..self.size.width {
let tile = Tile::new(x, y);
if tile.distance(self.player) <= distance {
self.stood.insert(tile);
}
}
}
self
}
/// The window reading only: what every walk had before section 15.
fn window_only(mut self) -> Self {
self.decoded = false;
self
}
fn pair_wall(mut self, from: (u8, u8), facing: Facing) -> Self {
self.pair_walls.push((Tile::new(from.0, from.1), facing));
self
}
fn connected(mut self, connections: Connections) -> Self {
self.connections = connections;
self
}
/// The ten-by-nine window agent A's predicate can answer for, which moves with the fly.
fn in_window(&self, x: u8, y: u8) -> bool {
let dx = i32::from(x) - i32::from(self.player.x);
let dy = i32::from(y) - i32::from(self.player.y);
(-4..=5).contains(&dx) && (-4..=4).contains(&dy)
}
fn walkable_tile(&self, x: u8, y: u8) -> bool {
x < self.size.width && y < self.size.height && !self.walls.contains(&Tile::new(x, y))
}
}
impl GameState for Ground {
fn scene(&mut self) -> Scene {
Scene::Overworld
}
fn player(&mut self) -> Option<Player> {
Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: Facing::Down })
}
fn map_size(&mut self) -> Option<MapSize> {
Some(self.size)
}
fn party(&mut self) -> Party {
Party { mons: Vec::<Mon>::new(), active: None }
}
fn battle(&mut self) -> Option<Battle> {
None
}
fn text_box(&mut self) -> TextBox {
TextBox { open: false, waiting: false }
}
fn start_menu(&mut self) -> Option<StartMenu> {
None
}
fn shop(&mut self) -> Option<Shop> {
None
}
fn pc(&mut self) -> Option<Pc> {
None
}
fn money(&mut self) -> u32 {
0
}
fn bag(&mut self) -> Vec<BagItem> {
Vec::new()
}
fn npcs(&mut self) -> Vec<Npc> {
self.npcs.clone()
}
fn signs(&mut self) -> Vec<Sign> {
Vec::new()
}
/// Agent A's predicate: the window, and `Unknown` outside it.
fn walkable(&mut self, x: u8, y: u8) -> Walkable {
if x >= self.size.width || y >= self.size.height {
return Walkable::No;
}
if !self.in_window(x, y) {
return Walkable::Unknown;
}
if self.walkable_tile(x, y) { Walkable::Yes } else { Walkable::No }
}
fn warps(&mut self) -> Vec<Warp> {
self.warps.clone()
}
fn connections(&mut self) -> Connections {
self.connections
}
}
impl MacroState for Ground {
fn map_grid(&mut self) -> Option<Arc<MapGrid>> {
if !self.decoded {
return None;
}
let mut grid = MapGrid::new(self.map, self.size.width, self.size.height);
for y in 0..self.size.height {
for x in 0..self.size.width {
let walkable = self.walkable_tile(x, y);
grid.set(
x,
y,
if walkable { FLOOR } else { WALL },
if walkable { Walkable::Yes } else { Walkable::No },
);
}
}
for (tile, facing) in &self.pair_walls {
grid.wall(tile.x, tile.y, *facing);
}
Some(Arc::new(grid))
}
fn tile_visited(&mut self, x: u8, y: u8) -> bool {
self.stood.contains(&Tile::new(x, y))
}
}
/// Walk a route's presses and report where they land and whether every tile was walkable.
fn walk(ground: &Ground, from: Tile, steps: &[Facing]) -> (Tile, bool) {
let mut at = from;
let mut clean = true;
for facing in steps {
let Some(next) = at.step(*facing) else {
clean = false;
break;
};
if !ground.walkable_tile(next.x, next.y) {
clean = false;
}
at = next;
}
(at, clean)
}
#[test]
fn one_plan_crosses_a_map_larger_than_the_window() {
// Twenty by eighteen — Pallet Town's size — with a wall down the middle and one gap in it,
// which is where a plan has to go and is off the screen from where the fly starts.
let start = (2, 2);
let goal = Tile::new(18, 16);
let mut ground = Ground::new(20, 18, start).wall_column(10, 1);
let route = path::route(&mut ground, &[goal]).expect("a route across the map");
assert_eq!(route.goal, Some(0), "the goal itself, not an approach to it");
let (landed, clean) = walk(&ground, Tile::new(start.0, start.1), &route.steps);
assert_eq!(landed, goal);
assert!(clean, "every tile of the plan is walkable ground");
// The gap is at the top, so the plan is longer than the Manhattan distance and knows it.
assert!(route.steps.len() > Tile::new(start.0, start.1).distance(goal) as usize);
// The same map with only the window to read: the plan sets off through a wall it cannot see.
let mut blind = Ground::new(20, 18, start).wall_column(10, 1).window_only();
let route = path::route(&mut blind, &[goal]).expect("a route through the unknown");
let (_, clean) = walk(&blind, Tile::new(start.0, start.1), &route.steps);
assert!(!clean, "the window cannot see the wall, so the plan walks into it");
}
#[test]
fn the_frontier_is_the_nearest_unstood_tile_anywhere_on_the_map() {
// A fly that has covered the ground around it. Nine tiles is the furthest corner of the
// ten-by-nine window (five across and four down), so every tile the window can answer for has
// been stood on and the nearest new ground is the first tile outside it.
let mut ground = Ground::new(20, 18, (3, 3)).stood_around(9);
let frontier = path::frontier(&mut ground);
assert!(!frontier.is_empty(), "the rest of the map is still new ground");
let (tile, facing) = frontier
.iter()
.copied()
.min_by_key(|(tile, _)| tile.distance(Tile::new(3, 3)))
.expect("a nearest frontier");
let new_ground = tile.step(facing).expect("the ground it faces");
assert!(!ground.tile_visited(new_ground.x, new_ground.y));
assert_eq!(tile.distance(Tile::new(3, 3)), 9, "the near side of the unstood ground");
// And a route to it, in one plan.
let goals: Vec<Tile> = frontier.iter().map(|(tile, _)| *tile).collect();
let route = path::route(&mut ground, &goals).expect("a route to the frontier");
assert!(route.goal.is_some());
// The window reading has nothing to offer here at all, which is the pad the operator saw
// hanging: every tile it can answer for has been stood on.
let mut blind = Ground::new(20, 18, (3, 3)).stood_around(9).window_only();
assert!(path::frontier(&mut blind).is_empty());
}
#[test]
fn a_step_the_tables_refuse_is_planned_around_rather_than_walked_into() {
// A tile-pair collision: both tiles passable, the step between them refused
// (`data/tilesets/pair_collision_tile_ids.asm`). The wall is the only way east on its row, so
// a search that did not know about it would plan straight through.
let goal = Tile::new(3, 0);
let mut ground = Ground::new(4, 3, (1, 0))
.pair_wall((1, 0), Facing::Right)
.pair_wall((2, 0), Facing::Left);
let route = path::route(&mut ground, &[goal]).expect("a route round the pair wall");
assert_eq!(route.steps.first(), Some(&Facing::Down), "round it, not through it");
let (landed, clean) = walk(&ground, Tile::new(1, 0), &route.steps);
assert_eq!(landed, goal);
assert!(clean);
assert!(!route.steps.is_empty());
// Without the pair rule the same map is a straight line east.
let mut open = Ground::new(4, 3, (1, 0));
let route = path::route(&mut open, &[goal]).expect("a route east");
assert_eq!(route.steps, vec![Facing::Right, Facing::Right]);
}
#[test]
fn a_connection_on_the_far_edge_is_a_goal_and_the_walls_along_it_are_not() {
// A route's north edge, off the screen from where the fly stands, with two walkable tiles on
// it. With the map decoded the exit list is those two tiles and not the whole row.
let mut ground = Ground::new(20, 18, (3, 16))
.connected(Connections { north: true, south: false, east: false, west: false });
for x in 0..20u8 {
if x != 7 && x != 8 {
ground = ground.wall(x, 0);
}
}
let exits = path::exits(&mut ground);
let north: Vec<ExitId> = exits
.iter()
.filter(|exit| exit.id == ExitId::Edge(Edge::North))
.map(|exit| exit.id)
.collect();
assert_eq!(north.len(), 2, "the two tiles a step north can be taken from");
let goals: Vec<Tile> = exits
.iter()
.filter(|exit| exit.way == Way::Route && exit.id == ExitId::Edge(Edge::North))
.map(|exit| exit.tile)
.collect();
assert!(goals.iter().all(|tile| tile.y == 0 && (tile.x == 7 || tile.x == 8)));
let route = path::route(&mut ground, &goals).expect("a route to the connection");
assert!(route.goal.is_some());
let (landed, clean) = walk(&ground, Tile::new(3, 16), &route.steps);
assert!(goals.contains(&landed));
assert!(clean, "one plan, across sixteen tiles of map, every tile of it known ground");
// With only the window, every tile of that edge is `Unknown` and so every one of them is an
// exit, walls included: the search's own approach answer is what used to carry the walk.
let mut blind = Ground::new(20, 18, (3, 16))
.connected(Connections { north: true, south: false, east: false, west: false })
.window_only();
let count = path::exits(&mut blind)
.iter()
.filter(|exit| exit.id == ExitId::Edge(Edge::North))
.count();
assert_eq!(count, 20);
}

View file

@ -1,270 +0,0 @@
//! The whole current map's walkability, decoded from the tables the cartridge has loaded.
//!
//! `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros need
//! to be map aware: A* over walkable tiles." [`super::state::walkable`] answers for the ten-by-nine
//! window of the screen buffer and [`super::macros::state::Walkable::Unknown`] for everything
//! else, which is honest and is also why every walk planned through guesses, re-planned at each
//! window edge, and called "frontier" whatever unstood ground happened to be on screen.
//!
//! This module is the same predicate over the whole map. Nothing about the *rule* changes -- a tile
//! is walkable when the current tileset's collision list holds its tile id, which is
//! `CheckTilePassable` -- what changes is where the tile id comes from:
//!
//! | what | where the cartridge keeps it | how it is read |
//! | --- | --- | --- |
//! | the map's blocks | `wOverworldMap`, one byte per 4x4-tile block, rows of `width + MAP_BORDER * 2` with the map three rows and three columns in (`LoadTileBlockMap`) | WRAM, through the ordinary reader |
//! | a block's tiles | the tileset header's blockset, 16 bytes per block id, four rows of four tile ids (`DrawTileBlock`) | ROM, through [`crate::adapter::MemoryReader::read_rom`], because the blockset is not in bank 0 |
//! | which tiles are passable | `wTilesetCollisionPtr`, a `$ff`-terminated list in bank 0 | WRAM pointer, ROM bank 0 read, exactly as the window predicate already did |
//! | which steps are refused between two passable tiles | `TilePairCollisionsLand`, keyed by `wCurMapTileset` | the table's values, quoted below |
//!
//! Two coordinate systems meet here and keeping them apart is the whole of the arithmetic. The
//! cartridge's *blocks* are 4x4 screen tiles; the player moves in *map tiles* of 2x2 screen tiles,
//! which is the unit `wXCoord`, the warp table and everything in `macros/` is in. So one block is
//! [`TILES_PER_BLOCK`] map tiles each way, and the screen tile a map tile's walkability is read
//! from is the **lower** left of its 2x2 quadrant ([`ANCHOR_ROW`]) -- the corner
//! `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the tile the player stands on,
//! measured rather than argued and pinned by [`super::state::map_grid`]'s cross-check against the
//! window predicate on a cartridge.
//!
//! What it does **not** model is what it did not model before: sprites standing on ground (the
//! sprite list answers that, and [`super::macros::path::frontier`] reads it), warps that fire on
//! the step onto them, and scripts that push the fly off a tile (a session ledger answers that).
use std::sync::Arc;
use super::macros::state::{Facing, MapGrid, Walkable};
/// Screen tiles across and down one block: `BLOCK_WIDTH` and `BLOCK_HEIGHT`
/// (`constants/gfx_constants.asm`).
pub const BLOCK_TILES: usize = 4;
/// Bytes one block takes in a tileset's blockset: its sixteen tile ids, four rows of four.
pub const BLOCK_BYTES: usize = BLOCK_TILES * BLOCK_TILES;
/// Blocks of border `wOverworldMap` keeps on every side: `MAP_BORDER`
/// (`constants/map_data_constants.asm`), which is what lets the view centre on a map smaller than
/// the screen and what a map's own blocks are offset by.
pub const MAP_BORDER: usize = 3;
/// Map tiles across one block. A block is four screen tiles and the player walks two at a time,
/// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this.
pub const TILES_PER_BLOCK: u8 = 2;
/// Bytes `wOverworldMap` has for the loaded map: `ds 1300` (`ram/wram.asm`).
///
/// A map plus its border of [`MAP_BORDER`] blocks has to fit in this, and every real map does. A
/// header that says otherwise is a header read mid-load, which is why the bound is a refusal
/// rather than a clamp.
pub const OVERWORLD_MAP_BYTES: usize = 1300;
/// Which of a quadrant's two rows the collision read takes its tile id from: the lower one.
///
/// A map tile is a 2x2 patch of screen tiles and only one of the four is ever asked about, because
/// `CheckTilePassable` matches a single tile id. Which one is **measured, not derived**: the
/// decoded ids were compared against the screen buffer on the cartridge, tile by tile, and the
/// screen agrees with the lower-left tile of each quadrant and not the upper-left -- Viridian
/// Forest's (4, 32) reads `$23`, the second row of its block, where the first row holds `$04`
/// (`tests/rom_map_grid.rs`, which is the test that pins it).
///
/// That is the same corner `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the
/// tile the player is standing on: the view is aligned so that the player's own 2x2 begins on
/// screen row 8, so row 9 is its lower half. An upper-left decode still answers, and answers
/// plausibly -- on the open ground of a town most quadrants hold one tile id four times over --
/// which is why the cross-check in [`super::state::map_grid`] is not optional.
const ANCHOR_ROW: usize = 1;
/// Tileset ids the tile-pair lists name (`constants/tileset_constants.asm`, counted in the order
/// that file declares them: OVERWORLD 0 … FOREST 3 … CAVERN 17).
pub mod tileset {
pub const FOREST: u8 = 3;
pub const CAVERN: u8 = 17;
}
/// `TilePairCollisionsLand` at the pinned pokered commit, as `(tileset, one tile, the other)`.
///
/// `data/tilesets/pair_collision_tile_ids.asm`. Values rather than an address, like the item
/// prices in [`super::macros::cartridge`]: what the cartridge keeps here is eleven triples, and
/// the file they came from is quoted so a wrong one is a review comment rather than a mystery.
///
/// `CheckForTilePairCollisions` walks the list comparing the tile the player stands on against
/// *either* member of the pair and the tile in front against the other, so the refusal is
/// symmetric -- a forest's tree line refuses the step in both directions -- and each triple
/// becomes two directed walls. Both tiles are passable on their own, which is why nothing in the
/// collision list can predict it and why the walk used to learn it one refusal at a time
/// ([`super::macros::path::Refusal`]).
///
/// `TilePairCollisionsWater` is deliberately absent: it is the list
/// `CheckForJumpingAndTilePairCollisions` uses while surfing, the fly has no Surf, and a rule for
/// a movement mode the palette cannot enter is not knowledge this grid should carry.
pub const TILE_PAIRS_LAND: [(u8, u8, u8); 11] = [
(tileset::CAVERN, 0x20, 0x05),
(tileset::CAVERN, 0x41, 0x05),
(tileset::FOREST, 0x30, 0x2e),
(tileset::CAVERN, 0x2a, 0x05),
(tileset::CAVERN, 0x05, 0x21),
(tileset::FOREST, 0x52, 0x2e),
(tileset::FOREST, 0x55, 0x2e),
(tileset::FOREST, 0x56, 0x2e),
(tileset::FOREST, 0x20, 0x2e),
(tileset::FOREST, 0x5e, 0x2e),
(tileset::FOREST, 0x5f, 0x2e),
];
/// The tileset's two tables, as the decoder needs them.
///
/// Gathered by [`super::state::map_grid`] from WRAM and ROM; separate from the decoding so that
/// the decoding is a pure function of bytes and can be tested against a made-up tileset.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tileset {
/// `wCurMapTileset`: which tileset, for the tile-pair lists.
pub id: u8,
/// The blockset: sixteen tile ids per block id, in block-id order from zero.
pub blocks: Vec<u8>,
/// The `$ff`-terminated passable-tile list, terminator included or not.
pub passable: Vec<u8>,
}
impl Tileset {
/// The screen tile id at `(column, row)` of block `block`, or `None` past the blockset.
fn tile(&self, block: u8, column: usize, row: usize) -> Option<u8> {
let offset = usize::from(block) * BLOCK_BYTES + row * BLOCK_TILES + column;
self.blocks.get(offset).copied()
}
/// Whether the collision list holds `tile`, which is `CheckTilePassable` and nothing else.
fn passable(&self, tile: u8) -> bool {
for candidate in &self.passable {
if *candidate == TERMINATOR {
return false;
}
if *candidate == tile {
return true;
}
}
false
}
}
/// The `$ff` that ends a collision list.
pub const TERMINATOR: u8 = 0xff;
/// Decode the whole map into a [`MapGrid`].
///
/// `blocks` is the map's block ids, row-major, `width_blocks * height_blocks` of them, already
/// lifted out of `wOverworldMap`'s bordered rows. Every map tile gets one answer:
/// [`Walkable::Yes`] or [`Walkable::No`] from the collision list, and [`Walkable::Unknown`] only
/// for a tile whose block id is past the end of the blockset -- which is a table that was read
/// short rather than a tile the game is unsure about.
pub fn decode(map: u8, width_blocks: u8, height_blocks: u8, blocks: &[u8], tiles: &Tileset) -> MapGrid {
let width = width_blocks.saturating_mul(TILES_PER_BLOCK);
let height = height_blocks.saturating_mul(TILES_PER_BLOCK);
let mut grid = MapGrid::new(map, width, height);
for y in 0..height {
for x in 0..width {
let block_index =
usize::from(y / TILES_PER_BLOCK) * usize::from(width_blocks)
+ usize::from(x / TILES_PER_BLOCK);
let Some(block) = blocks.get(block_index).copied() else {
continue;
};
// The screen tile the collision read uses, inside the map tile's own 2x2 quadrant of
// the block: the **lower** left one ([`ANCHOR_ROW`]).
let column = usize::from(x % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK);
let row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK) + ANCHOR_ROW;
let Some(tile) = tiles.tile(block, column, row) else {
continue;
};
grid.set(x, y, tile, if tiles.passable(tile) { Walkable::Yes } else { Walkable::No });
}
}
add_pair_walls(&mut grid, tiles.id);
grid
}
/// Turn every tile-pair collision the loaded tileset has into two directed walls.
fn add_pair_walls(grid: &mut MapGrid, tileset: u8) {
let pairs: Vec<(u8, u8)> = TILE_PAIRS_LAND
.iter()
.filter(|(id, _, _)| *id == tileset)
.map(|(_, one, other)| (*one, *other))
.collect();
if pairs.is_empty() {
return;
}
for y in 0..grid.height() {
for x in 0..grid.width() {
let Some(here) = grid.tile_id(x, y) else { continue };
for facing in [Facing::Down, Facing::Right] {
let (dx, dy) = facing.delta();
let Some(nx) = checked_step(x, dx) else { continue };
let Some(ny) = checked_step(y, dy) else { continue };
if nx >= grid.width() || ny >= grid.height() {
continue;
}
let Some(there) = grid.tile_id(nx, ny) else { continue };
if pairs.iter().any(|(one, other)| {
(*one == here && *other == there) || (*one == there && *other == here)
}) {
grid.wall(x, y, facing);
grid.wall(nx, ny, opposite(facing));
}
}
}
}
}
fn checked_step(value: u8, delta: i16) -> Option<u8> {
u8::try_from(i16::from(value) + delta).ok()
}
fn opposite(facing: Facing) -> Facing {
match facing {
Facing::Down => Facing::Up,
Facing::Up => Facing::Down,
Facing::Left => Facing::Right,
Facing::Right => Facing::Left,
}
}
/// The decoded grid of the map that is loaded, kept for as long as it is the loaded one.
///
/// One slot, keyed by map id and size: arriving on another map drops it, and so does a map whose
/// header reads a different size, because both mean the block data under it has been replaced.
/// A decode is a few thousand WRAM reads and a walk of the blockset, so it happens once per
/// arrival rather than once per plan -- and never per frame, which is what a precondition asking
/// for the frontier would otherwise cost.
///
/// Session state like the other ledgers of `docs/design/macros.md` section 12: owned by
/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the cartridge
/// on the first frame after a restore.
#[derive(Debug, Clone, Default)]
pub struct MapGrids {
current: Option<Arc<MapGrid>>,
}
impl MapGrids {
/// The cached grid for `map` at this size, or `None` when the cache is for somewhere else.
///
/// Handed out behind an [`Arc`] so that a caller that asks once a frame -- a precondition
/// wanting to know whether the frontier is empty -- pays a refcount rather than a copy of the
/// map.
pub fn get(&self, map: u8, width: u8, height: u8) -> Option<Arc<MapGrid>> {
self.current
.as_ref()
.filter(|grid| grid.map() == map && grid.width() == width && grid.height() == height)
.map(Arc::clone)
}
/// Keep `grid`, dropping whatever map the cache held before.
pub fn store(&mut self, grid: MapGrid) -> Arc<MapGrid> {
Arc::clone(self.current.insert(Arc::new(grid)))
}
/// Which map the cache is holding, for a log line and the tests.
pub fn held(&self) -> Option<u8> {
self.current.as_ref().map(|grid| grid.map())
}
}
#[cfg(test)]
mod tests;

View file

@ -1,164 +0,0 @@
//! The decoder, against a made-up tileset.
//!
//! Every byte here is synthetic on purpose: a block table, a blockset and a collision list of this
//! module's own making, so a failure says which of the three readings is wrong rather than "the
//! cartridge disagrees". The cartridge half — that the decode matches real presses on Pallet Town
//! and Viridian Forest — is `tests/rom_map_grid.rs`.
use super::*;
use crate::pokemon_red::macros::state::{Facing, Walkable};
/// A blockset with three blocks: all floor, all wall, and one whose four map-tile quadrants are
/// four different tile ids.
///
/// A block is four screen tiles each way and a map tile is two, so the quadrants are the four
/// corners and the tile a map tile's walkability comes from is the lower left of its own quadrant
/// ([`ANCHOR_ROW`], which the cartridge settled).
fn blockset() -> Vec<u8> {
let floor = [FLOOR; 16];
let wall = [WALL; 16];
// The four ids sit on the rows the decode reads -- the *lower* row of each 2x2 quadrant
// (`ANCHOR_ROW`) -- and the rows it does not read hold ids that would be wrong answers.
let quadrants = [
0x90, 0x91, 0x92, 0x93, //
NORTH_WEST, 0x94, NORTH_EAST, 0x95, //
0x96, 0x97, 0x98, 0x99, //
SOUTH_WEST, 0x9a, SOUTH_EAST, 0x9b,
];
let mut out = Vec::new();
out.extend_from_slice(&floor);
out.extend_from_slice(&wall);
out.extend_from_slice(&quadrants);
out
}
const FLOOR: u8 = 0x01;
const WALL: u8 = 0x60;
const NORTH_WEST: u8 = 0x11;
const NORTH_EAST: u8 = 0x12;
const SOUTH_WEST: u8 = 0x13;
const SOUTH_EAST: u8 = 0x14;
/// Floor and the four quadrant tiles are passable; the wall tile is in no list.
fn tileset(id: u8) -> Tileset {
Tileset {
id,
blocks: blockset(),
passable: vec![FLOOR, NORTH_WEST, NORTH_EAST, SOUTH_WEST, SOUTH_EAST, TERMINATOR],
}
}
#[test]
fn one_block_becomes_four_map_tiles_from_its_four_quadrants() {
let grid = decode(7, 1, 1, &[2], &tileset(0));
assert_eq!((grid.map(), grid.width(), grid.height()), (7, 2, 2));
assert_eq!(grid.tile_id(0, 0), Some(NORTH_WEST));
assert_eq!(grid.tile_id(1, 0), Some(NORTH_EAST));
assert_eq!(grid.tile_id(0, 1), Some(SOUTH_WEST));
assert_eq!(grid.tile_id(1, 1), Some(SOUTH_EAST));
}
#[test]
fn the_collision_list_answers_every_tile_of_a_map_larger_than_the_window() {
// Ten blocks by nine is twenty tiles by eighteen: wider than the ten-by-nine window the
// screen buffer can answer for, which is the point of the whole grid.
let (wide, high) = (10u8, 9u8);
let mut blocks = vec![0u8; usize::from(wide) * usize::from(high)];
// A wall down the middle column of blocks.
for row in 0..usize::from(high) {
blocks[row * usize::from(wide) + 5] = 1;
}
let grid = decode(0, wide, high, &blocks, &tileset(0));
assert_eq!((grid.width(), grid.height()), (20, 18));
assert_eq!(grid.walkable(0, 0), Walkable::Yes);
assert_eq!(grid.walkable(19, 17), Walkable::Yes, "the far corner, which no window reaches");
assert_eq!(grid.walkable(10, 9), Walkable::No);
assert_eq!(grid.walkable(11, 9), Walkable::No);
assert_eq!(grid.unknown_count(), 0);
assert_eq!(grid.walkable_count(), 20 * 18 - 18 * 2);
// Off the map is not a tile to stand on, which is the window predicate's own answer for it.
assert_eq!(grid.walkable(20, 0), Walkable::No);
assert_eq!(grid.walkable(0, 18), Walkable::No);
}
#[test]
fn a_tile_pair_collision_is_a_wall_in_both_directions_and_only_in_its_own_tileset() {
// `FOREST, $30, $2E`: two tiles that are each passable on their own and that the cartridge
// refuses the step between (`data/tilesets/pair_collision_tile_ids.asm`).
let (one, other) = (0x30, 0x2e);
let tiles = Tileset {
id: tileset::FOREST,
blocks: [[one; 16], [other; 16]].concat(),
passable: vec![one, other, TERMINATOR],
};
let grid = decode(0, 2, 1, &[0, 1], &tiles);
assert_eq!(grid.walkable(1, 0), Walkable::Yes);
assert_eq!(grid.walkable(2, 0), Walkable::Yes);
assert!(grid.walled(1, 0, Facing::Right), "the step onto the other tile");
assert!(grid.walled(2, 0, Facing::Left), "and the step back, which is the same rule");
assert!(!grid.walled(0, 0, Facing::Right), "two tiles of the same id are not a pair");
// The same two tile ids in a tileset the list does not name are ordinary ground.
let elsewhere = Tileset { id: tileset::CAVERN, ..tiles };
let grid = decode(0, 2, 1, &[0, 1], &elsewhere);
assert!(!grid.walled(1, 0, Facing::Right));
assert!(!grid.walled(2, 0, Facing::Left));
}
#[test]
fn a_block_id_the_blockset_does_not_reach_stays_unknown() {
// A blockset read short — a header pointer near the end of its bank — is not a tile the game
// is unsure about, and the search prices `Unknown` as plausible ground rather than refusing
// it, so saying so is the honest answer.
let grid = decode(0, 2, 1, &[0, 9], &tileset(0));
assert_eq!(grid.walkable(0, 0), Walkable::Yes);
assert_eq!(grid.walkable(2, 0), Walkable::Unknown);
assert_eq!(grid.tile_id(2, 0), None);
// Block 9 is one block, which is four map tiles: two rows of two.
assert_eq!(grid.unknown_count(), 4);
}
#[test]
fn reachable_from_counts_what_a_walk_can_get_to_and_not_what_it_can_see() {
// Two floor blocks with a wall block between them: eight walkable tiles, four of them fenced
// off from the fly. This is the number that tells a stalled walk from a long one.
let grid = decode(0, 3, 1, &[0, 1, 0], &tileset(0));
assert_eq!(grid.walkable_count(), 8);
assert_eq!(grid.reachable_from(0, 0), 4);
assert_eq!(grid.reachable_from(4, 0), 4);
// With the wall gone, the whole row is one region.
let grid = decode(0, 3, 1, &[0, 0, 0], &tileset(0));
assert_eq!(grid.reachable_from(0, 0), 12);
}
#[test]
fn a_directed_wall_fences_a_region_off_for_the_reachable_count_too() {
let (one, other) = (0x30, 0x2e);
let tiles = Tileset {
id: tileset::FOREST,
blocks: [[one; 16], [other; 16]].concat(),
passable: vec![one, other, TERMINATOR],
};
// A column of `$30` and a column of `$2e`, four tiles each, with the tile-pair rule between
// every pair of them: every tile is walkable and half of them are unreachable.
let grid = decode(0, 2, 1, &[0, 1], &tiles);
assert_eq!(grid.walkable_count(), 8);
assert_eq!(grid.reachable_from(0, 0), 4);
}
#[test]
fn the_cache_holds_one_map_and_drops_it_on_arrival_somewhere_else() {
let mut grids = MapGrids::default();
assert_eq!(grids.held(), None);
let stored = grids.store(decode(3, 2, 2, &[0, 0, 0, 0], &tileset(0)));
assert_eq!(grids.held(), Some(3));
assert_eq!(grids.get(3, 4, 4).as_deref(), Some(&*stored));
// The same map at another size is another map's block data under the same id, which is what a
// half-loaded header looks like.
assert!(grids.get(3, 8, 8).is_none());
assert!(grids.get(4, 4, 4).is_none());
grids.store(decode(4, 1, 1, &[0], &tileset(0)));
assert_eq!(grids.held(), Some(4));
assert!(grids.get(3, 4, 4).is_none());
}

View file

@ -11,7 +11,6 @@ pub mod catalog;
#[cfg(test)]
pub(crate) mod fake_wram;
pub mod macros;
pub mod mapgrid;
pub mod maps;
pub mod scene;
pub mod state;
@ -344,13 +343,6 @@ impl MemoryReader for SampleCache<'_> {
self.bytes.insert(address, value);
value
}
/// Straight through, uncached: a ROM byte cannot change, so there is nothing
/// for a per-sample cache to save, and the caller that reads a blockset
/// (`docs/design/macros.md` section 15) caches the decoded map instead.
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
self.source.read_rom(bank, address)
}
}
fn word(memory: &mut impl MemoryReader, address: u16) -> u32 {

View file

@ -29,10 +29,9 @@ use super::macros::cartridge::{
Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile,
};
use super::macros::geography::Amenity;
use super::mapgrid::{self, MapGrids};
use super::macros::state::{
BagItem, Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState,
MapGrid, MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu,
MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu,
Status, TextBox, Walkable, Warp,
};
use super::symbols::ram;
@ -460,13 +459,11 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
// frame is between turns, which is what it was before this change.
BattleMenu::Moves { cursor, .. } => cursor.is_some(),
BattleMenu::Party { .. } => !forced_switch,
// The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting
// input, so by the rule above it is the fly's turn (2026-09-22, section 12.10). Reading it
// as nobody's turn put it on the between-turns row, whose one button is the `NEXT` that
// advances *text* -- and on an open bag that same A press *uses* whatever the cursor
// happens to be sitting on. The pad that belongs to a bag is the bag's own three answers,
// `ITEM`, `THROW BALL` and `BACK`, which is what `palette::scene_set` deals here now.
BattleMenu::Bag { .. } => true,
// The bag is a list the fly opened *during* its turn, but it is not the turn's own menu:
// the pad that belongs to it is the list's two answers, which is what the between-turns
// row deals (`NEXT`, `BACK`). Reporting it as the own turn would put the move buttons on
// a screen they cannot press.
BattleMenu::Bag { .. } => false,
BattleMenu::None => false,
};
Some(Battle {
@ -778,16 +775,12 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
npcs
}
/// The current tileset's list of passable tile ids, terminator included.
/// Whether the current tileset calls this tile id passable.
///
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
/// collision tables, which all live in ROM bank 0 at this commit and so are always mapped — until
/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow, or the
/// list is not terminated inside the bound below.
///
/// One read of the list serves both callers: [`walkable`] asks about one tile of the window, and
/// [`map_grid`] asks about every tile of the map, and neither is allowed its own copy of the rule.
fn collision_list(memory: &mut dyn MemoryReader) -> Option<Vec<u8>> {
/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow.
fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
let low = u16::from(read(memory, ram::wTilesetCollisionPtr));
let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1));
let base = high * 256 + low;
@ -799,198 +792,16 @@ fn collision_list(memory: &mut dyn MemoryReader) -> Option<Vec<u8>> {
}
// No collision list in the game is longer than this; the bound is what stops a bad pointer
// from walking the cartridge.
let mut list = Vec::new();
for offset in 0..64u16 {
let byte = read(memory, base + offset);
list.push(byte);
if byte == mapgrid::TERMINATOR {
return Some(list);
match read(memory, base + offset) {
0xff => return Some(false),
found if found == tile => return Some(true),
_ => {}
}
}
None
}
/// Whether the current tileset calls this tile id passable.
///
/// [`collision_list`]'s own walk, and `CheckTilePassable`'s: match or `$ff`, whichever comes
/// first. `None` means the list could not be read at all.
fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
let list = collision_list(memory)?;
for candidate in list {
if candidate == mapgrid::TERMINATOR {
return Some(false);
}
if candidate == tile {
return Some(true);
}
}
Some(false)
}
/// Why a whole-map grid could not be decoded on this frame.
///
/// `docs/design/macros.md` section 15 asks the fallback to *say when*, so every way out of
/// [`map_grid`] is named rather than being one `None`. Each one leaves the window predicate in
/// charge, which is what the walks did before the grid existed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridRefusal {
/// The map header is not loaded, or its size is out of range (`map_size` said `None`).
NoHeader,
/// The player's coordinates are not readable, so nothing can be cross-checked.
NoPlayer,
/// The tileset's collision list could not be followed ([`collision_list`]).
NoCollisionList,
/// The blockset could not be read: the seam has no cartridge behind it
/// ([`MemoryReader::read_rom`] answered `None`), or the header's pointer runs off the image.
NoBlockset,
/// The screen is not showing the map — a battle, a text box, a frame mid-warp — so there is
/// nothing to check the decode against, and `wOverworldMap` shares its bytes with the picture
/// buffer (`ram/wram.asm`'s own union), which is exactly when it must not be trusted.
NoScreen,
/// The decode and the screen buffer disagree about a tile the window can answer for. A wrong
/// stride, a wrong quadrant or a half-loaded map all land here, and all of them answer
/// plausibly, which is why this check is not optional.
ScreenDisagrees,
}
impl GridRefusal {
/// A short label for a log line and the probes.
pub fn label(self) -> &'static str {
match self {
GridRefusal::NoHeader => "no map header",
GridRefusal::NoPlayer => "no player",
GridRefusal::NoCollisionList => "no collision list",
GridRefusal::NoBlockset => "no blockset",
GridRefusal::NoScreen => "map not on screen",
GridRefusal::ScreenDisagrees => "screen disagrees",
}
}
}
/// The whole loaded map's walkability, decoded from the tables the cartridge has loaded.
///
/// `docs/design/macros.md` section 15. The rule is [`walkable`]'s rule — the tileset's collision
/// list — and what this adds is the tile id of every tile of the map rather than of the ten-by-nine
/// window:
///
/// - the map's **blocks** come from `wOverworldMap`, which `LoadTileBlockMap` fills from the map's
/// own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows
/// and three columns in. That is WRAM, so it needs no bank at all.
/// - a block's **tiles** come from the tileset header's blockset, sixteen bytes per block id
/// (`DrawTileBlock`). That is ROM, and not bank 0, so it is the one read that goes through
/// [`MemoryReader::read_rom`] — the cartridge image as the process already holds it, because the
/// alternative would be *writing* the mapper's bank register and the joypad is the only write
/// this workspace makes into a running game.
/// - the **tile-pair** refusals come from the values of `TilePairCollisionsLand`, keyed by
/// `wCurMapTileset`, and become directed walls ([`mapgrid::TILE_PAIRS_LAND`]).
///
/// The last thing it does is check itself: the decoded tile ids are compared against
/// [`map_tile_id`] for the player's own tile and its four neighbours, every one the window can
/// answer for. A frame where the window can answer for none of them is refused
/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with
/// the picture buffer and a battle is exactly when the blocks under it are somebody else's.
pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
let size = map_size(memory).ok_or(GridRefusal::NoHeader)?;
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?;
let width_blocks = read(memory, ram::wCurMapWidth);
let height_blocks = read(memory, ram::wCurMapHeight);
let stride = u16::from(width_blocks) + (mapgrid::MAP_BORDER as u16) * 2;
let border = mapgrid::MAP_BORDER as u16;
// The map plus its border has to fit in `wOverworldMap`, which every real map does. One that
// does not is a header caught mid-load, and reading past the buffer would be reading somebody
// else's WRAM.
if usize::from(stride) * (usize::from(height_blocks) + mapgrid::MAP_BORDER * 2)
> mapgrid::OVERWORLD_MAP_BYTES
{
return Err(GridRefusal::NoHeader);
}
let mut blocks = Vec::with_capacity(usize::from(width_blocks) * usize::from(height_blocks));
for row in 0..u16::from(height_blocks) {
for column in 0..u16::from(width_blocks) {
blocks.push(read(memory, ram::wOverworldMap + (row + border) * stride + column + border));
}
}
// Only as much of the blockset as this map's blocks index into: a tileset has up to 256 of
// them and a room uses a dozen, and a read that stops at the highest block id used is a read
// that cannot run off the end of a bank for tiles nothing asks about.
let highest = blocks.iter().copied().max().unwrap_or(0);
let bank = read(memory, ram::wTilesetBank);
let base = u16::from(read(memory, ram::wTilesetBlocksPtr))
+ u16::from(read(memory, ram::wTilesetBlocksPtr + 1)) * 256;
let wanted = (usize::from(highest) + 1) * mapgrid::BLOCK_BYTES;
let mut blockset = Vec::with_capacity(wanted);
for offset in 0..wanted {
let address = base.checked_add(u16::try_from(offset).map_err(|_| GridRefusal::NoBlockset)?);
let byte = address
.and_then(|address| memory.read_rom(bank, address))
.ok_or(GridRefusal::NoBlockset)?;
blockset.push(byte);
}
let tiles = mapgrid::Tileset { id: read(memory, ram::wCurMapTileset), blocks: blockset, passable };
let grid = mapgrid::decode(player.map, width_blocks, height_blocks, &blocks, &tiles);
if grid.width() != size.width || grid.height() != size.height {
return Err(GridRefusal::NoHeader);
}
// The cross-check. `map_tile_id` reads the screen buffer at the offset
// `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for
// is agreeing with the cartridge's own reading of the same ground.
let mut checked = 0;
for (x, y) in neighbourhood(player.x, player.y) {
let Some(screen) = map_tile_id(memory, x, y) else { continue };
if grid.tile_id(x, y) != Some(screen) {
return Err(GridRefusal::ScreenDisagrees);
}
checked += 1;
}
if checked == 0 {
return Err(GridRefusal::NoScreen);
}
Ok(grid)
}
/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on.
///
/// The map id, the map header and the block data are written by different parts of a warp, so
/// there is a frame or two on the way through a door where `wCurMap` is the map the fly is
/// arriving on and the header and the blocks are still the map it is leaving: the decode agrees
/// with the screen (both are the old map) and is filed under the new id. Measured on the
/// cartridge — the fly on Oak's lab doormat with `wCurMap` already reading `PALLET_TOWN` and the
/// header still the lab's ten-by-twelve (`tests/rom_map_grid.rs`).
///
/// Nothing in WRAM says "the map has finished loading", so the cache asks the cheapest question
/// that can tell: does the grid still agree with the screen about the tile the fly is standing on?
/// One byte, once per question. A grid that does not is dropped and decoded again, so a torn
/// frame's grid lives exactly as long as the tear does — and through it the cartridge is walking
/// the fly, which is not a frame any macro plans on.
fn still_the_loaded_map(
memory: &mut dyn MemoryReader,
grid: &MapGrid,
x: u8,
y: u8,
) -> bool {
match map_tile_id(memory, x, y) {
// The screen is not showing the map (a battle, a text box): nothing to check against, and
// the grid was checked when it was decoded.
None => true,
Some(tile) => grid.tile_id(x, y) == Some(tile),
}
}
/// The player's own tile and its four neighbours, which is every tile the window is certain to be
/// able to answer for from where the fly is standing.
fn neighbourhood(x: u8, y: u8) -> Vec<(u8, u8)> {
let mut out = vec![(x, y)];
for (dx, dy) in [(0i16, 1i16), (0, -1), (-1, 0), (1, 0)] {
if let (Ok(nx), Ok(ny)) =
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
{
out.push((nx, ny));
}
}
out
}
/// Whether the player could stand on this tile of the current map.
///
/// Mirrors `CheckTilePassable`: the tile id comes out of the screen buffer at the offset
@ -1107,16 +918,6 @@ pub struct PokeState<'a> {
/// Tiles the cartridge has pushed the fly off ([`MacroState::pushed_tile`]). Session state
/// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37).
pushed: &'a dyn PushedLedger,
/// Where the decoded map grid is kept between frames ([`MacroState::map_grid`],
/// `docs/design/macros.md` section 15).
///
/// Mutable, unlike every ledger above, because this is the one thing the state *computes*
/// rather than looks up: a decode is a few thousand reads and a walk of the blockset, and it
/// is valid for as long as the map is loaded. Without a cache every caller decodes again,
/// which is correct and is what the tests do; the sim loop passes one
/// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a
/// refcount instead of a map.
grids: Option<&'a mut MapGrids>,
}
impl<'a> PokeState<'a> {
@ -1134,7 +935,6 @@ impl<'a> PokeState<'a> {
stood: &NoStood,
areas: &NoAreas,
pushed: &NoPushed,
grids: None,
}
}
@ -1148,7 +948,6 @@ impl<'a> PokeState<'a> {
stood: &NoStood,
areas: &NoAreas,
pushed: &NoPushed,
grids: None,
}
}
@ -1166,18 +965,7 @@ impl<'a> PokeState<'a> {
areas: &'a dyn AreaLedger,
pushed: &'a dyn PushedLedger,
) -> Self {
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None }
}
/// Keep the decoded map grid in `grids` instead of decoding it per question.
///
/// The cache is keyed by map id and size and holds one map, so arriving somewhere else drops
/// it (`docs/design/macros.md` section 15). Session state: it is owned by
/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the
/// cartridge on the first overworld frame after a restore.
pub fn caching_grid(mut self, grids: &'a mut MapGrids) -> Self {
self.grids = Some(grids);
self
Self { memory, ledger, talk, targets, stood, areas, pushed }
}
}
@ -1263,29 +1051,6 @@ impl MacroState for PokeState<'_> {
!controllable(self.memory)
}
/// The whole loaded map's walkability, from the cache when it is for this map
/// (`docs/design/macros.md` section 15).
///
/// `None` is the honest answer on every frame [`map_grid`] refuses — no cartridge behind the
/// seam, a battle or a text box over the map, a header that is not loaded — and every caller
/// falls back to the ten-by-nine window predicate then, which is what all of them did before
/// this existed. [`GridRefusal`] names which, for the probes.
fn map_grid(&mut self) -> Option<std::sync::Arc<MapGrid>> {
let player = player(self.memory)?;
let size = map_size(self.memory)?;
if let Some(grids) = self.grids.as_deref()
&& let Some(grid) = grids.get(player.map, size.width, size.height)
&& still_the_loaded_map(self.memory, &grid, player.x, player.y)
{
return Some(grid);
}
let grid = map_grid(self.memory).ok()?;
match self.grids.as_deref_mut() {
Some(grids) => Some(grids.store(grid)),
None => Some(std::sync::Arc::new(grid)),
}
}
/// What the open mart sells, in menu order (`docs/design/macros.md` section 13).
///
/// Gated on the mart scene being up, and that gate is the whole of the accuracy here:

View file

@ -5,8 +5,7 @@
//! passable-tile list walked out of ROM bank 0.
use super::*;
use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram};
use crate::pokemon_red::macros::cartridge::MacroState;
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, WALL_TILE, Wram};
use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign};
/// A walkable tile id from `RedsHouse1_Coll`.
@ -249,79 +248,6 @@ fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() {
assert!(cursor(&mut wram).cancellable());
}
/// Section 12.10: **a battle frame with a cursor accepting input is the fly's turn.**
///
/// The four menus a battle waits on are the top-level one, the move list, the party list and the
/// bag, and each of them is the game asking the player to choose. `own_turn` answered `false` for
/// the bag, which put it on the between-turns row whose one button is the `NEXT` that advances
/// text -- and on an open bag that same A press uses whatever the cursor holds.
///
/// The two frames that are correctly *not* the fly's turn are here too: a move list whose cursor
/// the seam cannot place (row 30b, a battle's opening frames) and a frame with no menu at all.
#[test]
fn a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn() {
let battler = |wram: &mut Wram| {
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]);
wram.party_mon(1, 16, 8, 24, 24, 0, &[(33, 35)]);
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)])
.enemy_mon(19, 3, 5, 11)
.battle(1);
};
// The top-level menu.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.battle_menu(false, 0);
assert!(battle(&mut wram).unwrap().own_turn, "the top-level menu");
// The move list, with a cursor the seam can place.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu(1, 3);
assert!(battle(&mut wram).unwrap().own_turn, "the move list");
// The party list, chosen rather than forced.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.party_list(1, false);
let fight = battle(&mut wram).unwrap();
assert!(!fight.forced_switch);
assert!(fight.own_turn, "the party list outside a forced switch");
// The bag, which `DisplayListMenuID` opens from the menu's ITEM entry. It is the one menu that
// read as nobody's turn, and it is what section 12.10 is about.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.bag(&[(crate::pokemon_red::macros::cartridge::item::POTION, 2)]).set(ram::wListMenuID, poke::ITEM_LIST_MENU);
let fight = battle(&mut wram).unwrap();
assert_eq!(fight.menu, BattleMenu::Bag { cursor: 0, count: 1 });
assert!(fight.own_turn, "the battle bag is a cursor accepting input");
// And the two frames that are not a choice. A move list whose cursor cannot be placed is a
// battle's opening frames (row 30b), and no menu at all is text, an animation or a turn
// resolving.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu(0, 3).set(ram::wCurrentMenuItem, 0);
let fight = battle(&mut wram).unwrap();
assert_eq!(fight.menu, BattleMenu::Moves { cursor: None, count: 3 });
assert!(!fight.own_turn, "a cursor the seam cannot place is not accepting input");
let mut wram = Wram::overworld();
battler(&mut wram);
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None);
assert!(!battle(&mut wram).unwrap().own_turn, "no menu, no turn");
// A forced switch is a cursor accepting input and it is *not* the own turn, because it has a
// pad of its own: the exception 12.6 named, kept here so the invariant reads honestly.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.party_list(1, true);
let fight = battle(&mut wram).unwrap();
assert!(fight.forced_switch);
assert!(!fight.own_turn, "a forced switch has its own pad");
}
#[test]
fn a_text_box_is_open_from_the_font_flag_and_waiting_from_the_box() {
let mut wram = Wram::overworld();
@ -677,141 +603,3 @@ fn the_live_implementation_answers_the_whole_trait() {
assert_eq!(state.warps().len(), 1);
assert!(state.connections().south);
}
/// A map ten blocks by nine — twenty tiles by eighteen, wider than the ten-by-nine window — with
/// a wall down one column of blocks, and a screen buffer that agrees with it.
///
/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
/// `wTilesetCollisionPtr` points.
fn town() -> (Wram, Vec<u8>, Vec<[u8; 16]>) {
const FLOOR: u8 = 0x01;
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
let (wide, high) = (10usize, 9usize);
let mut blocks = vec![0u8; wide * high];
for row in 0..high {
blocks[row * wide + 5] = 1;
}
let mut wram = Wram::new();
wram.started()
.map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4)
.facing(0)
.house_collision()
.tileset(0)
.blockset(&blockset)
.map_blocks(&blocks)
.fill_screen(WALL_TILE)
.screen_from_blocks(&blocks, &blockset);
(wram, blocks, blockset)
}
#[test]
fn the_whole_map_decodes_from_the_block_and_collision_tables() {
let (mut wram, _, _) = town();
let grid = map_grid(&mut wram).expect("a decodable map");
assert_eq!((grid.width(), grid.height()), (20, 18));
assert_eq!(grid.unknown_count(), 0);
// The far corner of the map, which the window predicate cannot answer for at all: the grid
// does, and that is the whole of section 15.
assert_eq!(walkable(&mut wram, 19, 17), Walkable::Unknown);
assert_eq!(grid.walkable(19, 17), Walkable::Yes);
// The wall column, again outside the window.
assert_eq!(grid.walkable(10, 17), Walkable::No);
assert_eq!(grid.walkable(11, 17), Walkable::No);
// Inside the window the two readings agree tile for tile, which is what the reader checks
// itself with before it trusts a decode.
for y in 0..18u8 {
for x in 0..20u8 {
if let Some(tile) = map_tile_id(&mut wram, x, y) {
assert_eq!(grid.tile_id(x, y), Some(tile), "({x}, {y})");
assert_eq!(grid.walkable(x, y), walkable(&mut wram, x, y), "({x}, {y})");
}
}
}
}
#[test]
fn a_decode_the_screen_disagrees_with_is_refused() {
let (mut wram, _, _) = town();
// One screen tile the block data does not account for: a wrong stride, a wrong quadrant or a
// half-loaded map all look like this, and all of them answer plausibly.
wram.map_tile(3, 4, 0x77);
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
}
#[test]
fn without_a_cartridge_behind_the_seam_there_is_no_grid() {
let (mut wram, blocks, _) = town();
// The same WRAM with no blockset in any bank: `read_rom` answers `None`, which is what every
// reader that is not the emulator answers, and the grid narrows to nothing rather than
// decoding the map out of whatever bytes were to hand.
let mut bare = Wram::new();
bare.started()
.map(fake_wram::PALLET_TOWN, 10, 9, 3, 4)
.facing(0)
.house_collision()
.map_blocks(&blocks)
.fill_screen(WALL_TILE);
assert_eq!(map_grid(&mut bare), Err(GridRefusal::NoBlockset));
// And the window predicate still answers, which is the fallback the whole thing rests on.
assert_eq!(walkable(&mut wram, 3, 4), Walkable::Yes);
}
#[test]
fn a_frame_that_is_not_showing_the_map_has_no_grid_to_check() {
let (mut wram, _, _) = town();
// `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own union), so a
// battle is exactly when the blocks under it belong to somebody else. Nothing can be
// cross-checked then, and a decode nothing can check is refused.
wram.battle(1);
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
let (mut wram, _, _) = town();
wram.dialogue_box();
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
}
#[test]
fn a_frame_with_no_map_header_has_no_grid() {
let mut wram = Wram::new();
wram.started();
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoHeader));
assert_eq!(GridRefusal::NoHeader.label(), "no map header");
}
#[test]
fn the_grid_is_decoded_once_per_map_and_dropped_on_arrival_somewhere_else() {
let (mut wram, blocks, blockset) = town();
let mut grids = MapGrids::default();
{
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
let first = state.map_grid().expect("a decodable map");
let again = state.map_grid().expect("the cached map");
assert!(std::sync::Arc::ptr_eq(&first, &again), "the second question is the same grid");
}
assert_eq!(grids.held(), Some(fake_wram::PALLET_TOWN));
// Walking through a door: another map id, so the cache is somebody else's and is dropped.
wram.map(fake_wram::OAKS_LAB, 10, 9, 3, 4)
.map_blocks(&blocks)
.screen_from_blocks(&blocks, &blockset);
{
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
let grid = state.map_grid().expect("a decodable map");
assert_eq!(grid.map(), fake_wram::OAKS_LAB);
}
assert_eq!(grids.held(), Some(fake_wram::OAKS_LAB));
}
#[test]
fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_none() {
let (mut wram, _, _) = town();
let mut state = PokeState::new(&mut wram);
assert!(state.map_grid().is_some(), "no cache is slower, not blinder");
let mut bare = Wram::overworld();
let mut state = PokeState::new(&mut bare);
assert!(state.map_grid().is_none(), "no blockset, no grid");
// Which is the frame the window predicate is for.
assert_eq!(state.walkable(3, 6), Walkable::No);
}

View file

@ -16,7 +16,6 @@ pub mod ram {
pub const wSpriteStateData1: u16 = 0xc100; // 49408
pub const wSpriteStateData2: u16 = 0xc200; // 49664
pub const wTileMap: u16 = 0xc3a0; // 50080
pub const wOverworldMap: u16 = 0xc6e8; // 50920
pub const wTopMenuItemY: u16 = 0xcc24; // 52260
pub const wTopMenuItemX: u16 = 0xcc25; // 52261
pub const wCurrentMenuItem: u16 = 0xcc26; // 52262
@ -60,7 +59,6 @@ pub mod ram {
pub const wCurMap: u16 = 0xd35e; // 54110
pub const wYCoord: u16 = 0xd361; // 54113
pub const wXCoord: u16 = 0xd362; // 54114
pub const wCurMapTileset: u16 = 0xd367; // 54119
pub const wCurMapHeight: u16 = 0xd368; // 54120
pub const wCurMapWidth: u16 = 0xd369; // 54121
pub const wCurMapConnections: u16 = 0xd370; // 54128
@ -70,8 +68,6 @@ pub mod ram {
pub const wSignCoords: u16 = 0xd4b1; // 54449
pub const wSignTextIDs: u16 = 0xd4d1; // 54481
pub const wNumSprites: u16 = 0xd4e1; // 54497
pub const wTilesetBank: u16 = 0xd52b; // 54571
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690

View file

@ -157,10 +157,6 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
use flybrain_gb::pokemon_red::macros::{geography, palette, path, plan};
use flybrain_gb::pokemon_red::macros::state::Walkable;
// Why the grid could not be decoded, read before the state borrows the emulator: it is the
// same call `MacroState::map_grid` makes, and the only reading that can say *which* of section
// 15's refusals a frame is.
let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err();
let ledger = AdapterLedger(adapter);
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
let state: &mut dyn MacroState = &mut poke;
@ -186,52 +182,16 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
let names: Vec<&str> =
plan.slots.iter().flatten().map(|spec| spec.name).collect();
println!("- the pad: {names:?}");
// The whole-map grid (`docs/design/macros.md` section 15), which is what the walks plan over
// now. Three numbers read a stalled walk: how much of the map is ground, how much of that the
// fly can actually get to from where it stands, and how much of *that* it has never stood on.
// A fly with 600 walkable tiles and 4 reachable ones is fenced in and no re-plan will help.
match state.map_grid() {
None => println!(
"- the map grid: none ({})",
refusal.map_or("unknown", |refusal| refusal.label())
),
Some(grid) => {
let unstood = grid
.walkable_tiles()
.into_iter()
.filter(|(x, y)| !state.tile_visited(*x, *y))
.count();
println!(
"- the map grid: {}x{} walkable {} reachable {} unstood {} unknown {}",
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(player.x, player.y),
unstood,
grid.unknown_count()
);
}
}
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The
// session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's
// palette, which this probe does not reach into, so a doormat the running fly has already
// marked still prints as unrecorded here. That is the point of the column: it shows what the
// reward ledger can and cannot answer.
// The grid's reading of the ground where there is one, the window's otherwise, said out loud
// so the map below cannot be mistaken for the other reading.
let grid = state.map_grid();
println!(
"\n### The ground, as the {} reads it (`v` = the adapter's lifetime ledger only)\n\n```",
if grid.is_some() { "map grid" } else { "ten-by-nine window" }
);
println!("\n### The ground (`v` = the adapter's lifetime ledger only)\n\n```");
for y in 0..size.height {
let row: Vec<String> = (0..size.width)
.map(|x| {
let answer = match grid.as_deref() {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
};
let walk = match answer {
let walk = match state.walkable(x, y) {
Walkable::Yes => '.',
Walkable::No => '#',
Walkable::Unknown => '?',
@ -248,11 +208,7 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
let mut n = 0;
for y in 0..size.height {
for x in 0..size.width {
let answer = match grid.as_deref() {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
};
if answer == Walkable::Yes && !state.tile_visited(x, y) {
if state.walkable(x, y) == Walkable::Yes && !state.tile_visited(x, y) {
n += 1;
}
}

View file

@ -205,12 +205,6 @@ struct Trace {
/// Every input of the detector's disputed branch on the last frame
/// (`pokemon_red::scene::why_unknown`).
ended_why: String,
/// The whole-map grid on the last frame, as [`grid_line`] reads it
/// (`docs/design/macros.md` section 15): how much of the map is ground, how much of it the fly
/// could reach from where it stopped, and how much of that it had never stood on. A hunt that
/// ends with reachable far below walkable ended fenced in, which no amount of re-planning was
/// ever going to fix.
ended_grid: String,
/// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four
/// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`).
///
@ -385,7 +379,6 @@ fn run(
ended_in: ("", String::new()),
longest_scene: BTreeMap::new(),
ended_why: String::new(),
ended_grid: String::new(),
font_corners_border: 0,
font_corners_no_border: 0,
font_no_corners: 0,
@ -551,9 +544,6 @@ fn run(
next_trace = ms + trace_every_ms;
let scene = macros.as_ref().map_or("", MacroLayer::scene_name);
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
// Read before the state borrows the emulator: this is the same call the state makes,
// and the only one that can say *which* refusal a frame is.
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
let state: &mut dyn MacroState = &mut state;
let player = state.player();
@ -561,10 +551,9 @@ fn run(
let ahead = Tile::new(player.x, player.y).step(player.facing)?;
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
});
let ground = grid_line(state, player, refusal);
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
println!(
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}",
(ms - began_ms) / MINUTE_MS
);
}
@ -635,14 +624,6 @@ fn run(
adapter.mode().to_string(),
);
trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
trace.ended_grid = {
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
let state: &mut dyn MacroState = &mut state;
let player = state.player();
grid_line(state, player, refusal)
};
trace.ended_ms = agent.network.ms;
trace.wall_seconds = began_wall.elapsed().as_secs_f64();
trace
@ -741,42 +722,6 @@ fn walk_report(trace: &Trace) {
}
}
/// The whole-map grid in one line: what a stalled walk looks like from outside.
///
/// `docs/design/macros.md` section 15. Walkable is how much of the map is ground, reachable is
/// how much of that the fly can get to from where it is standing (the directed walls respected),
/// and unstood is how much of *that* this run has never been on -- which is the frontier's own
/// candidate pool. A walk that cannot finish is one of three shapes and these numbers tell them
/// apart: fenced in (reachable far below walkable), nothing left to explore (unstood zero), or no
/// grid at all, in which case the walks are back on the ten-by-nine window and the reason is
/// named.
fn grid_line(
state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState,
player: Option<flybrain_gb::pokemon_red::macros::state::Player>,
refusal: Option<flybrain_gb::pokemon_red::state::GridRefusal>,
) -> String {
let Some(player) = player else { return "grid: no player".to_string() };
let Some(grid) = state.map_grid() else {
// Which of section 15's refusals this frame is, rather than a bare "none": a walk that is
// on the window reading should say why it is.
return format!("grid: none ({})", refusal.map_or("unknown", |refusal| refusal.label()));
};
let unstood = grid
.walkable_tiles()
.into_iter()
.filter(|(x, y)| !state.tile_visited(*x, *y))
.count();
format!(
"grid map={:#04x} {}x{} walkable={} reachable={} unstood={}",
grid.map(),
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(player.x, player.y),
unstood
)
}
fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else {
println!(
@ -851,7 +796,6 @@ fn main() {
println!("| --- | ---: |");
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
println!("| distinct (map, tile) | {} |", ground.len());
println!("| the map at the end | {} |", trace.ended_grid);
println!("| macros started | {} |", trace.starts.len());
for (outcome, count) in &trace.outcomes {
println!("| {outcome} | {count} |");

View file

@ -178,40 +178,8 @@ struct Run {
/// The rung-9 trap: between turns there is nothing to back out of, so the press completes
/// where the fly stands and the turn does not move. `false` is the assertion.
back_without_a_list: bool,
/// Where a `BACK` or a `NEXT` on a battle frame was dealt, as `scene/sub-state`.
///
/// The bool above says a rule broke; this says on which frame, which is the difference
/// between a pad rule to fix and a scene the detector cannot name.
battle_back_where: std::collections::BTreeSet<String>,
battle_next_where: std::collections::BTreeSet<String>,
/// Whether `THROW BALL` ever started at a species the party already held (section 12.9).
threw_at_a_held_species: bool,
/// Whether one battle pad ever held both `NEXT` and `BACK` (section 12.10).
///
/// The pair is the trap: `NEXT` is the A that advances text, `BACK` is the B that leaves a
/// list, and a pad with both has two buttons that undo each other with nothing else changing.
next_and_back_on_one_pad: bool,
/// Whether `NEXT` was ever on the pad while a battle menu was accepting input.
///
/// The live v0.4.2 shape: `NEXT` on the top-level menu was an A press on FIGHT, so it opened
/// the move list that `BACK` closed again -- 1264 starts against 1241 in 71 hours.
next_on_a_menu_accepting_input: bool,
/// The longest chain of battle macro starts that alternated `NEXT`, `BACK`, `NEXT`, `BACK`.
///
/// Two is an accident of the rotation; the live log did it for seventy-one hours. Only the
/// previous battle start has to be kept to measure the chain.
longest_next_back_alternation: u32,
alternation: u32,
last_battle_start: Option<&'static str>,
/// Macros started inside the battle that is running, and the worst any *finished* battle cost.
///
/// The claim the rung-9 loop breaks is that a battle **ends**, and that it ends on a bounded
/// number of macros rather than on however many holds the 2-cycle takes to fall out of.
macros_this_battle: u32,
worst_battle_macros: u32,
battles_entered: u32,
battles_ended: u32,
was_in_battle: bool,
/// Maps on whose *overworld* pad `GO OBJECTIVE` was ever bound.
///
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
@ -288,19 +256,7 @@ impl Run {
move_button_on_battle_pad: false,
battle_pad: None,
back_without_a_list: false,
battle_back_where: std::collections::BTreeSet::new(),
battle_next_where: std::collections::BTreeSet::new(),
threw_at_a_held_species: false,
next_and_back_on_one_pad: false,
next_on_a_menu_accepting_input: false,
longest_next_back_alternation: 0,
alternation: 0,
last_battle_start: None,
macros_this_battle: 0,
worst_battle_macros: 0,
battles_entered: 0,
battles_ended: 0,
was_in_battle: false,
objective_on_pad: std::collections::BTreeSet::new(),
}
}
@ -376,19 +332,7 @@ impl Run {
move_button_on_battle_pad: false,
battle_pad: None,
back_without_a_list: false,
battle_back_where: std::collections::BTreeSet::new(),
battle_next_where: std::collections::BTreeSet::new(),
threw_at_a_held_species: false,
next_and_back_on_one_pad: false,
next_on_a_menu_accepting_input: false,
longest_next_back_alternation: 0,
alternation: 0,
last_battle_start: None,
macros_this_battle: 0,
worst_battle_macros: 0,
battles_entered: 0,
battles_ended: 0,
was_in_battle: false,
objective_on_pad: std::collections::BTreeSet::new(),
}
}
@ -421,37 +365,6 @@ impl Run {
///
/// Section 12.9's question, through the same seam the macros read: `BACK` is a button where
/// there is a list to leave and nowhere else.
/// Whether the pad on this frame is a **battle** pad.
///
/// The scene the palette was dealt for, not `wIsInBattle`. The two differ on the `$ff` frame a
/// lost battle passes through and on a Safari or tutorial battle, where `state::battle` reads
/// nothing and `scene::detect` answers `Unknown` -- whose pad is `NEXT, BACK` by contract
/// (section 12.2, row 9: B is what leaves the Pokédex, the trainer card and OPTION). Asking
/// the cartridge byte instead accused that row of a battle rule it is not under.
fn battle_pad(&self) -> bool {
self.layer.scene_name() == "battle" || self.layer.scene_name() == "battle-switch"
}
/// The battle sub-state the seam reports, as a word, for the record and the failure message.
fn battle_sub_state(&mut self) -> &'static str {
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
let ledger = AdapterLedger(&self.adapter);
let mut state =
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
match state.battle() {
None => "no-battle",
Some(battle) => match battle.menu {
BattleMenu::Main { .. } => "main",
BattleMenu::Moves { cursor: Some(_), .. } => "moves",
BattleMenu::Moves { cursor: None, .. } => "moves-unplaceable",
BattleMenu::Party { .. } if battle.forced_switch => "party-forced",
BattleMenu::Party { .. } => "party",
BattleMenu::Bag { .. } => "bag",
BattleMenu::None => "between-turns",
},
}
}
fn battle_list_open(&mut self) -> bool {
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
let ledger = AdapterLedger(&self.adapter);
@ -570,30 +483,9 @@ impl Run {
}
// Section 12.9, on the cartridge: `BACK` belongs to a list. A battle frame with no list
// accepting input and `BACK` on the pad is the rung-9 trap itself.
if self.battle_pad() {
let back = bound.iter().any(|channel| channel.as_str() == "macro_back");
if back {
let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state());
self.battle_back_where.insert(where_);
}
if back && !self.battle_list_open() {
self.back_without_a_list = true;
}
}
// Section 12.10, on the cartridge: no battle pad holds a pair that undoes itself, and
// `NEXT` is on no frame with a cursor accepting input. The second is the stronger of the
// two -- the live pair was split across two sub-states, so no single pad held both.
if self.battle_pad() {
let next = bound.iter().any(|channel| channel.as_str() == "macro_next");
let back = bound.iter().any(|channel| channel.as_str() == "macro_back");
self.next_and_back_on_one_pad |= next && back;
if next {
let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state());
self.battle_next_where.insert(where_);
}
if next && self.own_turn() {
self.next_on_a_menu_accepting_input = true;
}
if self.in_battle() != 0 && !self.battle_list_open() {
self.back_without_a_list |=
bound.iter().any(|channel| channel.as_str() == "macro_back");
}
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
@ -614,32 +506,11 @@ impl Run {
.collect();
(decision.mask, started)
};
let in_battle_now = self.in_battle() != 0;
let on_a_battle_pad = self.battle_pad();
for name in started {
// Section 12.9's other half: a ball is never thrown at a species the party holds.
if name == "THROW BALL" && self.enemy_species_in_party() {
self.threw_at_a_held_species = true;
}
// Section 12.10's own signature, as the live event log printed it: `NEXT start/done,
// BACK start/done`, every hold, for seventy-one hours. Measured as the longest chain
// of consecutive battle starts drawn from those two alone and strictly alternating.
if in_battle_now {
self.macros_this_battle += 1;
}
if on_a_battle_pad {
let two = name == "NEXT" || name == "BACK";
self.alternation = match self.last_battle_start {
Some(last) if two && (last == "NEXT" || last == "BACK") && last != name => {
self.alternation.max(1) + 1
}
_ if two => 1,
_ => 0,
};
self.longest_next_back_alternation =
self.longest_next_back_alternation.max(self.alternation);
self.last_battle_start = Some(name);
}
*self.started.entry(name).or_insert(0) += 1;
}
self.gb.set_buttons(mask as u8);
@ -651,24 +522,6 @@ impl Run {
let ledger = AdapterLedger(&self.adapter);
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
}
// Battle boundaries, after the frame: what a battle cost in macros, and whether it ended.
let now_in_battle = self.in_battle() != 0;
match (self.was_in_battle, now_in_battle) {
(false, true) => {
self.battles_entered += 1;
self.macros_this_battle = 0;
self.last_battle_start = None;
self.alternation = 0;
}
(true, false) => {
self.battles_ended += 1;
self.worst_battle_macros =
self.worst_battle_macros.max(self.macros_this_battle);
self.macros_this_battle = 0;
}
_ => {}
}
self.was_in_battle = now_in_battle;
let map = self.map();
if self.route.last() != Some(&map) && map != u32::MAX {
self.route.push(map);
@ -891,8 +744,7 @@ fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
)
}
/// From the rung-9 forest checkpoint: the turns advance, the battles end, and no two buttons on a
/// battle pad undo each other.
/// From the rung-9 forest checkpoint: the turns advance, and `BACK` is never a battle's whole pad.
///
/// **What was live** (2026-09-22, `infra/docs/macros-traps.md`): rank 9, VIRIDIAN FOREST, 69 hours
/// on the rung, the ratchet's three attempts spent, and since the restart the macro starts were
@ -902,18 +754,9 @@ fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
/// `THROW BALL` spent balls on the species already in the party, each catch opening a nickname
/// screen the pad cannot leave.
///
/// **What was live again** (2026-09-22, thirty-five minutes after v0.4.2): the same rung, the same
/// forest, and the macro starts since the restart were `NEXT` 1264, `BACK` 1241, `THROW BALL` 5 and
/// `GO WARP` 3, the event log alternating `NEXT start/done, BACK start/done` every hold. The pair
/// was split across two sub-states of one turn, so 12.9's rule held and the loop survived it:
/// `NEXT` on the top-level menu was an A press on FIGHT, which **opened** the move list, and `BACK`
/// on the move list **closed** it again. Section 12.10 takes `NEXT` off every pad with a cursor
/// accepting input and gives the bag its own; `MOVE 1` is the backstop the top-level menu keeps.
///
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that it
/// ends on a bounded number of macros, that the fly's own presses are what ends it, and that none
/// of the three traps is on the pad any more. Which move it picks and whether it wins are the
/// fly's.
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that the
/// fly's own presses are what ends it, and that neither of the two traps is on the pad any more.
/// Which move it picks and whether it wins are the fly's.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
@ -970,34 +813,9 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
run.objective_on_pad
);
// The traps, as assertions on the cartridge.
eprintln!(
"`BACK` in a battle was dealt on {:?}; `NEXT` on {:?}",
run.battle_back_where, run.battle_next_where
);
assert!(
!run.back_without_a_list,
"`BACK` was on a battle pad with no list open: {:?}",
run.battle_back_where
);
// The two traps, as assertions on the cartridge.
assert!(!run.back_without_a_list, "`BACK` was on a battle pad with no list open");
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
// Section 12.10, the two halves of it.
assert!(
!run.next_and_back_on_one_pad,
"a battle pad held both `NEXT` and `BACK`: two buttons that undo each other"
);
assert!(
!run.next_on_a_menu_accepting_input,
"`NEXT` was on the pad while a battle menu was accepting input, where A opens rather \
than advances"
);
// And the shape the log had, rather than only the pads it came from. Two in a row is the
// rotation happening to deal the pair; the live run did it for seventy-one hours.
assert!(
run.longest_next_back_alternation < 4,
"`NEXT`/`BACK` alternated {} times in a row",
run.longest_next_back_alternation
);
// The turn moves: the fly's own battle presses happen, and a battle this run entered or
// resumed finishes.
@ -1010,33 +828,6 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
.sum();
assert!(battle_presses > 0, "no move and no ball: {:?}", run.started);
assert!(ended > 0, "no battle ever ended: {battles} entered");
// **Every battle that started, finished**, and each one on a bounded number of macros. That is
// the claim the 2-cycle breaks, and the way it breaks it is the opposite of a slow fight: the
// battle never leaves the fly's own turn at all, so the count grows with the *run*. On v0.4.2
// from this same checkpoint the trap hunt spent all twenty of its brain minutes -- 71,673
// frames, 1,489 macros, 73 of 73 windows flagged -- inside **one** battle that never ended,
// with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu. Four hundred
// is generous against the 275 this run's worst battle measured and far under an unbounded
// cycle.
assert!(
run.battles_ended + 1 >= run.battles_entered,
"{} battles entered and only {} left: a battle was entered and never got out",
run.battles_entered,
run.battles_ended
);
assert!(
run.worst_battle_macros > 0 && run.worst_battle_macros < 400,
"the worst battle cost {} macros over {} that ended",
run.worst_battle_macros,
run.battles_ended
);
eprintln!(
"battles: {} entered, {} ended, worst {} macros; longest NEXT/BACK alternation {}",
run.battles_entered,
run.battles_ended,
run.worst_battle_macros,
run.longest_next_back_alternation
);
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this

View file

@ -1,733 +0,0 @@
//! The whole-map walkability grid against the cartridge (`docs/design/macros.md` section 15).
//!
//! Gated on `FLY_ROM` and on a `FLYSIM01` checkpoint, and skips cleanly without either. The
//! checkpoints live outside the tree (`.local/` is not tracked) and are the release container's
//! own states, pulled read-only:
//!
//! ```sh
//! FLY_ROM="$HOME/…/Pokemon Red (U) [S][BF].gb" \
//! FLY_GRID_CHECKPOINT=.local/checkpoints/rank9-viridian-forest.checkpoint \
//! cargo test --release -p flysim --test rom_map_grid -- --nocapture
//! ```
//!
//! ## What only the cartridge can answer
//!
//! The unit tests in `pokemon_red/mapgrid/tests.rs` decode a made-up tileset, and the ones in
//! `pokemon_red/state/tests.rs` decode synthetic WRAM with a synthetic blockset in a synthetic
//! bank. Neither can say that those are the bytes the *game* writes: a wrong border, a wrong
//! stride or the wrong corner of a block all still answer, and answer plausibly. Three things here
//! need a running cartridge:
//!
//! 1. **the decode against the window predicate**, on every tile of the map the ten-by-nine
//! screen window can answer for — the same comparison the reader makes before it trusts
//! itself, done over the whole window rather than the player's own neighbourhood;
//! 2. **the decode against real presses** (the survey method of `docs/design/room-escape.md`
//! section 3): every tile the survey can stand on is `Yes`, and every step it refuses is `No`,
//! a directed wall, or a tile a sprite is standing on;
//! 3. **the walks**: that `GO FRONTIER` aims at ground outside the window and reaches it, and that
//! a way out of the map is one plan away rather than a re-plan at every window edge.
use std::collections::{BTreeMap, BTreeSet};
use flybrain_gb::macros::{MacroPalette, Started};
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
use flybrain_gb::pokemon_red::macros::palette::Palette;
use flybrain_gb::pokemon_red::macros::state::{Facing, MapGrid, Walkable};
use flybrain_gb::pokemon_red::macros::{PokemonPalette, path};
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, scene, state};
use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons,
};
/// One game frame at the Game Boy's real rate, for the adapter's brain clock.
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
/// Frames a restored state is given before anything reads the screen buffer.
///
/// `wTileMap` is the current view only on a *running* machine: read straight after
/// `import_state`, with no frame in between, it holds the view from wherever the state was taken
/// (`docs/design/macros-wram.md`). Twenty is what `tests/rom_scene.rs` gives every restored state
/// and what the survey below gives every one of its own.
const SETTLE_FRAMES: u32 = 20;
/// The seed the macro tests run the executor with. Nothing here depends on it: the slot is chosen
/// by name, not by a readout.
const SEED: u32 = 20_260_922;
fn rom() -> Option<Vec<u8>> {
let path = std::env::var_os("FLY_ROM")?;
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
}
}
fn checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_GRID_CHECKPOINT")
.or_else(|| std::env::var_os("FLY_TRAP_CHECKPOINT"))
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))?;
Some(
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope"),
)
}
macro_rules! skip_without {
() => {
match (rom(), checkpoint()) {
(Some(rom), Some(checkpoint)) => (rom, checkpoint),
(None, _) => {
eprintln!("skipped: FLY_ROM is not set");
return;
}
(_, None) => {
eprintln!("skipped: no FLY_GRID_CHECKPOINT / FLY_TRAP_CHECKPOINT");
return;
}
}
};
}
fn emulator(rom: &[u8]) -> Emulator {
Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge")
}
/// A cartridge resumed from a checkpoint, with the reward ledger the checkpoint carried and the
/// macro palette the sim loop runs.
struct Game {
gb: Emulator,
adapter: PokemonRedReward,
palette: PokemonPalette,
ms: f64,
}
impl Game {
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
let mut gb = emulator(rom);
let mut adapter = PokemonRedReward::new();
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
let mut game =
Self { gb, adapter, palette: PokemonPalette::new(SEED), ms: 0.0 };
game.settle_overworld();
if let Some(target) = std::env::var("FLY_GRID_TO_MAP")
.ok()
.and_then(|value| u8::from_str_radix(value.trim_start_matches("0x"), 16).ok())
{
game.reach_map(target);
}
game
}
/// Drive the cartridge with raw presses until the fly is standing on `target`.
///
/// The test's knowledge of the game, not the fly's, and the same device
/// `tests/rom_scene.rs`'s biased walk is: it decides which state gets *produced* and nothing
/// it does is asserted. What it is for is the second map of the survey
/// (`docs/design/macros.md` section 15 asks for two): no checkpoint in `.local/` is standing
/// on Pallet Town, and Oak's lab is one door south of it.
///
/// A biased random walk rather than the macros, deliberately: driving the state under test
/// into place with the macros under test is circular, and a walk needs no map knowledge. The
/// cycle is `tests/rom_scene.rs`'s — a direction, then B, which closes whatever a stray press
/// opened.
fn reach_map(&mut self, target: u8) {
let mut seed = u64::from(SEED);
let toward = if target < self.map() { buttons::DOWN } else { buttons::UP };
let mut arrived: Option<Tile> = None;
let mut walked = 0;
for frame in 0..90_000u32 {
if self.map() == target && walked > 0 {
eprintln!(
"reached map {target:#04x} after {frame} frames, {walked} tiles into it, at \
{:?}",
self.tile()
);
self.settle_overworld();
return;
}
// A few tiles *into* the map, not the doormat: a warp writes the map id before the
// header and the blocks, so the arrival frame itself is the torn one
// (`pokemon_red::state::still_the_loaded_map`).
if self.map() == target && self.tile() != arrived.unwrap_or(Tile::new(255, 255)) {
match arrived {
None => arrived = Some(self.tile()),
Some(_) => walked += 1,
}
}
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let random = [buttons::UP, buttons::DOWN, buttons::LEFT, buttons::RIGHT]
[((seed >> 33) % 4) as usize];
// Half biased, half random: the random half is what gets the walk around the
// furniture the bias walks it into.
let step = if (seed >> 41).is_multiple_of(2) { toward } else { random };
let mask = match frame % 48 {
0..=7 | 24..=31 => step,
12..=15 => buttons::B,
_ => buttons::NONE,
};
self.frame(mask);
}
assert_eq!(self.map(), target, "FLY_GRID_TO_MAP: never reached map {target:#04x}");
}
/// Settle a restored state until the cartridge is showing the overworld, pressing nothing.
///
/// Two reasons, both measured. `wTileMap` is the current view only on a *running* machine, so
/// every restored state needs frames before anything reads the screen
/// (`docs/design/macros-wram.md`); and a checkpoint is a frame of a live run, which can be
/// mid-step, mid-warp or inside a script that is walking the fly — Viridian City's own
/// checkpoint reads `Scene::Unknown` and its screen buffer is a tile out of step with its
/// coordinates, which is exactly the frame [`state::map_grid`] refuses rather than decodes.
/// A grid is a question about the overworld, so the test asks it there.
fn settle_overworld(&mut self) {
for _ in 0..1_200 {
self.frame(buttons::NONE);
if matches!(scene::detect(&mut self.gb), scene::Scene::Overworld) {
self.settle(SETTLE_FRAMES);
return;
}
}
eprintln!("the restored state never settled into the overworld");
}
/// Run frames with nothing held, sampling the adapter and observing the palette as the sim
/// loop does.
fn settle(&mut self, frames: u32) {
for _ in 0..frames {
self.frame(buttons::NONE);
}
}
/// One frame with these buttons held, then the adapter's sample and the palette's observation
/// — the sim loop's order.
fn frame(&mut self, mask: u8) {
self.gb.set_buttons(mask);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME;
self.adapter.sample(&mut self.gb, self.ms);
let ledger = AdapterLedger(&self.adapter);
self.palette.clock(self.ms);
let _ = self.palette.observe(&mut self.gb, &ledger);
}
fn map(&mut self) -> u8 {
self.gb.read_wram(ram::wCurMap)
}
fn tile(&mut self) -> Tile {
Tile::new(self.gb.read_wram(ram::wXCoord), self.gb.read_wram(ram::wYCoord))
}
/// The decoded grid, or the reason there is none.
fn grid(&mut self) -> Result<MapGrid, state::GridRefusal> {
state::map_grid(&mut self.gb)
}
/// Run `body` with the state the macros read, over this game's ledgers.
fn with_state<T>(&mut self, body: impl FnOnce(&mut dyn MacroState) -> T) -> T {
let ledger = AdapterLedger(&self.adapter);
let mut poke = state::PokeState::with_ledger(&mut self.gb, &ledger);
body(&mut poke)
}
/// The slot this scene binds `name` to, or `None` when the button is not on the pad.
fn slot(&mut self, name: &str) -> Option<u8> {
self.with_state(|state| {
let scene = state.scene();
let palette = Palette::for_scene(scene, state);
palette
.slots
.iter()
.enumerate()
.find(|(_, spec)| spec.is_some_and(|spec| spec.name == name))
.map(|(slot, _)| slot as u8)
})
}
/// Start `name` and run it to its outcome, returning the outcome's label and the frames spent.
///
/// The slot is chosen by name rather than by a readout, which is what makes this a test of the
/// macro instead of a test of the decoder: `docs/design/macros.md` section 12 has the fly
/// choosing, and what is under test here is what the chosen walk does.
fn run_macro(&mut self, name: &str) -> Option<(String, u32)> {
let slot = self.slot(name)?;
let ledger = AdapterLedger(&self.adapter);
match self.palette.start(slot, &mut self.gb, &ledger) {
Started::Refused { reason, .. } => Some((format!("refused: {reason}"), 0)),
Started::Running(_) => {
let mut frames = 0;
loop {
let ledger = AdapterLedger(&self.adapter);
let mask = self.palette.step(&mut self.gb, &ledger);
match mask {
None => break,
Some(mask) => {
self.frame(mask);
frames += 1;
}
}
if frames > 4_000 {
break;
}
}
let outcome = self
.palette
.take_finished()
.map(|(_, outcome)| format!("{outcome:?}"))
.unwrap_or_else(|| "none".to_string());
Some((outcome, frames))
}
}
}
}
/// Whether a tile is inside the ten-by-nine window the screen buffer can answer for.
fn in_window(player: Tile, x: u8, y: u8) -> bool {
let dx = i32::from(x) - i32::from(player.x);
let dy = i32::from(y) - i32::from(player.y);
(-4..=5).contains(&dx) && (-4..=4).contains(&dy)
}
#[test]
fn the_decoded_grid_agrees_with_the_window_on_every_tile_the_window_can_answer() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let here = game.tile();
let grid = match game.grid() {
Ok(grid) => grid,
Err(refusal) => {
eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label());
return;
}
};
eprintln!(
"map {map:#04x} {}x{} at {here:?}: walkable {}, reachable {}, unknown {}",
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(here.x, here.y),
grid.unknown_count()
);
assert_eq!(grid.map(), map);
assert_eq!(grid.unknown_count(), 0, "every block of the map is in the blockset");
let mut checked = 0;
let mut disagreements = Vec::new();
for y in 0..grid.height() {
for x in 0..grid.width() {
let Some(tile) = state::map_tile_id(&mut game.gb, x, y) else { continue };
let window = state::walkable(&mut game.gb, x, y);
checked += 1;
if grid.tile_id(x, y) != Some(tile) || grid.walkable(x, y) != window {
disagreements.push((x, y, tile, grid.tile_id(x, y), window, grid.walkable(x, y)));
}
assert!(in_window(here, x, y), "the window answered outside its own window");
}
}
eprintln!("{checked} tiles the window can answer for, {} disagreements", disagreements.len());
assert!(disagreements.is_empty(), "{disagreements:?}");
assert!(checked >= 40, "the window should answer for most of its ninety tiles: {checked}");
}
/// The survey: walk the map with real presses on throwaway emulators, tile by tile.
///
/// `docs/design/room-escape.md` section 3's method, and the one `tests/rom_scene.rs` used to pin
/// the window predicate's screen origin. Each tile keeps the state the walk arrived on, so every
/// press is made from the tile it belongs to rather than from wherever a long walk ended up.
///
/// Bounded by `FLY_GRID_SURVEY_TILES` (default [`SURVEY_TILES`]) because a whole forest is
/// 700-odd tiles and four presses each, and the claim is about the *rule*, not about coverage: a
/// wrong corner, a wrong stride or a wrong border disagrees within a dozen tiles.
type Survey = (
BTreeMap<Tile, Vec<u8>>,
BTreeSet<(Tile, &'static str)>,
BTreeSet<(Tile, &'static str)>,
BTreeSet<(Tile, &'static str)>,
BTreeSet<(Tile, &'static str)>,
);
/// Tiles the survey stands on before it stops, unless `FLY_GRID_SURVEY_TILES` says otherwise.
const SURVEY_TILES: usize = 120;
/// The four presses, with the direction each one is and the tile offset it aims at.
const PRESSES: [(&str, u8, Facing); 4] = [
("up", buttons::UP, Facing::Up),
("down", buttons::DOWN, Facing::Down),
("left", buttons::LEFT, Facing::Left),
("right", buttons::RIGHT, Facing::Right),
];
fn survey(rom: &[u8], start: &[u8], budget: usize) -> Survey {
let read = |probe: &mut Emulator| {
(
probe.read_wram(ram::wCurMap),
Tile::new(probe.read_wram(ram::wXCoord), probe.read_wram(ram::wYCoord)),
)
};
// 120 frames of held direction, then a release and twenty frames to settle, which are the two
// numbers `docs/design/macros-wram.md` measured: a press the player is not already facing
// turns first and steps second (53 frames from one staircase), and a state exported with a
// button held does not respond to that button after the import.
let step = |probe: &mut Emulator, mask: u8| {
let before = read(probe);
probe.set_buttons(mask);
let mut moved = false;
for _ in 0..120 {
probe.run_frame().expect("a frame should complete");
if read(probe) != before {
moved = true;
break;
}
}
probe.set_buttons(buttons::NONE);
for _ in 0..SETTLE_FRAMES {
probe.run_frame().expect("a frame should complete");
}
(read(probe), moved)
};
let restore = |state: &[u8]| {
let mut probe = emulator(rom);
probe.import_state(state).expect("a surveyed state should import");
probe
};
let mut first = restore(start);
for _ in 0..SETTLE_FRAMES {
first.run_frame().expect("a frame should complete");
}
let (map, here) = read(&mut first);
let mut states: BTreeMap<Tile, Vec<u8>> = BTreeMap::new();
let mut refused: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
let mut left: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
let mut interrupted: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
let mut blocked_by_sprite: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
states.insert(here, first.export_state().expect("a settled state should export"));
let mut queue = std::collections::VecDeque::from([here]);
while let Some(tile) = queue.pop_front() {
if states.len() >= budget {
break;
}
for (name, mask, _) in PRESSES {
let mut probe = restore(&states[&tile]);
// A press the cartridge answers with something other than a step -- a wild encounter
// in the grass, a bug catcher's line of sight, a script that takes the joypad -- says
// nothing about the ground either way, so the survey records it as its own category
// rather than as a wall. Viridian Forest is full of them: three tiles of the first
// hundred and twenty started a battle in three directions each.
if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) {
interrupted.insert((tile, name));
continue;
}
let ((there, at), moved) = step(&mut probe, mask);
if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) {
interrupted.insert((tile, name));
continue;
}
if !moved {
// **A person in the way is not a wall.** The collision table knows nothing about
// sprites and says so (`docs/design/macros-wram.md`), and Pallet Town's two
// villagers walk: whether one was standing on the target tile has to be read from
// the frame the press was made in, not from the state the survey started in.
let facing = PRESSES
.iter()
.find(|(press, _, _)| *press == name)
.map(|(_, _, facing)| *facing)
.expect("a known press");
let ahead = tile.step(facing);
let sprite = ahead.is_some_and(|ahead| {
state::npcs(&mut probe).iter().any(|npc| (npc.x, npc.y) == (ahead.x, ahead.y))
});
if sprite {
blocked_by_sprite.insert((tile, name));
} else {
refused.insert((tile, name));
}
continue;
}
if there != map {
// A warp: the press left the map, so it says nothing about the ground here.
left.insert((tile, name));
continue;
}
if let std::collections::btree_map::Entry::Vacant(entry) = states.entry(at) {
entry.insert(probe.export_state().expect("state export"));
queue.push_back(at);
}
}
}
(states, refused, left, interrupted, blocked_by_sprite)
}
#[test]
fn the_decoded_grid_matches_a_survey_with_real_presses() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let grid = match game.grid() {
Ok(grid) => grid,
Err(refusal) => {
eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label());
return;
}
};
let npcs: BTreeSet<Tile> = game
.with_state(|state| state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect());
let budget = std::env::var("FLY_GRID_SURVEY_TILES")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(SURVEY_TILES);
let start = game.gb.export_state().expect("the settled state should export");
let (stood, refused, left, interrupted, sprites) = survey(&rom, &start, budget);
eprintln!(
"survey of map {map:#04x}: {} tiles stood on, {} refused presses, {} presses that left \
the map, {} the cartridge answered with a battle or a script, {} a sprite was standing \
in the way of",
stood.len(),
refused.len(),
left.len(),
interrupted.len(),
sprites.len()
);
assert!(stood.len() > 8, "the survey barely moved: {} tiles", stood.len());
// Every tile the survey stood on is ground the grid calls walkable. This is the half that
// catches a decode that is too *strict* — a wall where the game lets the fly stand.
let mut wrong_walls = Vec::new();
for tile in stood.keys() {
if grid.walkable(tile.x, tile.y) != Walkable::Yes {
wrong_walls.push((*tile, grid.walkable(tile.x, tile.y), grid.tile_id(tile.x, tile.y)));
}
}
assert!(wrong_walls.is_empty(), "tiles the survey stood on that the grid calls walls: {wrong_walls:?}");
// Every press the cartridge refused is a wall in the grid, a directed wall out of that tile,
// or a tile a sprite is standing on — the three things a refusal can be
// (`docs/design/macros.md` section 15). This is the half that catches a decode that is too
// *permissive*.
let mut unexplained = Vec::new();
for (tile, name) in &refused {
let (_, _, facing) = PRESSES.iter().find(|(press, _, _)| press == name).copied().unwrap();
let ahead = tile.step(facing);
let explained = match ahead {
None => true,
Some(ahead) => {
ahead.x >= grid.width()
|| ahead.y >= grid.height()
|| grid.walkable(ahead.x, ahead.y) != Walkable::Yes
|| grid.walled(tile.x, tile.y, facing)
|| npcs.contains(&ahead)
}
};
if !explained {
unexplained.push((
*tile,
grid.tile_id(tile.x, tile.y),
*name,
ahead,
ahead.map(|at| grid.tile_id(at.x, at.y)),
grid.walled(tile.x, tile.y, facing),
));
}
}
assert!(
unexplained.is_empty(),
"presses the cartridge refused that the grid calls open ground: {unexplained:?}"
);
// And every step that *worked* is one the grid would have planned: walkable, not walled.
let mut wrongly_walled = Vec::new();
for tile in stood.keys() {
for (name, _, facing) in PRESSES {
if refused.contains(&(*tile, name))
|| left.contains(&(*tile, name))
|| interrupted.contains(&(*tile, name))
|| sprites.contains(&(*tile, name))
{
continue;
}
let Some(ahead) = tile.step(facing) else { continue };
if !stood.contains_key(&ahead) {
continue;
}
if grid.walkable(ahead.x, ahead.y) != Walkable::Yes
|| grid.walled(tile.x, tile.y, facing)
{
wrongly_walled.push((*tile, name, ahead));
}
}
}
assert!(
wrongly_walled.is_empty(),
"steps the cartridge made that the grid calls walls: {wrongly_walled:?}"
);
eprintln!(
"the grid agrees with every one of {} stood tiles and {} refused presses on map {map:#04x}",
stood.len(),
refused.len()
);
}
#[test]
fn go_frontier_aims_outside_the_window_and_walks_there() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let Ok(grid) = game.grid() else {
eprintln!("skipped: no grid on map {map:#04x}");
return;
};
// **The plan.** Every frontier tile the ten-by-nine window cannot answer for is ground the
// fly could not have aimed at before section 15, and a route to the nearest of them is a
// plan that crosses the window with no guesses in it.
let (here, outside, plan) = game.with_state(|state| {
let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player");
let outside: Vec<Tile> = path::frontier(state)
.into_iter()
.map(|(tile, _)| tile)
.filter(|tile| !in_window(here, tile.x, tile.y))
.collect();
let plan = path::route(state, &outside);
(here, outside, plan)
});
eprintln!(
"map {map:#04x} at {here:?}: {} frontier tiles outside the window, plan {:?} steps",
outside.len(),
plan.as_ref().map(|route| route.steps.len())
);
if outside.is_empty() {
eprintln!("skipped: every frontier tile on this map is inside the window");
return;
}
let plan = plan.expect("a route to the frontier beyond the window");
assert!(plan.goal.is_some(), "the plan only approaches the frontier");
let mut at = here;
for facing in &plan.steps {
at = at.step(*facing).expect("a step inside the map");
assert_eq!(
grid.walkable(at.x, at.y),
Walkable::Yes,
"the plan walks through {at:?}, which the grid does not call ground"
);
}
assert!(outside.contains(&at), "the plan ends on {at:?}, which is not one of its goals");
assert!(
!in_window(here, at.x, at.y),
"the plan ends on {at:?}, which the window could have answered for anyway"
);
// **The walk.** The fly's own choice is not being simulated here — the slot is started by
// name — but everything after that is the shipping executor: one plan, the per-step moved
// check, the three-failure rule, the frame cap. What is asserted is that some hold of
// `GO FRONTIER` ends on ground that was outside the window when the hold began, which is the
// whole of the operator's ask.
let mut left_the_window = None;
let mut left_the_map = None;
let mut done = 0;
for hold in 0..12 {
let began = game.tile();
let Some((outcome, frames)) = game.run_macro("GO FRONTIER") else {
eprintln!("GO FRONTIER left the pad after {hold} holds");
break;
};
let landed = game.tile();
if outcome == "Done" {
done += 1;
}
eprintln!("hold {hold}: {began:?} -> {landed:?} in {frames} frames, {outcome}");
if !in_window(began, landed.x, landed.y) {
left_the_window = Some((hold, began, landed, frames));
break;
}
if game.map() != map {
left_the_map = Some((hold, game.map()));
break;
}
}
assert!(done > 0, "no hold of GO FRONTIER finished");
match (left_the_window, left_the_map) {
(Some((hold, began, landed, frames)), _) => eprintln!(
"GO FRONTIER walked out of its own window on hold {hold}: {began:?} -> {landed:?} in \
{frames} frames"
),
// A doormat is unstood ground and the frontier is allowed to aim at it
// (`docs/design/macros.md` section 12.7: the reward ledger can never record a warp tile),
// so a near frontier that is a door takes the fly off the map before the far one is
// reached. That is the frontier's own rule rather than the grid's doing, and the plan
// above is what this test is for; the arrival is asserted on a map with no door next to
// the fly.
(None, Some((hold, map))) => eprintln!(
"GO FRONTIER stepped onto a warp tile on hold {hold} and left for map {map:#04x} \
before it left its window"
),
(None, None) => panic!("no hold of GO FRONTIER left the window it started in"),
}
}
#[test]
fn a_way_out_of_the_map_is_one_plan_away() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let Ok(grid) = game.grid() else {
eprintln!("skipped: no grid on map {map:#04x}");
return;
};
let (here, plans) = game.with_state(|state| {
let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player");
let exits = path::exits(state);
let mut plans = Vec::new();
for exit in &exits {
if let Some(route) = path::route(state, &[exit.tile]) {
plans.push((exit.id, exit.tile, exit.way, route.goal.is_some(), route.steps.clone()));
}
}
(here, plans)
});
assert!(!plans.is_empty(), "a map with no way out at all");
let mut crossed = 0;
let mut reached_one = false;
for (id, tile, way, reached, steps) in &plans {
// Every tile of the plan is ground the grid calls walkable: a plan with no guesses in it,
// which is what "one plan per walk" needs to mean.
let mut at = here;
let mut guesses = 0;
for facing in steps {
at = at.step(*facing).expect("a step inside the map");
if grid.walkable(at.x, at.y) != Walkable::Yes {
guesses += 1;
}
}
eprintln!(
"{id:?} at {tile:?} ({way:?}): {} steps, reached {reached}, {guesses} tiles the grid \
does not call ground",
steps.len()
);
assert_eq!(guesses, 0, "the plan to {tile:?} walks through {guesses} tiles of not-ground");
if !*reached {
// An exit tile the map fences off from where the fly stands: the grid *knows* it
// cannot be reached, which is the honest answer and is what the closest-approach route
// is for. Pallet Town's north-west corner is one — walkable ground behind a fence.
eprintln!(" (that one only approaches: {} of {} tiles reachable)", 0, steps.len());
continue;
}
reached_one = true;
assert_eq!(at, *tile, "the plan to {tile:?} ends on {at:?}");
if steps.len() > 9 {
crossed += 1;
}
}
assert!(reached_one, "no way out of map {map:#04x} can be reached at all");
assert!(
crossed > 0,
"no way out of map {map:#04x} is further than the window, so this checkpoint cannot show \
the difference"
);
}

View file

@ -182,24 +182,6 @@ EXTRA_RAM = (
# standing behind a desk can be talked to at all: none of the four tiles around either of
# them is walkable.
'wTilesetTalkingOverTiles',
# The whole-map walkability grid (docs/design/macros.md section 15).
#
# LoadTileBlockMap copies the loaded map out of its ROM bank into wOverworldMap
# as one byte per 4x4-tile block, in rows of wCurMapWidth + MAP_BORDER * 2 with
# the map itself three rows and three columns in, so the blocks of the current
# map are a WRAM read rather than a ROM one. wCurMapTileset keys the tile-pair
# collision lists (CheckForTilePairCollisions). wTilesetBank and
# wTilesetBlocksPtr are the tileset header's blockset: 16 bytes per block id,
# four rows of four tile ids, which DrawTileBlock indexes exactly that way --
# and it is not in bank 0, which is why the memory seam grew a bank-aware ROM
# read for it. All four are resolved the same way every other name here is;
# services/flysim/tools/resolve_wram.py is the second reading of them, from
# ram/wram.asm at this commit, and it re-derives 40 of the addresses this table
# already carries before it emits one of these four.
'wOverworldMap',
'wCurMapTileset',
'wTilesetBank',
'wTilesetBlocksPtr',
)

View file

@ -1,402 +0,0 @@
"""Resolve WRAM symbol addresses out of a pret/pokered checkout, and verify the pinned ones.
`gen_symbols.py` owns `symbols.rs` and takes its addresses from the prototype's
already-generated table; it refuses a hand-written address. Section 15 of
`docs/design/macros.md` needs four symbols that table does not carry
(`wOverworldMap`, `wCurMapTileset`, `wTilesetBank`, `wTilesetBlocksPtr`), and a
hand-computed address is exactly what neither script will take. So this tool
does the same job the disassembly's own `.sym` would, from `ram/wram.asm`:
* it walks the file in order, keeping a byte cursor;
* the cursor is **only ever live while it is anchored**: it is set from a symbol
`symbols.rs` already pins, and any declaration form this tool cannot evaluate
exactly kills it until the next pinned symbol revives it. An unanchored region
can therefore not produce a number at all, rather than producing a wrong one;
* every pinned symbol it reaches while live is checked against `symbols.rs`, and
a single disagreement is a failure with no output. A resolved symbol is only
reported when the run also re-derived the *next* pinned symbol after it, so
each answer is bracketed by two addresses the table already carries.
Usage (read-only; `--emit` rewrites the `ram` block of symbols.rs in place):
python3 services/flysim/tools/resolve_wram.py --pokered <checkout> [--emit]
"""
from __future__ import annotations
import argparse
import math
import re
from pathlib import Path
SYMBOLS = (
Path(__file__).resolve().parents[1] / 'crates/flybrain-gb/src/pokemon_red/symbols.rs'
)
#: The symbols this run is for, with why the macro layer needs each one. Every
#: one is checked to be bracketed by two pinned addresses before it is emitted.
WANTED = {
# The current map's block ids, as LoadTileBlockMap copies them out of the
# map's ROM bank: rows of (width + MAP_BORDER * 2) bytes, the map itself
# offset by three rows and three columns. This is what makes a whole-map
# walkability grid a WRAM read rather than a ROM one.
'wOverworldMap': 'the loaded map, one byte per 4x4-tile block',
# Which tileset the loaded map uses: the tile-pair collision lists are keyed
# by it (CheckForTilePairCollisions).
'wCurMapTileset': 'the loaded map tileset id',
# The tileset header's blockset: a bank byte and a little-endian pointer at
# 16 bytes per block, four rows of four tile ids (DrawTileBlock). The bank is
# not bank 0, so this is the read the memory seam grew a bank for.
'wTilesetBank': 'the ROM bank the blockset lives in',
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block',
}
def pinned(text: str) -> dict[str, int]:
"""Every address `symbols.rs` carries today, by symbol name."""
return {
name: int(value, 16)
for name, value in re.findall(r'pub const (w\w+): u16 = 0x([0-9a-f]{4});', text)
}
def constants(root: Path) -> dict[str, int]:
"""Every `DEF NAME EQU <expression>` the declarations below need.
Resolved by repeated passes rather than in one, because the decomp defines
constants in terms of each other (`SURROUNDING_WIDTH EQU SCREEN_BLOCK_WIDTH *
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it.
"""
pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc')
)
for path in sources:
for name, value in re.findall(
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
):
pending.setdefault(name, value.strip())
out: dict[str, int] = {}
while pending:
progressed = False
for name in list(pending):
try:
out[name] = size_of(pending[name], out)
except Unevaluable:
continue
del pending[name]
progressed = True
if not progressed:
break
return out
def number(token: str) -> int:
token = token.strip()
if token.startswith('$'):
return int(token[1:], 16)
if token.startswith('%'):
return int(token[1:], 2)
return int(token, 10)
class Unevaluable(Exception):
"""A declaration this tool will not guess the size of."""
def size_of(expression: str, known: dict[str, int]) -> int:
"""Bytes in a `ds`/`EQU` expression: the decomp's own arithmetic, nothing else.
`$`/`%` literals and the constants resolved so far are substituted, the
`tiles` unit is a factor of sixteen, and what is left must be plain
arithmetic over integers -- so a name this tool has not resolved, a function
call or anything else raises [`Unevaluable`] instead of becoming a guess.
"""
expression = expression.split(';')[0].strip()
if not expression:
raise Unevaluable('empty')
scale = 1
if expression.endswith('tiles'):
expression = expression[: -len('tiles')].strip()
scale = 16 # one 8x8 2bpp tile is 16 bytes
def substitute(match: re.Match[str]) -> str:
token = match.group(0)
if token[0] in '$%':
return str(number(token))
if token in known:
return str(known[token])
raise Unevaluable(token)
substituted = re.sub(r'\$[0-9A-Fa-f_]+|%[01_]+|[A-Za-z_]\w*', substitute, expression)
if not re.fullmatch(r'[\d\s()+\-*/]+', substituted):
raise Unevaluable(expression)
try:
value = eval(substituted, {'__builtins__': {}}, {}) # arithmetic only, checked above
except (SyntaxError, ZeroDivisionError, TypeError) as error:
raise Unevaluable(expression) from error
if not isinstance(value, int):
raise Unevaluable(expression)
return value * scale
def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
"""Sizes of the RAM struct macros, counted from their own declarations."""
out: dict[str, int] = {}
for path in sorted((root / 'macros').glob('*.asm')):
text = path.read_text()
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
name, body = match.group(1), match.group(2)
total = 0
for line in body.splitlines():
line = line.split(';')[0].strip()
# A struct macro labels each field with its argument
# (`\\1YCoord:: db`), so the label is stripped and the
# declaration after it is what reserves the bytes.
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
if not line or line.startswith(('IF', 'ELSE', 'ENDC', 'ASSERT')):
continue
if re.match(r'^\w+::?$', line):
continue
if line.startswith('db'):
total += max(1, len([part for part in line[2:].split(',') if part.strip()]))
elif line.startswith('dw'):
total += 2 * max(1, len([p for p in line[2:].split(',') if p.strip()]))
elif line.startswith('ds '):
try:
total += size_of(line[3:], known)
except Unevaluable:
total = None
break
else:
total = None
break
if total is not None:
out[name] = total
return out
def walk(
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
) -> tuple[dict[str, int], list[str], int]:
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
macros = macro_sizes(root, known)
lines = (root / 'ram/wram.asm').read_text().splitlines()
cursor: int | None = None
resolved: dict[str, int] = {}
# Symbols counted since the last pinned anchor, held back until a pinned
# address after them agrees.
pending_run: dict[str, int] = {}
order: list[str] = []
checked = 0
problems: list[str] = []
lost: list[str] = []
# UNION frames: (start address, widest branch so far, whether a branch was
# unevaluable). A frame whose start is unknown, or any one of whose branches this
# tool could not size, poisons the whole union: what the section advances by is
# the widest branch, so one branch it cannot measure means it cannot measure any.
unions: list[tuple[int | None, int, bool]] = []
index = 0
while index < len(lines):
raw = lines[index]
index += 1
line = raw.split(';')[0].strip()
if not line:
continue
if line.startswith('SECTION'):
# A section's address comes from the linker, not the source. WRAM0
# sections are packed in declaration order, so the cursor carries on
# -- and the next address `symbols.rs` pins is what tests that: any
# padding the linker inserted would land as a MISMATCH and this tool
# would emit nothing.
continue
if line == 'UNION':
unions.append((cursor, 0, False))
continue
if line == 'NEXTU':
if not unions:
cursor = None
continue
start, widest, poisoned = unions.pop()
poisoned = poisoned or start is None or cursor is None
if not poisoned:
widest = max(widest, cursor - start)
unions.append((start, widest, poisoned))
cursor = start
continue
if line == 'ENDU':
if not unions:
cursor = None
continue
start, widest, poisoned = unions.pop()
if poisoned or start is None or cursor is None:
cursor = None
continue
cursor = start + max(widest, cursor - start)
continue
if line.startswith(('FOR ', 'REPT ')):
# Evaluate the body only when every line of it has a known size.
head = line.split(None, 1)[1]
# `REPT n`, `FOR v, stop` and `FOR v, start, stop`: rgbasm's own
# three forms, and the third iterates stop - start times.
arguments = [part.strip() for part in head.split(',')]
count_token = arguments[-1]
start_token = arguments[-2] if line.startswith('FOR ') and len(arguments) == 3 else None
body: list[str] = []
depth = 1
while index < len(lines):
inner = lines[index].split(';')[0].strip()
index += 1
if inner.startswith(('FOR ', 'REPT ')):
depth += 1
if inner == 'ENDR':
depth -= 1
if depth == 0:
break
body.append(inner)
try:
count = size_of(count_token, known)
if start_token is not None:
count -= size_of(start_token, known)
per = 0
for inner in body:
per += declaration_size(inner, known, macros)
if cursor is not None:
cursor += count * per
except Unevaluable:
if cursor is not None:
lost.append(f'line {index}: {line}')
cursor = None
pending_run.clear()
continue
label = re.match(r'^(w\w+)::', line)
if label is not None:
name = label.group(1)
if name in table:
if cursor is not None and cursor != table[name]:
problems.append(
f'{name}: wram.asm gives ${cursor:04x}, symbols.rs pins ${table[name]:04x}'
)
pending_run.clear()
elif cursor is not None:
checked += 1
# Everything counted since the last anchor is now bracketed
# by two addresses the table already carries.
resolved.update(pending_run)
order.extend(pending_run)
pending_run.clear()
else:
if verbose:
lost.append(f'cold anchor at {name} (line {index})')
pending_run.clear()
cursor = table[name]
elif cursor is not None:
pending_run[name] = cursor
line = line[label.end() :].strip()
if not line:
continue
if line.endswith('::') or re.fullmatch(r'\.\w+', line):
continue
try:
if cursor is not None:
cursor += declaration_size(line, known, macros)
except Unevaluable:
if cursor is not None:
lost.append(f'line {index}: {line}')
cursor = None
pending_run.clear()
if verbose:
for entry in lost:
print(f'UNEVALUABLE {entry}')
return resolved, problems, checked
def declaration_size(line: str, known: dict[str, int], macros: dict[str, int]) -> int:
"""Bytes one declaration line reserves, or [`Unevaluable`]."""
line = line.split(';')[0].strip()
if not line or line.endswith('::') or line.startswith(('ENDSECTION', 'ASSERT', 'ENDR')):
return 0
# Any label, including the `{02d:n}` interpolations a FOR body labels its
# iterations with; what reserves the bytes is the declaration after it.
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
if not line:
return 0
if line == 'db':
return 1
if line == 'dw':
return 2
if line.startswith('db '):
return max(1, len([part for part in line[3:].split(',') if part.strip()]))
if line.startswith('dw '):
return 2 * max(1, len([part for part in line[3:].split(',') if part.strip()]))
if line.startswith('ds '):
return size_of(line[3:], known)
if line.startswith('flag_array '):
return math.ceil(size_of(line[len('flag_array ') :], known) / 8)
head = line.split(None, 1)[0]
if head in macros:
return macros[head]
raise Unevaluable(line)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--pokered', type=Path, required=True)
parser.add_argument('--verbose', action='store_true', help='name every declaration it will not size')
parser.add_argument('--emit', action='store_true', help='write the new addresses into symbols.rs')
args = parser.parse_args()
text = SYMBOLS.read_text()
commit = re.search(r'POKERED_COMMIT: &str = "([0-9a-f]{40})"', text)
if commit is None:
raise SystemExit('symbols.rs carries no POKERED_COMMIT')
head = (args.pokered / '.git/HEAD').read_text().strip()
if head.startswith('ref:'):
head = (args.pokered / '.git' / head.split()[1]).read_text().strip()
if head != commit.group(1):
raise SystemExit(
f'checkout is at {head}, symbols.rs pins {commit.group(1)}: two revisions of the '
'disassembly renumber RAM relative to each other'
)
table = pinned(text)
known = constants(args.pokered)
resolved, problems, checked = walk(args.pokered, table, known, args.verbose)
if problems:
for problem in problems:
print(f'MISMATCH {problem}')
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted')
print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement')
missing = [name for name in WANTED if name not in resolved]
if missing:
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
for name in WANTED:
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
if not args.emit:
return
block = re.search(r'(pub mod ram \{\n)(.*?)(\n\}\n)', text, re.S)
if block is None:
raise SystemExit('no ram block in symbols.rs')
rows = []
for row in block.group(2).splitlines():
name = re.match(r'\s*pub const (w\w+): u16 = 0x([0-9a-f]{4});', row)
if name is None:
continue
rows.append((int(name.group(2), 16), name.group(1)))
for name in WANTED:
if name not in table:
rows.append((resolved[name], name))
rows = sorted(set(rows))
width = max(len(name) for _, name in rows)
body = '\n'.join(
f' pub const {name}: u16 = 0x{address:04x};'.ljust(width + 31)
+ f'// {address}'
for address, name in rows
)
SYMBOLS.write_text(text[: block.start(2)] + body + text[block.end(2) :])
print(f'symbols.rs: {len(rows)} addresses written')
if __name__ == '__main__':
main()