Merge fix/loop-20260922T0735: MENU off every pad, stranded rooms keep their way out, battle menu columns read right

This commit is contained in:
acamilo 2026-09-22 09:29:57 +00:00
commit 335a3b534b
8 changed files with 1130 additions and 125 deletions

View file

@ -914,6 +914,95 @@ mechanism -- `BACK` **739 starts on the move list** and `NEXT` **739 on the top-
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.11 `MENU` opens a screen its own `BACK` closes, and Red's battle menu is two columns (2026-09-22, rung 10, thirty-one minutes after v0.4.3)
Rank 10 (PEWTER CITY, next the BOULDER BADGE), the fly inside a Pewter building, and since the
restart the macro starts were `MENU` **82**, `BACK` **82**, `GO FRONTIER` 8 -- the event log
alternating `MENU start/done, BACK start/done` on **map 0x35**. Reproduced from the live checkpoint
with the real cartridge: the map is the **upper floor of the Pewter museum**, fourteen blocks by
eight, 81 walkable tiles all reachable and four never stood on, one warp at (7, 7) down to the
museum's ground floor (0x34), two signs and three exhibits.
`infra/docs/macros-traps.md` has the probe whole.
- **`MENU` is a trap by section 12.2's own definition, and it was the *only* button left.** Opening
the start menu changes nothing in the world, so the macro completes on the tile it started on --
and the scene it opens deals `CLOSE`, `CONFIRM` and `BACK`, of which `BACK` presses B and closes
it again. Two buttons that undo each other with nothing else changing: section 12.10's rule, one
scene wider than a battle. **`MENU` is on no pad now.** Not narrowed -- there is nothing behind
it to press it for, because no macro in the vocabulary uses the start menu except as a scene to
leave, and "MENU to save the game" is not a macro that exists. It stays a type, a population, a
tag and a script, so the thirty-one channels, the roles and `--print-compatibility` do not move,
and the start menu is still the fly's to open with the **raw** START button, which reaches the
cartridge in macros mode.
- **Why that map's pad was `MENU` and `GO FRONTIER` and nothing else.** Every candidate list on the
museum's upper floor empties: `geography` has no row for the museum, so `next_hop` answers `None`
and `GO OBJECTIVE` has nothing to aim at -- the objective itself is right, map 0x36 with a
*person* on it, which is rung 11's gym leader; the three exhibits and two signs are *reached* by
`GO NPC` and `GO ITEM`, which retires them for the session (12.1); the four unstood tiles are
walked or excluded and `GO FRONTIER` empties for the window; and the one way out is classified a
**passage** and not an exit -- it is a staircase -- so `unexcluded_exits` drops it while the
blocked ledger rests it, and `ways`' tier 3 for a passage is built out of the same list.
- **`MENU` was also what made an empty overworld pad impossible, so that guarantee moves to the way
out.** `ways` gains, for a *room*, the last resort `GO ROUTE` has had outdoors since 13.1: with
nothing else on this map worth walking to (`palette::stranded`), the exits of this kind come back
**ignoring the blocked window**, the one toward the objective preferred. A target the ledger is
resting is still the only place to go, and a door the run has been through is a better answer
than a pad that cannot move. It is a last resort and not a tier: one unstood tile and it goes
away again, because "the nearest door, once per hold" is row 2's own two hours seventeen.
- **A map with no way out at all is now a genuinely empty pad, and that is said rather than
papered over.** No map in Red is that -- an interior has its front door or its staircase, an
outdoor map has its connections -- it is asserted as a named residual in the pad-empty sweep, and
`game.padEmptyMs` is what reports it if one ever appears.
- **A cursor step waits for the list it was built for.** `THROW BALL` was **63 starts and 63
`blocked`** on v0.4.3, mean sixty-nine frames, which is the cursor to ITEM, the A that confirms
it, the twenty settle frames, and a refusal on the next frame. Red keeps one cursor for every
menu in the game, so "where is the cursor" was half a question: the step that should have walked
the bag list read the battle *menu*'s four entries instead. A `Listing` now says which list it is
and a cursor step says which list its target indexes into; a step whose list is not up waits, as
it already waited for a list reporting no cursor at all (section 4), and takes its press order
and its budget from that list on the first frame it accepts input. A confirming press that has
begun still finishes, because the press is what answers the list.
- **And the reason `THROW BALL` never reached the bag: Red's battle menu is two columns, so its
order is FIGHT, ITEM, PKMN, RUN.** The screen reads `FIGHT PKMN` over `ITEM RUN` and the game's
index does not: `wCurrentMenuItem` is the row inside the column the cursor is in, and selection
adds two for the right column. `battle_entry` had `PKMN` 1 and `ITEM` 2 -- the row-major reading
of the picture -- so **every macro that meant to open the bag opened the party list and every
macro that meant to open the party list opened the bag**, for as long as the four constants have
existed. Surveyed on the cartridge: A at `wTopMenuItemX` 15 with `wCurrentMenuItem` 0 opens the
party list (`wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02`) and the game writes
`wCurrentMenuItem` 2 on the frame after. `THROW BALL` and `ITEM` and `SWITCH` have never once
completed on the release box; they do now. The fake had the same mistake in its own two-by-two
geometry, which is why no unit test could have caught it, and it is column-major now.
- **`BACK` on the move list only where the moves can be read.** The other v0.4.3 residual: `BACK`
was 263 of 797 macro starts and every one was over an open move list. A move list whose battler
the seam cannot place binds no `MOVE n` at all, so its pad was `BACK` alone -- and closing the
list is exactly undoing the `MOVE 1` on the menu underneath that opened it, which is 12.10's pair
with `MOVE 1` in `NEXT`'s place. With nothing readable the pad is `MOVE 1` alone, and its script
confirms wherever the cursor stands, which is the press that ends a turn.
**What the harness holds.** No scene's set and no scene's pad contains `MENU`, in any state; a room
whose one door the ledger rests still offers it and the pad is that walk; the move list's pad is
`MOVE n` plus `BACK` only with a readable battler and `MOVE 1` alone otherwise; a cursor step waits
rather than reading the list it has already answered; and `battle_entry`'s four numbers are pinned
against the survey. ROM-gated from the live checkpoint: the fly leaves the museum's upper floor on
**frame 182**, and over thirty-three brain minutes across seven maps `MENU` is on no pad, no
overworld pad is empty, and `MENU`/`BACK` never alternate -- where v0.4.3 deals `MENU` on all seven
with 147 starts. ROM-gated from the rung-9 forest checkpoint: `THROW BALL` 15 starts and **0**
blocked and `SWITCH` 17 and **0**, against 6 of 6 and 15 of 17 before.
**Two things measured and not fixed here.** The trap hunt from this checkpoint does not reproduce
this loop at all, and it cannot: the reached and blocked ledgers are session state that a restore
clears (12.1), so a restored fly walks straight out of the museum, and the live loop needed thirty
minutes of ledger to build. What the hunt reproduces instead is **row 41**, the Pokémon Center
nurse's box -- 62,804 of 71,673 frames on one tile of map 0x3a with `YES` 1,278 of 1,295 macro
starts -- and that is the next trap. Inside a battle the largest residual is `MOVE n`: 890 of 1,431
macros report `blocked`, every one with the move list drawn and its cursor placeable but not
accepting input, so no press moves it and the step spends its budget. That is row 30b's unplaceable
cursor inverted and it needs a WRAM reading rather than a pad change.
The decoder, the reward catalog, the adapter version, the roles 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.")
@ -981,18 +1070,18 @@ observe is not a precondition, it is a guess.
| scene / sub-state | pad | change |
| --- | --- | --- |
| Overworld, outdoors | GO OBJECTIVE, GO ROUTE, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU | **MENU added** (row 18); errands added |
| Overworld, indoors | GO OBJECTIVE, GO OUT, GO WARP, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU | **MENU added** (row 18); `GO NPC` was off section 3's fixed indoor row and on the plan's, and with one dealer it is on both |
| Overworld, outdoors | GO OBJECTIVE, GO ROUTE, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK | MENU was added by row 18 and **taken back off by 12.11**: it opens a screen whose own `BACK` closes it again, and nothing in the vocabulary uses the start menu. Errands added |
| Overworld, indoors | GO OBJECTIVE, GO OUT, GO WARP, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK | **MENU off, by 12.11**, as above; `GO NPC` was off section 3's fixed indoor row and on the plan's, and with one dealer it is on both |
| Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire |
| Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below |
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter, MENU | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing |
| Dialog | NEXT, YES, NO | unchanged. There is no "a choice is open" flag (`macros-wram.md`), and A and B both advance a plain box, so all three are dealt for every box — what it buys is the fly being able to answer *no* |
| 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 start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it |
| 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, move list | MOVE 1..4, BACK | as above |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | 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, move list | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands |
| 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 |
@ -1031,7 +1120,9 @@ measured where it cannot.
| cause | closed by |
| --- | --- |
| a scene that binds nothing at all | the table above: every playable scene has at least one unconditional button (`MENU` on the overworld, `NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC) |
| a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below |
| an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it |
| an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it |
| an overworld where the talked, reached and blocked ledgers exclude every person, object and frontier tile, every route leads somewhere visited, and there is no objective hop | **fixed**: `ways`' last resort. With *nothing else on this map worth walking to* (`palette::stranded`), `GO ROUTE` offers the exit toward the objective and, failing that, all of them — **ignoring the blocked window**, because a target the ledger is resting is still the only place to go. It is a last resort and not a tier: with anything else on the pad it stays off, because "all of them, nearest" once per hold is row 2's own loop |
| the frontier is empty because `path::frontier` answers about the ten-by-nine walkable window, while most of the map is unstood | **fixed**: `frontier_aims` falls back to the nearest tile of the *whole* map the stood ledger has no entry for. A fallback and not the rule, because the windowed answer is the correct one whenever it has anything in it |
| a running macro aborts and the pad is not re-dealt until the next frame | not a cause: `observe` runs after every frame and the decoder is handed the bound channels again on the very next one |

View file

@ -1589,3 +1589,181 @@ state's `runs/` directory.
- `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.
## 2026-09-22, rows 44 to 47: the screen the pad opens and closes again, and a menu read row-major
Thirty-one minutes after v0.4.3 deployed, the release watchdog flagged the next rung. Rank 10
(PEWTER CITY, next the BOULDER BADGE), the fly inside a Pewter building, and since the restart the
macro starts were `MENU` **82**, `BACK` **82** and `GO FRONTIER` 8, with the event log alternating
```
MENU start, MENU done, BACK start, BACK done
```
on map `0x35`. It is section 12.10's pair again -- two buttons that undo each other with nothing
else changing -- with the difference that the two are on **different scenes**, so no per-pad rule
could see it: `MENU` is on the overworld and `BACK` is on the start menu that `MENU` opens.
`docs/design/macros.md` section 12.11 is the contract this closed against.
### Which building, and why its pad was two buttons
Reproduced from the release container's own checkpoint with the real cartridge,
`examples/scene_probe.rs`:
```
- rank 10 (PEWTER CITY), badges 0, unique tiles 2132
- player: Player { map: 53, x: 3, y: 3, facing: Left }
- objective: Objective { map: 54, target: Some(Person) }
- map size: MapSize { width: 14, height: 8 }
- warps: [Warp { x: 7, y: 7, destination_warp: 4, destination_map: 52 }]
- signs: [Sign { x: 11, y: 2 }, Sign { x: 2, y: 5 }]
- people and objects: slot 1 picture 0x04 at (2, 7), slot 2 picture 0x25 at (0, 5),
slot 3 picture 0x20 at (7, 5)
- the pad: ["GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
- the map grid: 14x8 walkable 81 reachable 81 unstood 4 unknown 0
- `next_hop(Region { map: 53, part: 0 }, 0x36)` = None, neighbours []
- `ways(Exit)` = [], `ways(Passage)` = [Warp(0)], `ways(Route)` = []
- `objective_goals` = [], `objective_targets` = []
```
Map `0x35` is **the upper floor of the Pewter museum** -- `0x34` is its ground floor, `0x36` the
gym, `0x38` the mart, `0x3a` the centre -- fourteen blocks by eight, one staircase down at (7, 7),
two exhibit signs and three people. Every candidate list on it empties, which is why the live pad
was `MENU` and an occasional `GO FRONTIER`:
| button | why it was not there |
| --- | --- |
| `GO OBJECTIVE` | the objective is right -- map `0x36` with a **person** on it, which is rung 11's gym leader -- but `geography` has no row for the museum, so `next_hop` from map 53 answers `None` with no neighbours, and `objective_goals` is empty |
| `GO OUT` | the museum's upper floor has **no exit-class warp at all**: its one way out is a staircase, which is a `Passage` |
| `GO WARP` | on the pad at a fresh restore, and off it live: `ways(Passage)`' tier 3 is `unexcluded_exits`, which the blocked window empties for ten brain minutes after a refused walk |
| `GO ITEM`, `GO NPC` | the two signs and three exhibits are **reached**, and a reached target is retired for the session (12.1) |
| `TALK` | the fly at (3, 3) facing Left has nothing in front of it |
| `GO SHOP`, `GO HEAL` | `geography::area_of` has no row for the museum, so the area's errands cannot be aimed at from inside it |
| `GO FRONTIER` | on the pad while any of the four unstood tiles is outside the blocked window, which is the 8 starts in thirty minutes |
### The survey that named the second trap
`THROW BALL` was **63 starts and 63 `blocked`** in v0.4.3's own after-arm, mean sixty-nine frames,
and the first reading of that -- the bag had not finished drawing when the step read the cursor --
is true and is not the whole of it. Instrumenting *where* a macro reports `blocked` from the rung-9
checkpoint answered `battle/party` and `battle/between-turns`, never `battle/bag`, and a frame dump
of the menu bytes across one `THROW BALL` says why:
```
DUMP 21 topy=14 topx=9 cur=1 max=1 watch=0x11 sub=main (the cursor walked DOWN)
DUMP 35 topy=14 topx=15 cur=1 max=1 watch=0x21 sub=main (then RIGHT)
DUMP 52 topy=14 topx=15 cur=0 max=1 watch=0x21 sub=main (then UP: the step's target)
DUMP 55 topy=14 topx=15 cur=2 max=1 watch=0x21 sub=main (A: the game adds 2 for the column)
DUMP 60 topy=1 topx=0 cur=0 max=0 watch=0x03 sub=party list=0x02
```
The A press at the right column's first row opens the **party list**. Red's battle menu is two
*columns* -- the screen reads `FIGHT PKMN` over `ITEM RUN` -- and `wCurrentMenuItem` is the row
inside the column the cursor is in, with two added for the right column on selection. So the
game's order is **FIGHT, ITEM, PKMN, RUN**, and `macros::cartridge::battle_entry` had `PKMN` 1 and
`ITEM` 2: the row-major reading of the picture. Every macro that meant to open the bag opened the
party list and every macro that meant to open the party list opened the bag, for as long as the
four constants have existed. The fake's own two-by-two moved row-major too, which is why no unit
test could have caught it.
### What changed, all of it inside the macros
- **`MENU` is on no pad.** Not narrowed: nothing in the vocabulary uses the start menu except as a
scene to leave, so there is nothing behind the button. It stays a type, a population, a tag and a
script -- thirty-one channels, the roles and `--print-compatibility` do not move -- and the start
menu is still the fly's to open with the raw START button.
- **`ways` gains a room's last resort**, the one `GO ROUTE` has had outdoors since 13.1: with
nothing else on this map worth walking to (`palette::stranded`), the exits of that kind come back
ignoring the blocked window, the one toward the objective preferred.
- **A cursor step waits for the list it was built for.** A `Listing` says which list it is and a
step says which list its target indexes into; a step whose list is not up waits rather than
pressing at the list it has already answered, and takes its order and budget from that list on
the first frame it accepts input.
- **`battle_entry`'s `ITEM` and `PKMN` swap**, and the fake's geometry becomes column-major.
- **`BACK` on the move list only where the battler reads**, else `MOVE 1` alone.
| # | trap | trigger | test | fix, or why it is left |
| ---: | --- | --- | --- | --- |
| 44 | `MENU` opens the start menu and that scene's `BACK` closes it again: a pair split across two scenes, and on a map where every other list has emptied it is the whole pad | thirty brain minutes on map `0x35`, `MENU` 82 starts and `BACK` 82; on v0.4.3 `MENU` is dealt on every overworld map | `menu_is_on_no_scenes_pad`, `the_overworld_plan_never_truncates_the_frontier_away`, `the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint` (ROM-gated: it **fails on v0.4.3** with `MENU` on the pad of maps 2, 52, 53, 54, 55, 57, 58 and 147 starts) | **fixed**: `MENU` is off every scene's set. A macro whose precondition holds wherever the fly stands and whose effect a neighbouring scene undoes is section 12.2's trap, and the start menu has nothing in it for the fly |
| 45 | the overworld's never-empty guarantee *was* `MENU`, so taking it off could strand a room -- and the museum's one way out is a passage the blocked window rests | the same thirty minutes: `ways(Passage)` empty, `ways(Exit)` empty because there is no exit-class warp at all | `a_room_whose_only_way_out_the_ledger_rests_still_offers_it`, `an_overworld_pad_is_never_one_button_that_undoes_itself`, `no_playable_scene_and_no_sub_state_deals_an_empty_pad` | **fixed**: `ways`' last resort covers a room as well as a route. A map with **no way out at all** is a genuinely empty pad, is asserted as such, and is reported by `game.padEmptyMs` -- no map in Red is that shape |
| 46 | Red's battle menu is two columns, so `battle_entry`'s row-major `PKMN` 1 / `ITEM` 2 sent `ITEM` and `THROW BALL` to the party list and `SWITCH` to the bag | every `THROW BALL`, `ITEM` and `SWITCH` ever started: 63 starts and 63 `blocked` in v0.4.3's after-arm, `SWITCH` 15 of them | `the_battle_menus_two_columns_put_item_under_fight_and_pkmn_beside_it`, `throw_ball_waits_for_the_bag_rather_than_reading_the_menu_it_came_from`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated, now asserting `THROW BALL` never blocks) | **fixed**: the order is FIGHT, ITEM, PKMN, RUN, surveyed byte by byte. ROM-gated from the rung-9 checkpoint: `THROW BALL` 15 starts and 0 blocked, `SWITCH` 17 and 0, against 6 of 6 and 15 before |
| 47 | a move list whose battler the seam cannot read binds no `MOVE n`, so its pad is `BACK` alone -- which closes the list `MOVE 1` underneath had just opened | `BACK` was 263 of 797 macro starts in v0.4.3's after-arm, every one over an open move list | `the_move_list_deals_back_only_where_the_moves_can_be_read` | **fixed**: `BACK` is dealt on the move list only while `wBattleMon*` reads; otherwise `MOVE 1` alone, whose script confirms where the cursor stands, which is the press that ends a turn |
### The ROM-gated runs, before and after
From the rung-10 checkpoint, thirty-three brain minutes of the game-blind rotation, v0.4.3
(`928d66b`) against this branch:
| measure | before (v0.4.3) | after |
| --- | ---: | ---: |
| maps whose overworld pad dealt `MENU` | **7** (2, 52, 53, 54, 55, 57, 58) | **0** |
| `MENU` starts | **147** | **0** |
| overworld pads that were empty | 0 | 0 |
| frame the fly left map `0x35` | (it left, then came back) | **182** |
From the rung-9 forest checkpoint, sixty-seven brain minutes, the same two arms:
| measure | before (v0.4.3) | after |
| --- | ---: | ---: |
| `THROW BALL` starts / blocked | 6 / **6** | 15 / **0** |
| `SWITCH` starts / blocked | 17 / **15** | 17 / **0** |
| `RUN` blocked | 6 | 0 |
| battles entered / ended | 8 / 8 | 4 / 3 (one still running at the budget) |
| worst battle, in macros | 275 | 450 |
The worst battle grows because a fly whose `SWITCH` and `ITEM` reach their own lists spends turns
switching and healing instead of only attacking. The claim the assertion carries is unchanged and
still holds: a battle **ends**, on a bounded number of macros.
### The trap hunt, before and after -- and why it says nothing about this trap
Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge,
from the release container's own rung-10 checkpoint, driven by the brain.
| measure | before (v0.4.3) | after |
| --- | ---: | ---: |
| distinct (map, tile) | 175 | 175 |
| windows flagged | 73/73 | 73/73 |
| macros started | 1413 | 1413 |
| `MENU` starts | **0** | **0** |
| `THROW BALL` blocked | 0 (never started) | 0 (never started) |
| frames in `dialog` | 63,668 | 63,668 |
**The two arms are identical, and that is the honest result rather than a null one.** The reached
and blocked ledgers are session state that a restore clears (12.1), so a restored fly is not in the
state the loop needed: it walks out of the museum's upper floor in one `GO WARP` and never presses
`MENU` at all, so removing a channel the decoder never picked changes nothing downstream. The trap
hunt cannot reproduce a ledger-built loop from a checkpoint, and this is the first review where
that has mattered; the proof for rows 44 and 45 is the pad rules and the ROM-gated run above.
What the hunt *does* reproduce, from this checkpoint, is **row 41** -- the Pokémon Center nurse's
box -- at full scale: 62,804 of 71,673 frames are one text box on **one tile** of map `0x3a` at
(3, 3), with `YES` **1,278** of 1,295 macro starts and the run ending there. That is the next trap
and it now has a checkpoint of its own.
### Residuals, named rather than worked around
- **Row 41 is reproduced and is the next brief.** 1,295 of 1,413 macro starts in both arms are
`YES` at the nurse's counter, on one tile, for the last eighteen brain minutes of the run.
- **`MOVE n` reports `blocked` 890 times in 1,431 macros** in the forest ROM run, every one of them
with the move list drawn and its cursor *placeable* but not accepting input: no press moves the
cursor and the step spends its budget (`reason=budget target=2 max=3 kind=BattleMoves here=1`,
535 of them). That is row 30b's unplaceable cursor inverted, it is unchanged from v0.4.3 (747
blocked in 1,260), and the honest fix is a WRAM reading rather than a pad change.
- **The museum is not in `geography`**, which is why `GO OBJECTIVE`, `GO SHOP` and `GO HEAL` are
all off the pad inside it. Adding the row would give the fly the road to the gym from indoors;
this branch did not, because the map graph is a data change with its own survey (12.7) and the
way out now stands on its own.
- **A map with no way out at all deals an empty pad.** No map in Red is that shape; it is asserted
and reported rather than covered.
### 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.3 on this box (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,
v0.4.2 and v0.4.3. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -107,8 +107,24 @@ pub const CHEAPEST_PURCHASE: u32 = {
/// 1 PKMN, 2 ITEM, 3 RUN".
pub mod battle_entry {
pub const FIGHT: u8 = 0;
pub const PKMN: u8 = 1;
pub const ITEM: u8 = 2;
/// `$01`. **The left column's second row, not the right column's first.**
///
/// Red draws the battle menu as `FIGHT PKMN` over `ITEM RUN`, which reads as two rows -- and
/// the game's own index is two *columns*: `wCurrentMenuItem` is the row inside the column the
/// cursor is in and selection adds two for the right one. So the order is FIGHT, `ITEM`,
/// `PKMN`, RUN, and this pair was the other way round for as long as the four constants have
/// existed.
///
/// **Measured on the cartridge** (2026-09-22, `infra/docs/macros-traps.md`): a `THROW BALL`
/// aiming at 2 walked the cursor to `wTopMenuItemX` 15, `wCurrentMenuItem` 0, pressed A, and
/// the **party list** opened -- `wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02`.
/// The frame after the press the game wrote `wCurrentMenuItem` 2, which is the right column's
/// first row plus two, and the right column's first row is PKMN. So `THROW BALL` and `ITEM`
/// opened the party list and `SWITCH` opened the bag, every single time: `THROW BALL` was 63
/// starts and 63 `blocked` on v0.4.3, and `SWITCH` 15 of them.
pub const ITEM: u8 = 1;
/// `$02`. The right column's first row: see [`ITEM`].
pub const PKMN: u8 = 2;
pub const RUN: u8 = 3;
}
@ -272,12 +288,44 @@ pub enum TargetKey {
Tile(Tile),
}
/// A cursor the current scene's macros navigate: where it is, and how far it can go.
/// Which list the shared cursor belongs to right now.
///
/// Red keeps one cursor for every menu in the game (`wCurrentMenuItem`), so "where is the cursor"
/// is only half a question: a script that opens the bag from the battle menu and then navigates to
/// a bag index has to know that the index it is aiming at belongs to the *bag* and not to the four
/// entries it was reading a moment ago. Measured on the cartridge 2026-09-22: `THROW BALL` was
/// **63 starts and 63 `blocked`**, mean sixty-nine frames, because the bag takes longer than the
/// twenty settle frames to draw -- so the step that should have walked the bag list read the
/// battle menu's `max` of 3, found the ball's bag index above it, and gave up at once
/// (`docs/design/macros.md` section 12.11).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListKind {
/// FIGHT / PKMN / ITEM / RUN.
BattleMain,
/// The move list.
BattleMoves,
/// The party list, inside a battle.
BattleParty,
/// The bag, inside a battle.
BattleBag,
/// The start menu.
StartMenu,
/// A mart's counter, on whichever of its screens is up.
Shop,
/// A PC.
Pc,
}
/// A cursor the current scene's macros navigate: which list it is, where it is, and how far it can
/// go.
///
/// Derived from whichever of agent A's menus is up, so a script asks "where is the cursor" once
/// and does not care whether it is in a battle, a mart or the start menu.
/// and does not care whether it is in a battle, a mart or the start menu -- but it can ask *which*
/// list answered, which is what a script that crosses from one list into another needs
/// ([`ListKind`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Listing {
pub kind: ListKind,
pub current: u8,
pub max: u8,
}

View file

@ -25,7 +25,8 @@ use crate::adapter::MemoryReader;
use crate::emulator::buttons;
use super::cartridge::{
FACINGS, MacroState, TalkTarget, TargetKey, Tile, battle_entry, button, item, opposite,
FACINGS, ListKind, MacroState, TalkTarget, TargetKey, Tile, battle_entry, button, item,
opposite,
};
use super::geography::Amenity;
use super::palette::{
@ -411,14 +412,37 @@ struct Walk {
struct Cursor {
target: u8,
confirm: bool,
/// The directions to try, in order, aimed at the target by the first comparison.
order: [Facing; 4],
/// Which list `target` indexes into, when the script knows -- and it always does when it has
/// just pressed A to open one.
///
/// `None` navigates whatever list is up, which is what a step that does not cross a boundary
/// wants: the shop's own screens are one [`ListKind`] and the step after a purchase's A press
/// is still the counter's.
///
/// While the list up is a *different* one, the step waits rather than pressing at it, exactly
/// as it waits for a list that reports no cursor at all (section 4: "never by counting
/// presses"). Twenty settle frames is not always enough for the cartridge to draw the next
/// list, and a step that reads the list it has already answered is pressing blind: it takes
/// its length and its direction from four entries that are not the ones it is walking.
///
/// Section 12.11 is where this came from, and it is also what *found* the reason `THROW BALL`
/// was 63 starts and 63 `blocked`: the step began reporting which list it had been left
/// looking at, and the answer was never the bag -- it was the party list, because
/// `battle_entry`'s `ITEM` and `PKMN` were the other way round. That is fixed at the
/// constants; this rule stands on its own.
want: Option<ListKind>,
/// The directions to try, in order, aimed at the target from where the cursor actually is.
///
/// `None` until the list this step is for is accepting input, because "which way is the
/// target" is a fact about that list and not about whatever was up when the script was built.
order: Option<[Facing; 4]>,
/// Which of them is being tried now.
at: u8,
/// The cursor value the current pulse started from.
before: u8,
/// Presses left in the budget.
left: u8,
/// Presses left in the budget, `None` until the list is up: twice the list plus slack, and
/// the list whose length that is has to be the one being walked.
left: Option<u8>,
phase: u32,
/// Frames spent waiting for the list to accept input.
waited: u32,
@ -1370,9 +1394,19 @@ fn goal_tile(walk: &Walk) -> Tile {
/// which is what section 4 means by "never by counting presses": a list that is a column moves
/// under DOWN and UP, Red's two-by-two battle menu also needs RIGHT and LEFT, and this finds out
/// which by watching rather than by knowing. A list that is not accepting input yet is *waited*
/// for, never pressed at.
/// for, never pressed at -- and since section 12.11 so is a list that is up but is **not the one
/// this step is walking**, because Red keeps one cursor for every menu in the game and reading the
/// wrong one is not reading.
fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
let Some(list) = listing(state) else {
// A confirming press that has begun finishes, and it is read before the list: the press is
// what *answers* the list, so on the very frames it is being issued the cartridge is already
// drawing the next one, and waiting for the list this step walked would wait for a list the
// step has just left.
if cursor.confirmed {
return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP);
}
let list = listing(state).filter(|list| cursor.want.is_none_or(|want| want == list.kind));
let Some(list) = list else {
cursor.waited += 1;
return if cursor.waited > CURSOR_WAIT {
Progress::Blocked
@ -1381,9 +1415,6 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
};
};
let here = list.current;
if cursor.confirmed {
return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP);
}
if here == cursor.target {
if !cursor.confirm {
return Progress::Next;
@ -1392,18 +1423,25 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
cursor.phase = 0;
return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP);
}
if cursor.left == 0 || cursor.target > list.max {
// The first frame the list this step is for is accepting input is where the navigation is
// aimed from and budgeted by. Both are facts about *this* list, and a script that opened it
// could not have known either when it was built.
let aimed = aim_order(cursor.target, here);
let order = *cursor.order.get_or_insert(aimed);
let sized = list.max.saturating_add(1).saturating_mul(2).saturating_add(CURSOR_SLACK);
let budget = *cursor.left.get_or_insert(sized);
if budget == 0 || cursor.target > list.max {
return Progress::Blocked;
}
if cursor.phase == 0 {
cursor.before = here;
}
let facing = cursor.order[usize::from(cursor.at) % cursor.order.len()];
let facing = order[usize::from(cursor.at) % order.len()];
match pulse(button(facing), &mut cursor.phase, PRESS_HOLD, PRESS_GAP) {
Progress::Next => {
let closer = here.abs_diff(cursor.target) < cursor.before.abs_diff(cursor.target);
cursor.at = if closer { 0 } else { (cursor.at + 1) % 4 };
cursor.left -= 1;
cursor.left = Some(budget.saturating_sub(1));
cursor.phase = 0;
Progress::Hold(buttons::NONE)
}
@ -1411,6 +1449,19 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
}
}
/// The directions a cursor tries, in order, to get from `here` to `target`.
///
/// A column moves under DOWN and UP; Red's two-by-two battle menu needs RIGHT and LEFT as well,
/// and which of the four works is found by watching the cursor rather than by knowing the
/// geometry, so this only decides which to try *first*.
const fn aim_order(target: u8, here: u8) -> [Facing; 4] {
if target > here {
[Facing::Down, Facing::Right, Facing::Up, Facing::Left]
} else {
[Facing::Up, Facing::Left, Facing::Down, Facing::Right]
}
}
/// Build the script for one macro, or `None` when there is nothing to walk to.
///
/// Every target is computed here, once, from the state the macro started in — which move, which
@ -1520,7 +1571,7 @@ fn script(
} else {
cursor_at.filter(|at| *at < count)?
};
steps.push(cursor(state, target, true));
steps.push(cursor_on(target, true, Some(ListKind::BattleMoves)));
return Some((steps.into(), aimed));
}
// From the menu above, FIGHT has to be chosen first, whether or not anything has PP:
@ -1529,7 +1580,7 @@ fn script(
if !in_main_menu(state) {
return None;
}
steps.push(cursor(state, battle_entry::FIGHT, true));
steps.push(cursor_on(battle_entry::FIGHT, true, Some(ListKind::BattleMain)));
steps.push(settle());
// What happens after FIGHT is the cartridge's own answer and it is measured rather
// than assumed (row 34): with a move that has PP the list opens and this slot is
@ -1538,7 +1589,7 @@ fn script(
// second cursor step would press A at text. `MOVE 1` is the button that reaches that
// state, because it is the only one bound there.
if slot_has_pp(state, slot) {
steps.push(cursor(state, slot, true));
steps.push(cursor_on(slot, true, Some(ListKind::BattleMoves)));
}
steps
}
@ -1550,10 +1601,13 @@ fn script(
let bag_slot = throw_slot(state)?;
let mut steps = Vec::new();
if in_main_menu(state) {
steps.push(cursor(state, battle_entry::ITEM, true));
steps.push(cursor_on(battle_entry::ITEM, true, Some(ListKind::BattleMain)));
steps.push(settle());
}
steps.push(cursor(state, bag_slot, true));
// The bag, and it has to *be* the bag: this is the step that reported `blocked` 63
// times out of 63 on the cartridge while it was allowed to read the battle menu's
// cursor instead (section 12.11).
steps.push(cursor_on(bag_slot, true, Some(ListKind::BattleBag)));
steps
}
MacroKind::Switch => {
@ -1562,10 +1616,10 @@ fn script(
// A forced switch is already looking at the party list; a chosen switch has to get
// there through the battle menu's PKMN entry first.
if in_main_menu(state) {
steps.push(cursor(state, battle_entry::PKMN, true));
steps.push(cursor_on(battle_entry::PKMN, true, Some(ListKind::BattleMain)));
steps.push(settle());
}
steps.push(cursor(state, slot, true));
steps.push(cursor_on(slot, true, Some(ListKind::BattleParty)));
steps.push(settle());
// The party entry's action list opens on SWITCH.
steps.push(press(buttons::A));
@ -1576,17 +1630,19 @@ fn script(
let active = state.battle().and_then(|battle| battle.own).map(|mon| mon.slot)?;
let mut steps = Vec::new();
if in_main_menu(state) {
steps.push(cursor(state, battle_entry::ITEM, true));
steps.push(cursor_on(battle_entry::ITEM, true, Some(ListKind::BattleMain)));
steps.push(settle());
}
steps.push(cursor(state, bag_slot, true));
steps.push(cursor_on(bag_slot, true, Some(ListKind::BattleBag)));
steps.push(settle());
// Which Pokémon to heal: the one that is out, because that is the HP the precondition
// measured.
steps.push(cursor(state, active, true));
steps.push(cursor_on(active, true, Some(ListKind::BattleParty)));
steps
}
MacroKind::Run => vec![cursor(state, battle_entry::RUN, true)],
MacroKind::Run => {
vec![cursor_on(battle_entry::RUN, true, Some(ListKind::BattleMain))]
}
MacroKind::BuyPotion => shop_plan(state, item::POTION)?,
MacroKind::BuyBall => shop_plan(state, item::POKE_BALL)?,
MacroKind::BuyAntidote => shop_plan(state, item::ANTIDOTE)?,
@ -1675,24 +1731,31 @@ fn settle() -> Step {
Step::Settle { phase: 0 }
}
/// A cursor step aimed at `target`, with its press order and budget taken from the list that is
/// up right now.
fn cursor(state: &mut dyn MacroState, target: u8, confirm: bool) -> Step {
let list = listing(state);
let from = list.map_or(0, |list| list.current);
let max = list.map_or(target, |list| list.max);
/// A cursor step aimed at `target` in whichever list is accepting input when it runs.
///
/// For a step that does not cross from one list into another: the shop's own screens, and the
/// first step of any script, which reads the list its macro was dealt on.
fn cursor(target: u8, confirm: bool) -> Step {
cursor_on(target, confirm, None)
}
/// A cursor step aimed at `target` in `want`, waiting for that list rather than pressing at
/// whichever one happens to be up.
///
/// The press order and the budget are taken from the list on the first frame it accepts input
/// ([`cursor_frame`]) and not from here, because a script that has just pressed A to open a list
/// is still reading the list it pressed A *in*: twenty settle frames is not always enough for the
/// cartridge to draw the next one, and the one it has is the wrong length and points the wrong
/// way (section 12.11).
fn cursor_on(target: u8, confirm: bool, want: Option<ListKind>) -> Step {
Step::Cursor(Cursor {
target,
confirm,
order: if target > from {
[Facing::Down, Facing::Right, Facing::Up, Facing::Left]
} else {
[Facing::Up, Facing::Left, Facing::Down, Facing::Right]
},
want,
order: None,
at: 0,
before: from,
// Twice the list, plus slack for a press the game swallows while a menu draws.
left: max.saturating_add(1).saturating_mul(2).saturating_add(CURSOR_SLACK),
before: target,
left: None,
phase: 0,
waited: 0,
confirmed: false,
@ -1902,10 +1965,10 @@ fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option<Vec<Step>> {
let mut steps = Vec::new();
if shop_screen(state)? == ShopScreen::BuySellQuit {
// BUY is the counter menu's first entry.
steps.push(cursor(state, 0, true));
steps.push(cursor(0, true));
steps.push(settle());
}
steps.push(cursor(state, index, true));
steps.push(cursor(index, true));
steps.push(settle());
// The quantity prompt opens on one, and the price confirmation opens on YES.
steps.push(press(buttons::A));

View file

@ -10,8 +10,8 @@
use super::cartridge::{
CHEAPEST_PURCHASE, FACINGS, opposite,
ExitId, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget, TargetKey,
Tile, item, outdoors,
ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget,
TargetKey, Tile, item, outdoors,
};
use crate::adapter::PlaceKind;
@ -484,8 +484,18 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
Scene::Menu => vec![Close, Confirm, Back],
// Section 9.1's split, plus section 13's errands and centre. Indoors the ways out of a
// room are the building's door and its passages; outdoors there is no building to leave.
// `MENU` is unconditional and last, which is what makes an empty overworld pad impossible
// (section 13.1, row 18).
//
// **`MENU` is on no pad** (section 12.11). It was here as the unconditional button that
// made an empty overworld pad impossible, and that is exactly what made it a trap: opening
// the start menu changes nothing in the world, so the macro completes where the fly stands
// -- section 12.2's rule -- and the scene it opens deals `CLOSE` and `BACK`, which close it
// again. Live on rung 10, thirty minutes inside the Pewter museum's upper floor: `MENU` 82
// starts, `BACK` 82, `GO FRONTIER` 8, the log alternating `MENU start/done, BACK
// start/done`. Nothing in the macro vocabulary uses the start menu for anything -- there is
// no SAVE macro and no POKéDEX macro -- so there is nothing behind the button worth
// pressing it for. What keeps this pad from being empty instead is the way out, which
// [`ways`] offers regardless of the ledgers when nothing else on the map is worth walking
// to.
Scene::Overworld => {
let mut set = vec![GoObjective];
if outdoors_now(state) {
@ -497,7 +507,7 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
if inside_center(state) {
set.push(Heal);
}
set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk, Menu]);
set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk]);
set
}
// Section 12: "Forced switch: SWITCH" -- plus the press that advances a battle, because
@ -516,7 +526,20 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
// 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.
Scene::Battle { own_turn: true, .. } => match battle_menu(state) {
BattleMenu::Moves { .. } => vec![Move1, Move2, Move3, Move4, Back],
// The move list. `BACK` is a button here because there is a list to leave (12.9) --
// but only while the moves can be *read*: a battler the seam cannot place leaves all
// four `MOVE n` unbound, and a pad of `BACK` alone closes the list that `MOVE 1` on
// the menu underneath had just opened. That is 12.10's pair again, with `MOVE 1` in
// `NEXT`'s place. With nothing readable the pad is `MOVE 1` alone and its script
// confirms wherever the cursor stands, which is the press that ends the turn
// (section 12.11).
BattleMenu::Moves { .. } => {
if state.battle().and_then(|battle| battle.own).is_some() {
vec![Move1, Move2, Move3, Move4, Back]
} else {
vec![Move1]
}
}
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`).
@ -579,7 +602,15 @@ pub fn precondition(kind: MacroKind, state: &mut dyn MacroState) -> bool {
// (only when facing something untalked)"). A tile ahead with nothing on it is not a
// reason to press A, and a shelf that has been read is not a reason to read it again.
MacroKind::Talk => facing_untalked(state),
// Unconditional: opening the start menu is always available.
// The start menu opens from anywhere, and that is exactly why `MENU` is on no pad:
// "the precondition is satisfied wherever the fly stands" is section 12.2's trap, and a
// macro whose whole effect is a screen its own scene's `BACK` closes again is 12.10's
// pair one scene wider (section 12.11). The refusal belongs at the **dealer** and not
// here, because this arm is a true fact about the macro and [`scene_set`] is where the
// reason lives: nothing in the vocabulary uses the start menu, so there is nothing behind
// the button to press it for -- and the day a SAVE macro exists, one row changes.
// `MENU` stays a type, a population, a tag and a script, so the roles, the channel order
// and `--print-compatibility` are untouched.
MacroKind::Menu => true,
// Advancing text, answering a prompt and backing out never need anything.
MacroKind::Next
@ -985,7 +1016,17 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
let all = unexcluded_exits(state, way);
// Tier 3, and only where a map would otherwise be impossible to leave: see the doc comment.
match way {
Way::Exit => all,
// A room still has to be leavable, and since section 12.11 that holds even while the
// ledgers are resting its one door: `unexcluded_exits` is emptied by the blocked window,
// by the rung's own target being on this map (row 29) and by an unfaced counter, and with
// `MENU` off the pad an emptied way out is an overworld with nothing on it at all.
Way::Exit => {
if all.is_empty() {
last_resort(state, way)
} else {
all
}
}
// A staircase the run has already been up is exploration already done, and the same bounce
// `GO ROUTE` had: up, straight back down, up again, once per hold. The exception is the map
// whose only way anywhere *is* a passage -- Red's bedroom, the upper floor of any house --
@ -993,6 +1034,8 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
Way::Passage => {
if path::exits(state).iter().any(|exit| exit.way == Way::Exit) {
Vec::new()
} else if all.is_empty() {
last_resort(state, way)
} else {
all
}
@ -1010,19 +1053,31 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
//
// It also ignores the blocked window, which nothing else does: a target the ledger is
// resting is still the only place to go.
Way::Route => {
if stranded(state) {
let every: Vec<Exit> =
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
let toward = toward_objective(state, &every);
if toward.is_empty() { every } else { toward }
} else {
Vec::new()
}
}
Way::Route => last_resort(state, way),
}
}
/// Every way out of this kind, ignoring the ledgers, when the map offers nothing else at all.
///
/// Section 13.1's never-empty rule, and since section 12.11 it is what the rule *rests* on: the
/// overworld has no unconditional button any more, so the pad of a map with every ledger against it
/// is this list or nothing. Guarded by [`stranded`], which is built out of [`exit_tiers`] rather
/// than [`ways`] so that asking "is the fly stranded" cannot recurse into the answer it decides.
///
/// It ignores the blocked window, which nothing else does: a target the ledger is resting is still
/// the only place to go. And it is a last resort rather than a tier -- with anything else on the
/// pad it stays off, because "all of them, nearest" once per hold is row 2's own loop, measured as
/// two hours seventeen in and out of one house door.
fn last_resort(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
if !stranded(state) {
return Vec::new();
}
let every: Vec<Exit> =
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
let toward = toward_objective(state, &every);
if toward.is_empty() { every } else { toward }
}
/// The exits of the current map of one kind that no ledger excludes.
fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
if !objective_targets(state).is_empty() {
@ -1053,8 +1108,10 @@ fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
/// Section 13.1's pad-empty audit. Deliberately built out of [`exit_tiers`] rather than [`ways`],
/// so that asking "is the fly stranded" cannot recurse into the fallback the answer decides.
///
/// `MENU` is unconditional, so an overworld pad is never literally empty; what this measures is
/// the state the operator saw on stream, where the pad has a button that cannot move the fly anywhere.
/// What this measures is the state the operator saw on stream: a map where nothing the pad offers
/// can move the fly anywhere. It used to be the weaker claim -- `MENU` was unconditional, so the
/// pad was never *literally* empty, only useless -- and since section 12.11 took `MENU` off the
/// overworld it is the literal one, which is why [`last_resort`] is what answers it.
pub fn stranded(state: &mut dyn MacroState) -> bool {
objective_goals(state).is_empty()
&& amenity_goals(state, Amenity::Mart).is_empty()
@ -1599,7 +1656,14 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) {
return true;
}
let Some(own) = battle.own else { return false };
// And the same backstop over an **open move list** whose battler the seam cannot read
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
// it reads no move either, and confirming a move is what ends a turn.
let Some(own) = battle.own else {
return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. });
};
let holds = |slot: usize| -> Option<&Move> {
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
};
@ -1671,28 +1735,42 @@ pub fn throw_slot(state: &mut dyn MacroState) -> Option<u8> {
pub fn listing(state: &mut dyn MacroState) -> Option<Listing> {
if let Some(battle) = state.battle() {
return match battle.menu {
BattleMenu::Main { cursor } => Some(Listing { current: cursor, max: 3 }),
BattleMenu::Moves { cursor: Some(cursor), count } => {
Some(Listing { current: cursor, max: count.saturating_sub(1) })
BattleMenu::Main { cursor } => {
Some(Listing { kind: ListKind::BattleMain, current: cursor, max: 3 })
}
BattleMenu::Moves { cursor: Some(cursor), count } => Some(Listing {
kind: ListKind::BattleMoves,
current: cursor,
max: count.saturating_sub(1),
}),
BattleMenu::Moves { cursor: None, .. } | BattleMenu::None => None,
BattleMenu::Bag { cursor, count } => {
(count > 0).then(|| Listing { current: cursor, max: count.saturating_sub(1) })
}
BattleMenu::Bag { cursor, count } => (count > 0).then(|| Listing {
kind: ListKind::BattleBag,
current: cursor,
max: count.saturating_sub(1),
}),
BattleMenu::Party { cursor } => {
let max = u8::try_from(state.party().mons.len().saturating_sub(1)).unwrap_or(0);
Some(Listing { current: cursor, max })
Some(Listing { kind: ListKind::BattleParty, current: cursor, max })
}
};
}
if let Some(menu) = state.start_menu() {
return Some(Listing { current: menu.cursor.current, max: menu.cursor.max });
return Some(Listing {
kind: ListKind::StartMenu,
current: menu.cursor.current,
max: menu.cursor.max,
});
}
if let Some(shop) = state.shop() {
return Some(Listing { current: shop.cursor.current, max: shop.cursor.max });
return Some(Listing {
kind: ListKind::Shop,
current: shop.cursor.current,
max: shop.cursor.max,
});
}
if let Some(pc) = state.pc() {
return Some(Listing { current: pc.cursor.current, max: pc.cursor.max });
return Some(Listing { kind: ListKind::Pc, current: pc.cursor.current, max: pc.cursor.max });
}
None
}

View file

@ -149,6 +149,15 @@ struct World {
start_to_open: u8,
/// What the next A presses open.
opens: VecDeque<Opens>,
/// Frames the cartridge spends *drawing* a list an A press opened, before it accepts input.
///
/// Zero everywhere but the one test this is for. On the cartridge it is not zero and it is not
/// bounded by the twenty frames a script's `settle` waits: measured 2026-09-22, `THROW BALL`
/// pressed ITEM, settled, and then read the battle *menu*'s cursor because the bag had not
/// drawn yet -- 63 starts, 63 `blocked` (section 12.11).
opens_draw_in: u32,
/// The list an A press opened and the frame it starts accepting input on.
pending: Option<(u32, Opens)>,
/// A scene the world switches to at this frame, for the abort rule.
switch: Option<(u32, Scene)>,
/// A frame at which the cartridge heals the party, which is what a Pokémon Center does while
@ -223,6 +232,8 @@ impl World {
b_to_close: 1,
start_to_open: 1,
opens: VecDeque::new(),
opens_draw_in: 0,
pending: None,
switch: None,
heal_at: None,
scripted: false,
@ -342,6 +353,15 @@ impl World {
fn frame(&mut self, mask: u8) {
self.frames += 1;
if let Some((at, next)) = self.pending
&& self.frames >= at
{
self.list = next.list;
self.cursor = next.cursor;
self.cursor_max = next.max;
self.grid = next.grid;
self.pending = None;
}
if let Some(at) = self.scripted_at
&& self.frames >= at
{
@ -411,10 +431,14 @@ impl World {
if mask == buttons::A
&& let Some(next) = self.opens.pop_front()
{
self.list = next.list;
self.cursor = next.cursor;
self.cursor_max = next.max;
self.grid = next.grid;
if self.opens_draw_in > 0 {
self.pending = Some((self.frames + self.opens_draw_in, next));
} else {
self.list = next.list;
self.cursor = next.cursor;
self.cursor_max = next.max;
self.grid = next.grid;
}
}
}
@ -424,12 +448,16 @@ impl World {
}
let here = i16::from(self.cursor);
let target = if self.grid {
// Two rows of two: up and down change row, left and right change column.
// Red's battle menu: two **columns** of two, indexed by column. `wCurrentMenuItem` is
// the row inside the column the cursor is in and selection adds two for the right
// column, so the order is FIGHT, ITEM, PKMN, RUN -- up and down move one inside a
// column, left and right move two across (surveyed 2026-09-22; the fake had it
// row-major, which is the same mistake `battle_entry` had).
match facing {
Facing::Down => here + 2,
Facing::Up => here - 2,
Facing::Right if here % 2 == 0 => here + 1,
Facing::Left if here % 2 == 1 => here - 1,
Facing::Down if here % 2 == 0 => here + 1,
Facing::Up if here % 2 == 1 => here - 1,
Facing::Right => here + 2,
Facing::Left => here - 2,
_ => here,
}
} else {
@ -685,7 +713,19 @@ fn run_with(
kind: MacroKind,
) -> Result<MacroAbort, MacroRefused> {
let (palette, slot) = pick(world, kind);
if let Err(refused) = machine.start(&palette, slot, world) {
drive(machine, &palette, slot, world)
}
/// [`run_with`], on a palette the caller dealt: start the slot, drive to the outcome, and do the
/// driver's own bookkeeping after it.
fn drive(
machine: &mut MacroMachine,
palette: &Palette,
slot: MacroId,
world: &mut World,
) -> Result<MacroAbort, MacroRefused> {
let kind = palette.slot(slot).expect("the caller dealt this slot").kind;
if let Err(refused) = machine.start(palette, slot, world) {
// The driver drains the ledgers after every `start`, refusal included: a `no route`
// refusal earns blocked entries and presses nothing, so nothing else would collect them
// (`PokemonPalette::start`).
@ -726,6 +766,19 @@ fn run_with(
Ok(machine.outcome().expect("a finished macro has an outcome").1)
}
/// A palette of exactly one button, for a script whose macro no scene binds any more.
///
/// `MENU` is the only one (section 12.11): it is still a type, a population, a tag and a script --
/// the roles and `--print-compatibility` depend on the type list -- and it is on no pad, so
/// [`pick`] cannot find it. Its script is exercised here rather than deleted, because what took it
/// off the pad is that the start menu has nothing in it for the fly and not that pressing START is
/// wrong.
fn forced(kind: MacroKind, world: &mut World) -> (Palette, MacroId) {
let mut slots = [None; super::palette::SLOTS];
slots[usize::from(kind.slot())] = Some(super::palette::MacroSpec::of(kind));
(Palette { scene: world.scene(), slots }, MacroId(kind.slot()))
}
fn pick(world: &mut World, kind: MacroKind) -> (Palette, MacroId) {
let scene = world.scene();
// The shipping mode is `PaletteMode::Plan` (`MacroMode::Macros` in `flysim::snapshot`), which
@ -781,7 +834,8 @@ fn the_indoor_overworld_row_is_section_nine_ones_row() {
let palette = Palette::for_scene(Scene::Overworld, &mut world);
assert_eq!(
names(&palette),
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK", "MENU"]
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK"],
"and no `MENU`, which section 12.11 took off this row"
);
}
@ -794,7 +848,7 @@ fn the_outdoor_overworld_row_has_the_route_and_no_passage_in_it() {
world.connections.south = true;
world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }];
let palette = Palette::for_scene(Scene::Overworld, &mut world);
assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER", "MENU"]);
assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER"]);
}
#[test]
@ -802,7 +856,7 @@ fn an_overworld_with_no_way_out_leaves_the_leaving_buttons_unbound() {
let mut world = World::room();
let palette = Palette::for_scene(Scene::Overworld, &mut world);
assert_eq!(palette.slot(MacroId(MacroKind::GoOut.slot())), None, "a sealed room binds no way out");
assert_eq!(names(&palette), ["GO FRONTIER", "MENU"], "and there is always ground to cover");
assert_eq!(names(&palette), ["GO FRONTIER"], "and there is always ground to cover");
}
#[test]
@ -1016,8 +1070,12 @@ fn a_move_button_confirms_its_own_slot_over_an_open_list() {
assert_eq!(world.cursor, 2, "the third slot, by watching where the cursor went");
assert_eq!(world.pulses.last(), Some(&buttons::A));
// And from the menu above, FIGHT first and then the slot.
// And from the menu above, FIGHT first and then the slot. The list has to actually open:
// since section 12.11 the step that walks it *waits* for the move list rather than reading
// whatever cursor is up, so a fixture where FIGHT opens nothing waits out `CURSOR_WAIT` --
// which is the right answer, and is what the cartridge was doing to `THROW BALL` in reverse.
let mut world = World::battle();
world.opens.push_back(Opens { list: List::Moves(3), cursor: 0, max: 2, grid: false });
assert_eq!(run(&mut world, MacroKind::Move2).unwrap(), MacroAbort::Done);
assert!(
world.pulses.contains(&buttons::A),
@ -1715,7 +1773,11 @@ fn go_frontier_is_unbound_once_every_reachable_tile_has_been_stood_on() {
fn menu_holds_start_until_the_start_menu_opens() {
let mut world = World::room();
world.start_to_open = 2;
assert_eq!(run(&mut world, MacroKind::Menu).unwrap(), MacroAbort::Done);
// Off every pad since section 12.11, so the palette is built by hand: the script is what is
// under test here and no scene offers the button any more.
let mut machine = MacroMachine::new(0x1234_5678);
let (palette, slot) = forced(MacroKind::Menu, &mut world);
assert_eq!(drive(&mut machine, &palette, slot, &mut world).unwrap(), MacroAbort::Done);
assert_eq!(world.pulses, vec![buttons::START, buttons::START]);
assert_eq!(world.scene, Scene::Menu);
}
@ -1724,7 +1786,9 @@ fn menu_holds_start_until_the_start_menu_opens() {
fn menu_reports_blocked_when_the_start_menu_never_opens() {
let mut world = World::room();
world.start_to_open = u8::MAX;
assert_eq!(run(&mut world, MacroKind::Menu).unwrap(), MacroAbort::Blocked);
let mut machine = MacroMachine::new(0x1234_5678);
let (palette, slot) = forced(MacroKind::Menu, &mut world);
assert_eq!(drive(&mut machine, &palette, slot, &mut world).unwrap(), MacroAbort::Blocked);
}
#[test]
@ -2110,6 +2174,10 @@ fn item_reaches_the_potion_by_reading_the_bags_cursor() {
let mut world = World::battle();
world.bag = vec![(item::POKE_BALL, 3), (item::POTION, 2)];
world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 1, grid: false });
// And confirming the potion opens the party list, which is where the script says which
// Pokémon to heal. Section 12.11: that step waits for the *party* list, so the fixture has to
// open it -- the cartridge does.
world.opens.push_back(Opens { list: List::BattleParty, cursor: 0, max: 2, grid: false });
assert_eq!(run(&mut world, MacroKind::Item).unwrap(), MacroAbort::Done);
assert!(
world.pulses.iter().filter(|mask| **mask == buttons::A).count() >= 2,
@ -2268,7 +2336,7 @@ fn the_indoor_pad_is_section_nine_ones_set() {
let mut world = populated();
assert_eq!(
plan(&mut world),
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"],
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"],
"no objective is known, so `GO OBJECTIVE` is not on the pad"
);
}
@ -2277,7 +2345,7 @@ fn the_indoor_pad_is_section_nine_ones_set() {
fn the_outdoor_pad_has_the_route_and_no_passage_in_it() {
let mut world = populated();
world.map = 0x00;
assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]);
assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER"]);
}
#[test]
@ -2304,7 +2372,7 @@ fn a_rung_on_another_floor_of_this_building_is_reached_through_the_passage() {
assert!(plan::passage_to_objective(&mut world));
assert_eq!(
plan(&mut world),
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
);
// And the objective's own goal is the staircase, because that is the warp that names it.
let goals: Vec<Tile> = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect();
@ -2324,14 +2392,14 @@ fn an_objective_on_this_map_with_no_finer_place_falls_through() {
// *ledger* half of that fix (`ways`' tiers), and it is unchanged.
assert_eq!(
plan(&mut world),
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
);
// A rung the catalog does know a tile for is walked to instead.
world.objective = Some(Objective { map: world.map, tile: Some(Tile::new(6, 3)), warp: None, edge: None, target: None });
assert_eq!(
plan(&mut world),
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
);
let aims = objective_goals(&mut world);
assert_eq!(aims.iter().map(|aim| (aim.tile, aim.press)).collect::<Vec<_>>(), vec![
@ -2351,14 +2419,14 @@ fn a_thing_this_session_has_talked_to_leaves_the_plan() {
world.talked.insert(TalkTarget::Sprite(1));
assert_eq!(
plan(&mut world),
["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER", "MENU"],
["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER"],
"the person drops out and the object stays"
);
assert!(!precondition(MacroKind::GoNpc, &mut world), "and the button is gone");
// The object is the other half, keyed by its own sprite slot.
world.talked.insert(TalkTarget::Sprite(2));
assert_eq!(plan(&mut world), ["GO OUT", "GO WARP", "GO FRONTIER", "MENU"]);
assert_eq!(plan(&mut world), ["GO OUT", "GO WARP", "GO FRONTIER"]);
// Palette mode reads the same ledger: an unbound slot is dim rather than aimed at a villager
// the fly has already heard out.
@ -2953,24 +3021,67 @@ fn a_no_route_refusal_records_what_it_could_not_reach() {
}
#[test]
fn the_overworld_plan_never_truncates_the_frontier_or_the_menu_away() {
// Eleven buttons and six rows: the entries that used to be cut were the last ones, which are
// the fallback that always has somewhere to go while any ground is unexplored and -- since
// section 13.1 -- the unconditional `MENU` that makes an empty pad impossible.
fn the_overworld_plan_never_truncates_the_frontier_away() {
// Eleven buttons and six rows: the entry that used to be cut was the last one, which is the
// fallback that always has somewhere to go while any ground is unexplored. `MENU` was the
// other, and section 12.11 took it off the row rather than rescuing it again.
let mut world = populated();
world.objective = Some(Objective { map: 0x00, tile: None, warp: None, edge: None, target: None });
world.signs = vec![Sign { x: 6, y: 6, text_id: 3 }];
let rows = plan(&mut world);
assert!(rows.len() >= 7, "a full overworld pad is more than six buttons: {rows:?}");
// Section 14: nothing is truncated at all any more, so the two that used to have to be
// rescued from the cut are simply there, in their own places at the end of the type order.
assert!(rows.len() >= 6, "a full overworld pad is more than five buttons: {rows:?}");
assert_eq!(
&rows[rows.len() - 2..],
["GO FRONTIER", "MENU"],
"the explorer and the start menu are both dealt: {rows:?}"
rows.last(),
Some(&"GO FRONTIER"),
"the explorer is dealt last and is never cut: {rows:?}"
);
}
/// Section 12.11: `MENU` is on no scene's pad, in any state of any scene.
///
/// **What was live** (2026-09-22, rung 10, thirty minutes inside the Pewter museum's upper floor):
/// macro starts `MENU` 82, `BACK` 82, `GO FRONTIER` 8, the event log alternating `MENU
/// start/done, BACK start/done`. `MENU` pressed START, the start menu opened, its pad is `CLOSE`,
/// `CONFIRM` and `BACK`, and `BACK` pressed B and closed it again -- two buttons that undo each
/// other with nothing else changing, which is section 12.10's rule one scene wider than a battle.
///
/// `MENU` was there as the overworld's *unconditional* button, the one that made an empty pad
/// impossible. It is also section 12.2's trap by definition -- a precondition satisfied wherever
/// the fly stands, and a macro that completes without moving -- and nothing in the vocabulary uses
/// the start menu for anything, so there is nothing behind it worth pressing it for. What keeps the
/// pad from being empty instead is the never-empty way out of [`ways`](super::palette::ways).
#[test]
fn menu_is_on_no_scenes_pad() {
let mut world = populated();
for scene in [
Scene::Overworld,
Scene::Dialog,
Scene::Unknown,
Scene::Menu,
Scene::Shop,
Scene::Pc,
Scene::Title,
Scene::Battle { own_turn: true, forced_switch: false },
Scene::Battle { own_turn: false, forced_switch: false },
Scene::Battle { own_turn: false, forced_switch: true },
] {
world.scene = scene;
world.battle = matches!(scene, Scene::Battle { .. })
.then_some((BattleKind::Wild, true, false));
assert!(
!super::palette::scene_set(scene, &mut world).contains(&MacroKind::Menu),
"{} deals MENU",
scene.label()
);
assert!(!plan(&mut world).contains(&"MENU"), "{} binds MENU", scene.label());
}
// The start menu is still reachable and still has its own pad: the fly's raw START reaches the
// cartridge in macros mode (section 13.1), and what is on that pad is what leaves it.
world.scene = Scene::Menu;
world.list = List::Start;
assert_eq!(pad_of(&mut world), ["CLOSE", "CONFIRM", "BACK"]);
}
#[test]
fn no_playable_scene_deals_an_empty_pad() {
// The battle-text deadlock of v0.2.4 was one scene with nothing on the pad. This is the sweep
@ -3891,6 +4002,13 @@ fn every_macro_type_has_a_population_a_tag_and_a_gloss() {
///
/// The sweep that catches an empty pad, which is the one state the doctrine cannot recover from on
/// its own: nothing presses for the fly, so a scene with no buttons waits for ever.
///
/// **What the overworld's half rests on since section 12.11** is the way out and no longer the
/// unconditional `MENU`: every map in the game has one -- an interior's front door or its
/// staircase, an outdoor map's connection -- and `ways`' last resort offers it regardless of the
/// ledgers when the map holds nothing else worth walking to. So the fixtures below have their
/// doors, where the old ones did not need them, and the map with *no way out at all* is the named
/// residual at the end of this test rather than a case the rule covers.
#[test]
fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
let worst = |world: &mut World| {
@ -3902,12 +4020,17 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
);
};
// The overworld, in all three of its rows, on a map with nothing on it and nowhere to go.
let mut bare = World::room();
// The overworld, in all three of its rows, on a map with nothing on it but its own door.
let mut bare = World::ground_floor();
bare.seen_maps.insert(0x00);
bare.seen_maps.insert(0x26);
bare.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
assert!(super::palette::stranded(&mut bare), "nothing on this floor to walk to");
worst(&mut bare);
let mut outdoors = viridian();
outdoors.warps.clear();
outdoors.connections = Connections { north: true, south: false, east: false, west: false };
outdoors.seen_maps.insert(maps::ROUTE_2);
outdoors.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY));
outdoors.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY));
outdoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
@ -3946,6 +4069,16 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
cornered.list = list;
worst(&mut cornered);
}
// And the one overworld that still deals nothing, said out loud rather than papered over: a
// map with **no way out at all**, every tile stood on and nothing on it. No map in Red is
// that -- an interior has its front door or its staircase and an outdoor map has its
// connections -- so this is a shape the cartridge does not hold, and `game.padEmptyMs` is what
// would report it if one ever did (section 13.1).
let mut sealed = World::room();
sealed.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
assert!(path::exits(&mut sealed).is_empty(), "the fixture really has no way out");
assert_eq!(plan::plan_for(Scene::Overworld, &mut sealed).bound(), 0);
}
/// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk.
@ -4132,6 +4265,203 @@ fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
assert_eq!(world.pulses.last(), Some(&buttons::A));
}
/// Section 12.11: a room whose one way out every ledger is resting still offers it.
///
/// **What was live** (2026-09-22, rung 10, the release container's own checkpoint): the fly on
/// **map 0x35, the Pewter museum's upper floor** -- fourteen blocks by eight, one warp at (7, 7)
/// down to the floor below (0x34), two signs and three exhibits. The reproduction is in
/// `infra/docs/macros-traps.md`; the shape of it is that every candidate list on that map empties:
///
/// - `geography` has no row for the museum, so `next_hop` from it answers `None` and
/// `GO OBJECTIVE` has nothing to aim at -- the objective itself is fine (map 0x36, the gym
/// leader, rung 11's BOULDER BADGE);
/// - the three exhibits and two signs are *reached* by `GO NPC` and `GO ITEM`, which retires them
/// for the session;
/// - the four unstood tiles are walked or excluded, and `GO FRONTIER` empties for the window;
/// - the one warp out is classified a **passage** and not an exit -- it is a staircase -- and
/// `unexcluded_exits` drops it while the blocked ledger rests it.
///
/// That left `MENU` and nothing else, and `MENU` opened the start menu whose `BACK` closed it
/// again: 82 starts each in thirty minutes. With `MENU` gone the same state has to deal a walk, and
/// the walk is the staircase **although the ledger is resting it** -- a target the ledger has
/// parked is still the only place to go.
#[test]
fn a_room_whose_only_way_out_the_ledger_rests_still_offers_it() {
let mut world = World::room();
// One staircase, no front door: the museum's upper floor, and Red's bedroom, and every other
// map whose only way anywhere is a passage.
world.warps = vec![Warp { x: 7, y: 1, destination_warp: 2, destination_map: 0x26 }];
world.seen_maps.insert(0x26);
world.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
assert_eq!(
path::exits(&mut world).iter().map(|exit| exit.way).collect::<Vec<_>>(),
vec![Way::Passage],
"a staircase and no front door"
);
// With the staircase unexcluded the pad is the ordinary indoor one.
assert!(on_the_pad(&mut world, MacroKind::GoWarp));
// Now rest it, which is what a refused walk does for ten brain minutes.
world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Warp(0)));
assert!(super::palette::stranded(&mut world), "nothing else on this floor to walk to");
assert!(
on_the_pad(&mut world, MacroKind::GoWarp),
"the resting staircase comes back as the last resort"
);
let pad = pad_of(&mut world);
assert_eq!(pad, ["GO WARP"], "and it is the whole pad: {pad:?}");
// And it is *only* a last resort: one unstood tile and the resting staircase goes away again,
// because "the nearest door, once per hold" is row 2's own loop.
world.stood.remove(&Tile::new(0, 0));
assert!(!super::palette::stranded(&mut world));
assert!(!on_the_pad(&mut world, MacroKind::GoWarp));
assert!(on_the_pad(&mut world, MacroKind::GoFrontier));
}
/// Section 12.11, stated at the pad: no overworld pad is one button that undoes itself.
///
/// The test the museum loop would have failed. `MENU` on the overworld and `BACK` on the start
/// menu are a pair across two scenes, so a per-pad rule cannot see it -- what can is that `MENU`
/// is on no pad at all, and that what is left when every ledger is against the map is a *walk*.
#[test]
fn an_overworld_pad_is_never_one_button_that_undoes_itself() {
// The museum shape, indoors, and the town shape, outdoors: both stranded, both dealt a walk.
let mut indoors = World::ground_floor();
indoors.seen_maps.insert(0x00);
indoors.seen_maps.insert(0x26);
indoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
for exit in path::exits(&mut indoors) {
indoors.targets.record_blocked(indoors.map, TargetKey::Exit(exit.id));
}
let mut outdoors = viridian();
outdoors.warps.clear();
outdoors.connections = Connections { north: true, south: false, east: false, west: false };
outdoors.seen_maps.insert(maps::ROUTE_2);
outdoors.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY));
outdoors.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY));
outdoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
outdoors.targets.record_blocked(outdoors.map, TargetKey::Exit(ExitId::Edge(Edge::North)));
for world in [&mut indoors, &mut outdoors] {
let pad = pad_of(world);
assert!(!pad.is_empty(), "an overworld with a way out deals something");
assert!(!pad.contains(&"MENU"), "MENU is on no pad: {pad:?}");
assert!(
pad.iter().any(|name| name.starts_with("GO ")),
"what is left is a walk rather than a screen to open and close: {pad:?}"
);
}
}
/// Section 12.11: the move list deals `BACK` only where the moves can be read.
///
/// The v0.4.3 residual (`infra/docs/macros-traps.md`): `BACK` was 263 of 797 macro starts, every
/// one over an open move list, and 142 of the run's `NEXT` starts were on a move list whose cursor
/// the seam could not place. A move list the seam cannot read the battler for binds no `MOVE n` at
/// all, so its pad was `BACK` alone -- and the only thing that button does is close the list that
/// `MOVE 1` on the menu underneath had just opened. That is 12.10's pair with `MOVE 1` in `NEXT`'s
/// place. `MOVE 1` alone confirms wherever the cursor stands, which is the press that ends a turn.
#[test]
fn the_move_list_deals_back_only_where_the_moves_can_be_read() {
let mut world = World::battle();
world.list = List::Moves(3);
world.grid = false;
world.cursor_max = 2;
assert_eq!(pad_of(&mut world), ["BACK", "MOVE 1", "MOVE 2", "MOVE 3"]);
// The battler the seam cannot place: no active slot, so `battle.own` is `None`.
world.active = None;
let pad = pad_of(&mut world);
assert_eq!(pad, ["MOVE 1"], "one button, and it ends the turn: {pad:?}");
assert!(move_slot_bound(&mut world, MacroKind::Move1));
assert!(!move_slot_bound(&mut world, MacroKind::Move2));
// And it really presses: the cursor is confirmed where it stands, which is Struggle's own
// path (row 30a) and the only reading available here.
assert_eq!(run(&mut world, MacroKind::Move1).unwrap(), MacroAbort::Done);
assert_eq!(world.pulses.last(), Some(&buttons::A));
}
/// Section 12.11: Red's battle menu is two columns, so its order is FIGHT, ITEM, PKMN, RUN.
///
/// The screen reads `FIGHT PKMN` over `ITEM RUN` and the game's own index does not: it is the row
/// inside the column the cursor is in, plus two for the right column. `battle_entry` had `PKMN` 1
/// and `ITEM` 2, which is the row-major reading of the picture, so **every macro that opened the
/// bag opened the party list and every macro that opened the party list opened the bag**.
///
/// **Surveyed on the cartridge** (`infra/docs/macros-traps.md`): a `THROW BALL` aiming at 2 walked
/// the cursor to `wTopMenuItemX` 15 / `wCurrentMenuItem` 0, pressed A, and the party list opened --
/// `wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02` -- with the game writing
/// `wCurrentMenuItem` 2 on the frame after the press. On v0.4.3 `THROW BALL` was 63 starts and 63
/// `blocked`, and `SWITCH` 15 of them: never the macro's own list, always the other one.
#[test]
fn the_battle_menus_two_columns_put_item_under_fight_and_pkmn_beside_it() {
use super::cartridge::battle_entry;
assert_eq!(
[battle_entry::FIGHT, battle_entry::ITEM, battle_entry::PKMN, battle_entry::RUN],
[0, 1, 2, 3],
"the left column is FIGHT then ITEM, the right is PKMN then RUN"
);
// And the seam reads the same order back off the fake's own geometry: DOWN moves one inside a
// column, RIGHT moves two across.
let mut world = World::battle();
let menu = |world: &mut World| super::palette::battle_menu(world);
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::FIGHT });
world.on_pulse(buttons::DOWN);
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::ITEM });
world.on_pulse(buttons::UP);
world.on_pulse(buttons::RIGHT);
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::PKMN });
world.on_pulse(buttons::DOWN);
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::RUN });
}
/// Section 12.11: a cursor step waits for the list it was built for.
///
/// **Measured on the cartridge, v0.4.3** (`infra/docs/macros-traps.md`): `THROW BALL` was **63
/// starts and 63 `blocked`**, mean sixty-nine frames -- which is the cursor to ITEM, the A that
/// confirms it, the twenty settle frames, and then a refusal on the very next frame. The bag had
/// not drawn yet, so `listing` still answered for the battle *menu*: four entries, `max` 3. The
/// ball's own bag index was above that, so the step read "off the end of the list" and gave up at
/// once -- and where the index was inside it, the step pressed UP and LEFT at the battle menu
/// instead, which is the blind pressing section 4 forbids.
#[test]
fn throw_ball_waits_for_the_bag_rather_than_reading_the_menu_it_came_from() {
let mut world = World::battle();
world.mons.truncate(1);
// Five items with the ball last, so its bag index is 4 -- above the battle menu's `max` of 3,
// which is the number the old step compared it against.
world.bag = vec![
(item::POTION, 1),
(item::ANTIDOTE, 1),
(item::REPEL, 1),
(item::POTION, 1),
(item::POKE_BALL, 5),
];
assert_eq!(throw_slot(&mut world), Some(4));
// The bag takes longer to draw than the script's twenty settle frames, which is the cartridge's
// own timing and the whole of the trap.
world.opens_draw_in = 40;
world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 4, grid: false });
assert_eq!(run(&mut world, MacroKind::ThrowBall).unwrap(), MacroAbort::Done);
assert_eq!(world.cursor, 4, "the ball's own bag index, by reading the bag's cursor");
assert_eq!(world.pulses.last(), Some(&buttons::A));
// Nothing was pressed at the menu it came from while the bag was drawing: the presses are the
// one that chose ITEM and then the bag's own.
assert_eq!(
world.pulses.iter().filter(|mask| **mask == buttons::UP).count(),
0,
"no blind press at the list it had already answered: {:?}",
world.pulses
);
// And a list that never opens is `blocked` rather than pressed at blind.
let mut never = World::battle();
never.mons.truncate(1);
never.bag = vec![(item::POKE_BALL, 5)];
assert_eq!(run(&mut never, MacroKind::ThrowBall).unwrap(), MacroAbort::Blocked);
}
/// Row 37 of `infra/docs/macros-traps.md`: a tile the cartridge pushes the fly off is not a tile
/// to stand on, and the ground beside a villager is not the villager's fault.
///

View file

@ -406,8 +406,14 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
&& (left || right);
let menu = if main {
// FIGHT and PKMN are the left column, ITEM and RUN the right; the cursor index the game
// keeps is within the column, and `.rightColumn` adds two to it on selection.
// **FIGHT and ITEM are the left column, PKMN and RUN the right.** The screen reads
// `FIGHT PKMN` over `ITEM RUN` and the game's index is by column: `wCurrentMenuItem` is
// the row inside the column the cursor is in, and `.rightColumn` adds two to it on
// selection -- so the order is FIGHT, ITEM, PKMN, RUN. Surveyed on the cartridge
// 2026-09-22 (`infra/docs/macros-traps.md`): A at `wTopMenuItemX` 15 with
// `wCurrentMenuItem` 0 opens the **party** list and the game then writes
// `wCurrentMenuItem` 2. `macros::cartridge::battle_entry` had this pair the other way
// round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag.
let column = if right { 2 } else { 0 };
BattleMenu::Main { cursor: column + cursor.current.min(1) }
} else if cursor.top_y == 12 && cursor.top_x == 5 {

View file

@ -59,6 +59,8 @@ const VIRIDIAN_MART: u32 = 0x2a;
/// Route 2's southern forest gate and the forest north of it, which is rung 9's own road.
const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32;
const VIRIDIAN_FOREST: u32 = 0x33;
/// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
const MUSEUM_2F: u32 = 0x35;
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter
/// (`macros::geography`, and rung 10's own road).
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
@ -217,6 +219,28 @@ struct Run {
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
/// north is whatever `GO FRONTIER` stumbles into.
objective_on_pad: std::collections::BTreeSet<u32>,
/// Maps on whose *overworld* pad `MENU` was ever bound (section 12.11).
///
/// The rung-10 trap: `MENU` opened the start menu and that scene's `BACK` closed it again, 82
/// starts each in thirty brain minutes inside one building. `MENU` is on no pad at all now, so
/// this set is empty -- and on v0.4.3 it holds every overworld map the run stood on.
menu_on_pad: std::collections::BTreeSet<u32>,
/// Overworld pads that bound nothing at all, by map: section 13.1's never-empty rule, which
/// since 12.11 rests on the way out rather than on an unconditional `MENU`.
empty_overworld_pads: std::collections::BTreeSet<u32>,
/// How many times each macro finished `blocked`, for the `THROW BALL` residual.
///
/// v0.4.3 on the cartridge: `THROW BALL` 63 starts, 63 `blocked` -- the bag had not drawn and
/// the step read the battle menu's cursor instead (section 12.11).
blocked: std::collections::BTreeMap<&'static str, u32>,
/// `NAME@scene/sub-state` for every macro that reported `blocked`: *where* it gave up.
blocked_where: std::collections::BTreeSet<String>,
/// Macros started while the fly was still on the map it resumed on.
macros_on_the_first_map: u32,
/// The longest chain of macro starts that alternated `MENU`, `BACK`, `MENU`, `BACK`.
longest_menu_back_alternation: u32,
menu_alternation: u32,
last_start: Option<&'static str>,
}
impl Run {
@ -302,6 +326,14 @@ impl Run {
battles_ended: 0,
was_in_battle: false,
objective_on_pad: std::collections::BTreeSet::new(),
menu_on_pad: std::collections::BTreeSet::new(),
empty_overworld_pads: std::collections::BTreeSet::new(),
blocked: std::collections::BTreeMap::new(),
blocked_where: std::collections::BTreeSet::new(),
macros_on_the_first_map: 0,
longest_menu_back_alternation: 0,
menu_alternation: 0,
last_start: None,
}
}
@ -390,6 +422,14 @@ impl Run {
battles_ended: 0,
was_in_battle: false,
objective_on_pad: std::collections::BTreeSet::new(),
menu_on_pad: std::collections::BTreeSet::new(),
empty_overworld_pads: std::collections::BTreeSet::new(),
blocked: std::collections::BTreeMap::new(),
blocked_where: std::collections::BTreeSet::new(),
macros_on_the_first_map: 0,
longest_menu_back_alternation: 0,
menu_alternation: 0,
last_start: None,
}
}
@ -603,7 +643,7 @@ impl Run {
self.talk_on_pad = talk_bound;
let active =
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let (mask, started) = {
let (mask, started, blocked) = {
let ledger = AdapterLedger(&self.adapter);
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
let started: Vec<&'static str> = decision
@ -612,8 +652,25 @@ impl Run {
.filter(|event| event.outcome.is_none())
.map(|event| event.name)
.collect();
(decision.mask, started)
let blocked: Vec<&'static str> = decision
.events
.iter()
.filter(|event| {
event.outcome.is_some_and(|outcome| outcome.as_str() == "blocked")
})
.map(|event| event.name)
.collect();
(decision.mask, started, blocked)
};
for name in blocked {
*self.blocked.entry(name).or_insert(0) += 1;
let sub = self.battle_sub_state();
let list = self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wListMenuID);
self.blocked_where.insert(format!(
"{name}@{}/{sub}/list={list:#04x}",
self.layer.scene_name()
));
}
let in_battle_now = self.in_battle() != 0;
let on_a_battle_pad = self.battle_pad();
for name in started {
@ -640,6 +697,24 @@ impl Run {
self.longest_next_back_alternation.max(self.alternation);
self.last_battle_start = Some(name);
}
// Section 12.11's own signature, as the live event log printed it: `MENU
// start/done, BACK start/done`, every hold, for thirty brain minutes inside one
// building. The pair is split across two *scenes*, so this is measured over every
// start rather than over a battle's.
let two = name == "MENU" || name == "BACK";
self.menu_alternation = match self.last_start {
Some(last) if two && (last == "MENU" || last == "BACK") && last != name => {
self.menu_alternation.max(1) + 1
}
_ if two => 1,
_ => 0,
};
self.longest_menu_back_alternation =
self.longest_menu_back_alternation.max(self.menu_alternation);
self.last_start = Some(name);
if self.route.len() == 1 {
self.macros_on_the_first_map += 1;
}
*self.started.entry(name).or_insert(0) += 1;
}
self.gb.set_buttons(mask as u8);
@ -681,6 +756,12 @@ impl Run {
if dealt.iter().any(|channel| channel.as_str() == "macro_go_objective") {
self.objective_on_pad.insert(map);
}
if dealt.iter().any(|channel| channel.as_str() == "macro_menu") {
self.menu_on_pad.insert(map);
}
if dealt.is_empty() {
self.empty_overworld_pads.insert(map);
}
let dealt = dealt.len();
let seen = self.pads.entry(map).or_insert(dealt);
*seen = (*seen).min(dealt);
@ -981,6 +1062,16 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
run.battle_back_where
);
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
eprintln!("blocked where: {:?}", run.blocked_where);
// Section 12.11, the other half of `THROW BALL`: it reaches the bag. On v0.4.3 every start of
// it was `blocked` -- 63 of 63, mean sixty-nine frames -- because the step that walks the bag
// list was allowed to read the battle menu's cursor while the bag was still drawing.
assert_eq!(
run.blocked.get("THROW BALL"),
None,
"`THROW BALL` reported blocked: {:?}",
run.blocked
);
// Section 12.10, the two halves of it.
assert!(
!run.next_and_back_on_one_pad,
@ -1015,9 +1106,15 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
// 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.
// with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu.
//
// The bound is **seven hundred** and was four; the number it is generous against is **450**
// and was 275, re-measured after section 12.11 put `battle_entry`'s `ITEM` and `PKMN` the
// right way round. Before that `SWITCH` opened the bag and `ITEM` and `THROW BALL` opened the
// party list, so neither could ever finish: every turn was an attack or nothing. A fly that
// can switch and heal fights longer, which is a longer battle and not a stalled one -- what
// the assertion is for is the difference between a battle that ends and a cycle that does
// not, and 3 of 3 ended here.
assert!(
run.battles_ended + 1 >= run.battles_entered,
"{} battles entered and only {} left: a battle was entered and never got out",
@ -1025,7 +1122,7 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
run.battles_ended
);
assert!(
run.worst_battle_macros > 0 && run.worst_battle_macros < 400,
run.worst_battle_macros > 0 && run.worst_battle_macros < 700,
"the worst battle cost {} macros over {} that ended",
run.worst_battle_macros,
run.battles_ended
@ -1806,3 +1903,117 @@ fn go_heal_enters_the_centre_and_heal_restores_the_party() {
run.force_hot = None;
assert!(run.party_rested(), "the nurse healed the party");
}
/// The rung-10 Pewter checkpoint, or `None` to skip.
///
/// Its own variable, like `FLY_FOREST_CHECKPOINT`: the tests above assert the map their envelope
/// is on and one envelope cannot be two maps.
fn building_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_BUILDING_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the rung-10 checkpoint: the fly leaves the building, and no pad is `MENU` and a way back.
///
/// **What was live** (2026-09-22, thirty-one minutes after v0.4.3 deployed): rank 10, PEWTER CITY,
/// the fly on **map 0x35 -- the upper floor of the Pewter museum**, fourteen blocks by eight, one
/// warp at (7, 7) down to the floor below, two signs and three exhibits. For thirty brain minutes
/// the macro starts were `MENU` 82, `BACK` 82 and `GO FRONTIER` 8, the event log alternating `MENU
/// start/done, BACK start/done`.
///
/// Every candidate list on that map empties: `geography` has no row for the museum, so `next_hop`
/// answers `None` and `GO OBJECTIVE` has nothing to aim at (the objective itself is map 0x36 --
/// the gym leader, rung 11's BOULDER BADGE); the three exhibits and two signs are *reached* and
/// retired for the session; the four unstood tiles are walked or excluded; and the one way out is
/// a **passage** whose blocked window `unexcluded_exits` respects. That left `MENU`, which opens a
/// scene whose pad is `CLOSE`, `CONFIRM` and `BACK` -- and `BACK` closes it again.
///
/// The claims here are about the pad and about *leaving*, not about where the fly goes next:
///
/// - `MENU` is on **no** overworld pad, on any map the run stands on (section 12.11). On v0.4.3
/// this set holds every one of them, which is what makes this the regression test.
/// - no overworld pad is empty, which since 12.11 rests on the way out rather than on `MENU`.
/// - the fly **leaves map 0x35** on a bounded number of macros.
/// - `MENU`/`BACK` never alternate, and `THROW BALL` never reports `blocked` -- the two residuals
/// v0.4.3 left (63 starts, 63 blocked, mean sixty-nine frames).
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_BUILDING_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
/// ```
#[test]
fn the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = building_checkpoint() else {
eprintln!("skipped: no FLY_BUILDING_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
let from = run.map();
assert_eq!(from, MUSEUM_2F, "the checkpoint is the building the stream stalled in");
// Rung 10 is stood on, so the objective is rung 11 -- the BOULDER BADGE, which is the gym
// leader and so a *person* on the gym's map.
assert_eq!(
run.objective_map(),
Some(0x36),
"the objective is the Pewter gym, where the badge is"
);
// Driven the whole budget rather than stopped at the door: leaving is one claim and "`MENU`
// is on no pad" is a claim about every pad the run deals, so the run keeps going and keeps
// recording. Thirty-three brain minutes, against the thirty the live run spent not leaving.
let mut left = None;
for frame in 0..120_000u32 {
run.frame();
if left.is_none() && run.map() != from {
left = Some(frame);
}
}
eprintln!(
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, MENU on the pad \
of {:?}, empty pads {:?}, blocked {:?}",
run.ms / 60_000.0,
run.route,
run.started,
run.menu_on_pad,
run.empty_overworld_pads,
run.blocked
);
assert!(
run.menu_on_pad.is_empty(),
"`MENU` was on an overworld pad: {:?}",
run.menu_on_pad
);
assert_eq!(run.started.get("MENU"), None, "`MENU` cannot start if it is on no pad");
// One is a lone `BACK`, which is an ordinary press in a list; two is the pair, and the live
// run did it eighty-two times each for thirty brain minutes.
assert!(
run.longest_menu_back_alternation < 2,
"`MENU`/`BACK` alternated {} times in a row",
run.longest_menu_back_alternation
);
assert!(
run.empty_overworld_pads.is_empty(),
"an overworld pad was empty on {:?}",
run.empty_overworld_pads
);
let Some(left) = left else { panic!("the fly never left map {from:#04x}") };
eprintln!("it left map {from:#04x} on frame {left}");
assert!(
run.macros_on_the_first_map < 400,
"leaving the building cost {} macros",
run.macros_on_the_first_map
);
// The v0.4.3 residual, on the cartridge: every `THROW BALL` was blocked because the step read
// the battle menu's cursor while the bag was still drawing.
assert_eq!(
run.blocked.get("THROW BALL"),
None,
"`THROW BALL` reported blocked: {:?}",
run.blocked
);
}