diff --git a/docs/design/macros.md b/docs/design/macros.md index 20f9509..a7d5765 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -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 | diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index efbdc85..b6969de 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -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. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 96e49cd..aedded3 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -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, } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs index 74ba77d..975e67b 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs @@ -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, + /// 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, 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) -> 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> { 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)); diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index 218bb11..d133701 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -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 { 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 { 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 { // 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 { 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 { 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 { // // 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 = - 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 { + if !stranded(state) { + return Vec::new(); + } + let every: Vec = + 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 { if !objective_targets(state).is_empty() { @@ -1053,8 +1108,10 @@ fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec { /// 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 { pub fn listing(state: &mut dyn MacroState) -> Option { 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 } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index 96b4401..28d9fe6 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -149,6 +149,15 @@ struct World { start_to_open: u8, /// What the next A presses open. opens: VecDeque, + /// 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 { 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 { + 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 = 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![ @@ -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![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. /// diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index e409d76..da6167e 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -406,8 +406,14 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option { && (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 { diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index f9319dc..8ba9b5c 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -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, + /// 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, + /// 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, + /// 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, + /// 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 { + 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 + ); +}