diff --git a/docs/design/ladder.md b/docs/design/ladder.md index 197437a..db7b402 100644 --- a/docs/design/ladder.md +++ b/docs/design/ladder.md @@ -125,7 +125,18 @@ condition the plan wanted, at the cost of no new state. ## Recovery budgets Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window -unchanged (120 s without new exploration, 180 s since last recovery). The ratchet's rank bound is +unchanged (120 s without new exploration, 180 s since last recovery). + +**Amended 2026-09-22 (rung 10).** "New exploration" is the adapter's lifetime tile ledger, so a map +entered for the first time resets the window -- a new map is a map's worth of tiles nobody has stood +on -- and a map *re-entered* does not. Two "Stuck" rollbacks fired inside half an hour on a fly that +was walking a town it had already covered toward the rung's own door, and both were this rule +working. The window now takes a second signal beside the tile count: **the fly being nearer its +objective, in map hops, than this run has ever been** (`docs/design/macros.md` section 12.15). The +ratchet treats it exactly as it treats new exploration -- it restarts the window and does nothing +else -- it can fire at most once per step of the road, and the ratchet itself knows no more about +what it means than it knows what a tile is. Nothing else about the budgets, the triggers or the +checkpointed state changes. The ratchet's rank bound is now the running adapter's ladder length, passed in rather than a constant, because the bound belongs to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16 (`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's diff --git a/docs/design/macros.md b/docs/design/macros.md index f830c78..acd7643 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1080,6 +1080,98 @@ while a prompt is readable, and `TALK` is on no pad at a rested nurse. The decoder, the reward catalog, the adapter version, the roles and the compatibility string are untouched. +### 12.13 `UNKNOWN` is two states, and one of them has nothing to press (2026-09-22, rung 10, five and a half hours) + +Live on v0.4.5, rank 10, the fly inside the Pewter museum with rung 11's BOULDER BADGE two doors +away: since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**, +`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169, and the previous review had already named the shape -- +`BACK` pressed 189 times "in a text box" on map `0x02`, surrounded by `GO OBJECTIVE` and +`GO FRONTIER`. `infra/docs/macros-traps.md` has the reproduction; three things were wrong and all +three are here, and the first of them is not a text box at all. + +- **`BACK` was dealt by `Scene::Unknown`, on frames with nothing drawn.** `BACK` is on no overworld + pad and on no dialog pad, so every one of those presses came from `Unknown` — and `Unknown` holds + two states under one name. One is a screen this crate cannot name: the Pokédex, the trainer card, + OPTION, where A and B are what leave it and 13.1 put them there on purpose. The other is a frame + of the **overworld** where the buttons are not reaching the player — a warp in flight, a scripted + push-back, the museum guide walking the fly through the door — which `scene::detect` calls + `Unknown` because its overworld branch needs `controllable`. On the second, `NEXT` and `BACK` are + an A and a B pressed into somebody else's script: they change nothing, they complete where the fly + stands, and that is 12.2's trap with no box to advance. So the pad is dealt on **whether a box is + drawn** (`wFontLoaded`, the reading `text_box` already makes), and a scripted overworld frame is an + empty pad the fly waits out. Nothing else can wait it out: the cartridge gives the buttons back by + itself, which is the difference between this and every other empty pad 13.1 enumerates. + +### 12.14 A frontier no walk can reach is a fact about the map (2026-09-22, the same run) + +The museum's ground floor is 98 walkable tiles, **62** of them reachable from the door and **39** +never stood on — and almost all of those 39 are behind the admission desk. `GO FRONTIER` aims at +ground the run has not stood on, the route search cannot reach any of it, so the macro refuses +`no route`, presses nothing and writes every goal to the blocked ledger (12.1). That ledger is a +**ten brain minute window**: it lapsed, all of it was a candidate again, and the refusal happened +again, once per hold, for hours. + +A window is the right shape for a target somebody is standing in front of and the wrong shape for +ground the map has fenced off. So the refusal is remembered **per map** instead, with no window, +beside the pushed-tile ledger of row 37 — and it is *cleared* by the one event that can change the +answer: the fly standing somewhere on that map it has not stood on before, because a door opened, a +script carried it through, or somebody moved out of a doorway. Re-entering the map clears nothing; +that was the loop the window made. Session state like every other ledger, never checkpointed. + +### 12.15 The ratchet's stall window cannot see a fly walking a road it has already covered + +Two "Stuck" rollbacks fired on this rung inside half an hour, attempts 0 → 2, each one putting the +fly back where it had started. Both were the ratchet working exactly to contract: its stall window +is restarted by *exploration* — `progress.unique_locations`, ground the run has never stood on +(`docs/design/ladder.md`, and row 22) — and 120 brain seconds of safe overworld samples without one +new tile is a stall by definition. Entering a map for the first time already counts, because a new +map is a map's worth of tiles nobody has stood on; **re-entering** one does not, and two museum +floors and a covered town are exactly that. + +What is plainly progress and is not ground: **being nearer the objective than this run has ever +been**, counted in hops over the same map graph `GO OBJECTIVE` walks (`geography::hops`). The macro +layer answers it, the sim loop passes it to the ratchet beside the coverage figure, and the ratchet +treats it exactly as it treats a rise in coverage — it restarts the window and does nothing else: no +budget spent, no snapshot taken, no trigger skipped. It can fire at most once per step of the road, +and nothing in the macro layer reads it back: no macro is ranked by it and no button is bound on it. + +The checkpointed ratchet state does not move. The signal is a level on one sample, not a counter, so +there is nothing to serialise and nothing to drift across a restore. + +### 12.16 What is still in the way of the badge, measured rather than fixed + +With 12.13, 12.14 and the museum's two rows on the map graph, the ROM-gated run from the live +checkpoint reaches the gym's own interior on **15 macros** — against never, in five and a half live +hours. Two things it then does are worth naming, because neither is a bug and both cost the rung: + +- **the town's errands come first, and they are session state.** Section 13 puts an unvisited mart + or Pokémon Center ahead of the rung's place for every map in that area, and the gym is in Pewter's + area like everything else. The ledger does not survive a restart, so the 11:30 restart re-armed + both errands and `GO OBJECTIVE` aimed at them before the leader. They are paid once and the run + goes on; the cost is minutes, not hours. +- **`GO FRONTIER` is still most of the run** — 1,247 starts in 55 brain minutes, 649 of them in + Pewter City itself. There the frontier is genuinely reachable, one tile at a time, because the + whole-map grid is refused on a walking fly (below) and the windowed frontier is the nearest + unstood tile on screen. It is covering ground rather than standing still, which is why it is a + residual and not a trap. + +**The trap hunt does not improve.** Distinct tiles 193 -> 175 and flagged windows 59 -> 69, with +`BACK` in a text box 295 -> 0 and battle frames 6,948 -> 20,894. What the old cycle is replaced by +is a new one on the same five tiles -- `GO FRONTIER`, `GO HEAL`, `GO ROUTE`, x42, for seven and a +half brain minutes -- and then four brain minutes inside one battle, which the hunt's tile rule +flags as hard as it flags a stall. `infra/docs/macros-traps.md` has both arms whole and row 54 is +the next brief. The ethos check's "the trap hunt improves" does not hold for this branch; the +ROM-gated run does, and both are reported rather than one of them. + +**The whole-map grid is refused while the fly is moving.** `pokemon_red::state::map_grid` checks its +decode against the screen buffer over the fly's own tile and its four neighbours, and on a frame +mid-step the two are a tile apart: `wYCoord` is the tile being walked *to* while the background is +still scrolling. Measured on Pewter City from the rung-10 checkpoint: standing still it decodes on +**118 of 120** frames, and the frame the survey caught disagreed on three tiles by exactly one row +in the direction of travel. A walk planned on such a frame is planned over the ten-by-nine window of +section 15's "before". Naming it needs a WRAM reading of "a step is in progress" that this crate's +reviewed symbol list does not carry, so it is reported here and by the probes rather than guessed at. + ## 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.") @@ -1210,6 +1302,7 @@ measured where it cannot. | a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette | | the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt | | a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change | +| an `Unknown` frame that is the **overworld with the cartridge driving** -- a warp in flight, a scripted push-back, a guide walking the fly through a door | **empty on purpose** (12.13). There is no box to advance and no screen to leave, so an A or a B press is a press into somebody else's script: it changes nothing and completes where the fly stands. This is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames -- and `game.padEmptyMs` reports it like any other | | the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens | And because "closed where a macro can close it" is not "closed": diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 95bc117..d0e7e12 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -1941,3 +1941,148 @@ checkpoint's own half-finished conversation plus the one `NO` that answered the - `infra/tests/lint.sh`: all checks passed, de-PII guard included. - `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1 through v0.4.4. Decoder, reward catalog, adapter version and roles untouched. + +## 2026-09-22, rows 51 to 53: the road the museum had no row for, and a `BACK` with no box under it + +Flagged live by the watchdog within the hour of v0.4.5 going out: rank 10 (PEWTER CITY) for five +and a half hours, the objective rung 11's BOULDER BADGE whose place is the gym leader in map +`0x36`, and since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**, +`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169. Between 11:46 and 12:17 the ratchet fired **two** +"Stuck: rolled back to PEWTER CITY" recoveries, attempts 0 -> 2. The fly was on map 52, the Pewter +museum's ground floor. This is the residual the row-41 review named: `BACK` 189 times "in a text +box" on map `0x02`, surrounded by `GO OBJECTIVE` and `GO FRONTIER`, with `GO FRONTIER` runs of 112. + +`docs/design/macros.md` sections 12.13 to 12.16 are the design; this is the reproduction, the +surveys and the before/after. + +### Where the fly was standing + +`examples/scene_probe.rs` from the live checkpoint: + +- map `0x34` (52), **20x8**, the player at **(10, 7)** facing up -- standing on the museum's own + front doormat, one of four `LAST_MAP` warps on the south wall; +- the scene reads **`Unknown`**, not `Dialog`: `font=0x00` and **no text box at all** + (`corners=(0x10,0x10,0x10,0x10)`), with `flags5=0x24` -- the cartridge holding the joypad. The + pad is **`NEXT`, `BACK`**; +- the objective is map `0x36` and `next_hop(Region { map: 52 }, 0x36)` answers **`None`**, because + `neighbours(52)` is **empty**: the museum has no row on the map graph. So + `objective_goals` is `[]`, `GO OBJECTIVE` is off the pad, and so are `GO SHOP` and `GO HEAL`; +- the map grid decodes: **98 walkable tiles, 62 reachable from the door, 39 never stood on** -- + and `frontier_aims` offers **40** of them, almost all behind the admission desk on the east side. + +### The three mechanisms, and none of them is the text box + +1. **`BACK` was never in a text box.** It is on no overworld pad and on no dialog pad, so every one + of the 678 was dealt by `Scene::Unknown` -- which is two states under one name. A screen this + crate cannot name (the Pokedex, the trainer card, OPTION) is one, and the hunt's own attribution + counts `unknown` frames as "a text box" (`dialog_map`), which is what made the residual read the + way it did. The other is a frame of the **overworld** with the cartridge driving: `detect`'s + overworld branch needs `controllable`, and a warp in flight, a scripted push-back or a guide + walking the fly through a door all fail it. `NEXT` and `BACK` there are an A and a B pressed into + somebody else's script. +2. **The museum is not on the map graph**, which the row-41 review named as a residual and left: + "adding the row would give the fly the road to the gym from indoors". Both rows come from the + cartridge's own warp table rather than from counting: Pewter City names `0x34` at (14, 7) and at + (19, 5) -- the museum's two doors -- and `0x34` names `0x35` at (7, 7), the staircase. +3. **The museum's frontier is unreachable and the blocked ledger is a window.** A `GO FRONTIER` + that can reach none of its goals refuses `no route` and writes all forty tiles to the ledger; + ten brain minutes later they are candidates again and it refuses again, once per hold. + +And the ratchet's two rollbacks were the ratchet working to contract: its stall window is restarted +by *exploration*, and two museum floors and a town the run had already covered earn none. Entering +a map for the first time does count -- a new map is a map's worth of tiles nobody has stood on -- +which is the half of the 2026-09-17 rule that already held and is now pinned by a test of its own. + +| # | trap | trigger | test | fix, or why it is left | +| ---: | --- | --- | --- | --- | +| 51 | `Scene::Unknown` deals `NEXT` and `BACK` on a frame of the overworld the cartridge is driving, where an A and a B press are presses into a script | every warp, every scripted push-back, every guide that walks the fly somewhere -- `BACK` 678 starts in 47 live minutes, 189 of them on map `0x02` | `an_unknown_frame_with_no_box_on_it_deals_nothing`, `an_unknown_scene_is_dialog_with_advance_only`, `the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint` (ROM-gated: `BACK` in a box 0, unknown pads with no box 0) | **fixed**: the pad is dealt on whether a box is drawn. A scripted overworld frame is an empty pad the fly waits out, and it is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames | +| 52 | a building with no row on the map graph has no neighbours, so from inside it there is no road to the objective and no errand either | the whole rung-10 stall: `GO OBJECTIVE`, `GO SHOP` and `GO HEAL` all off the pad on maps `0x34` and `0x35` | `the_museums_two_floors_know_the_road_to_the_gym`, `the_hop_count_is_the_road_measured_rather_than_named`, the ROM run below | **fixed**: two rows, both surveyed from the cartridge's warp table. The upper floor has no *area* -- no front door of its own, like a bedroom -- so it offers no errand, and its road out is the staircase | +| 53 | a frontier the route search cannot reach is excluded by a ten brain minute window, so it comes back every ten minutes for ever | `GO FRONTIER` 1,235 starts in 47 minutes over two museum floors and a covered town; 39 unstood tiles on map `0x34`, almost all behind the admission desk | `a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off`, `a_frontier_mark_is_the_stood_ledgers_to_clear`, `standing_on_new_ground_is_what_clears_a_maps_frontier_mark` | **fixed**: the refusal is remembered per map with no window, beside the pushed-tile ledger of row 37, and cleared by the fly standing on ground of that map it had not stood on before -- the only event that can change which tiles it can reach | + +### The ROM-gated run, from the live checkpoint + +`FLY_PEWTER_CHECKPOINT`, macros mode, the stub rotation, 200,000 frames (55.8 brain minutes): + +| measure | value | +| --- | --- | +| the checkpoint's map | `0x34`, the museum's ground floor, on its doormat | +| reached **map `0x36`**, the gym's interior | **frame 2,423, on 15 macros** | +| `TALK` on the pad inside the gym | yes -- and 15 `TALK` starts on map `0x36` | +| `GO OBJECTIVE` bound on the gym's own pad | yes | +| `BACK` on a `dialog` or `unknown` frame | **0** (189 in the before hunt, on map `0x02` alone) | +| pads dealt on an `unknown` frame with nothing drawn | **0** | +| `GO FRONTIER` starts on the museum's two floors | **0** (1,235 live over those floors and the town) | +| `GO FRONTIER` starts overall | 1,247 -- 649 of them in Pewter City itself | +| route | `0x34` -> Pewter City -> the gym, then the town's shops and houses | +| the rank at the end | 10 (PEWTER CITY), **0 badges**: the road is open, the badge is not won | + +The fly is out of the museum and into the gym in the first minute, and then spends the run in +Pewter City's buildings: the town's errands are session state and the 11:30 restart re-armed both, +so `GO OBJECTIVE` aims at the mart and the centre before the leader (section 13, and `BUY +ANTIDOTE` 3, `BUY BALL` 2, `HEAL` 1 in the same run say it was paid). + +### The trap hunt, before and after — and it does not improve + +Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge, +from the release container's own rung-10 Pewter checkpoint, **driven by the brain**. + +| measure | before (v0.4.5) | after | +| --- | ---: | ---: | +| distinct (map, tile) | **193** | 175 | +| windows flagged | **59/73** | 69/73 | +| macros started | 1283 | 1056 | +| `BACK` starts | **334** | 133 | +| `BACK` in a text box on map `0x02` | **291** | **0** | +| `BACK` on any text box | 295 | **0** | +| `GO FRONTIER` starts | 531 | 242 | +| `GO HEAL` starts | 0 | **204** | +| frames in `overworld` | 43,797 | 30,861 | +| frames in `unknown` | 16,595 | 13,990 | +| frames in `battle` | 6,948 | **20,894** | +| recoveries | 0 | 0 | +| the repeated sequence | `BACK, GO FRONTIER, GO FRONTIER, GO OBJECTIVE` x16 and `GO OUT, BACK, GO FRONTIER, GO FRONTIER` x37 | `GO FRONTIER, GO HEAL, GO ROUTE` x42 | + +**The before arm is the live loop whole**: the two four-macro cycles it flags are the two the +watchdog's own log named, `BACK` is 291 of them in a text box on map `0x02`, and the fly ends the +run having covered 193 tiles of a 879-tile town. + +**The after arm does not improve on either of the hunt's two measures, and this says so.** `BACK` +in a box goes 295 -> 0, which is row 51 closed; the museum is left in the first window and never +returned to, which is rows 52 and 53. What replaces the old cycle is a *new* one on the same five +tiles -- `GO FRONTIER, GO HEAL, GO ROUTE` x42 from brain minute 1.0 to 8.5 -- and after that the +fly covers ground (up to 103 tiles in a window) and spends the last four brain minutes inside one +battle, which the hunt's tile rule flags as hard as it flags a loop. Battle frames go **6,948 -> +20,894**, and section 15 of `docs/design/macros.md` measured the same trade the same way: a fly +that covers more ground walks into more grass, and the hunt cannot tell a long fight from a stall. + +| # | trap | trigger | test | fix, or why it is left | +| ---: | --- | --- | --- | --- | +| 54 | `GO FRONTIER`, `GO HEAL` and `GO ROUTE` cycle on five tiles: three walks that each end where they began | measured in the **after** arm only, brain minutes 1.0 to 8.5, `GO HEAL` **204** starts at a mean net of 0.0 tiles and a mean reach of 0.0, `GO ROUTE` 211 at a net of 0.2 | -- | **named, not worked, and it is the next brief.** Two readings fit and the hunt cannot separate them: the errand walking the fly in and out of a building whose door is underfoot (row 2's shape, with `GO HEAL` in `GO ROUTE`'s place), and the windowed walk oscillation of 12.3's row 23 -- which the whole-map grid was built to end and which is back **because the grid is refused on every frame the fly is mid-step** (below). Rows 1, 2b, 23, 24 and 41 were each found this way | + +### Residuals, named rather than worked around + +- **The whole-map grid is refused while the fly is moving.** `map_grid` checks its decode against + the screen buffer over the fly's tile and its four neighbours, and mid-step the two are one tile + apart: `wYCoord` is the tile being walked *to* while the background is still scrolling. Measured + on Pewter City from this checkpoint: standing still it decodes on **118 of 120** frames, and the + frame the survey caught disagreed on three tiles by exactly one row in the direction of travel. + A walk planned on such a frame is planned over the ten-by-nine window of section 15's "before", + which is what row 23's oscillation is made of. The honest fix is a WRAM reading of "a step is in + progress", which this crate's reviewed symbol list does not carry, so it is reported by the + probes and left. +- **The town's errands are session state**, so every restart re-arms them and `GO OBJECTIVE` aims + at the mart and the centre before the rung's place, the gym included. Section 13's own design. +- **`MOVE n` still reports `blocked`** with the move list drawn and its cursor placeable but not + accepting input (row 50): 42 of 65 in the after arm. Unchanged since v0.4.3. + +### Gates + +- `cargo test --workspace` with `FLY_ROM` set: green except + `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`, + which is the known debug-build boot failure on this box -- the service took 49.2 s to its first + healthy `/healthz` and `/status` still read the booting header (`milestone.total` 1, the + placeholder `simloop.rs` uses before the adapter exists). Pre-existing and unrelated. +- `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 + through v0.4.5. Decoder, reward catalog, adapter version and roles untouched. diff --git a/services/flysim/crates/flybrain-gb/src/macros.rs b/services/flysim/crates/flybrain-gb/src/macros.rs index 3183ab1..55bd04a 100644 --- a/services/flysim/crates/flybrain-gb/src/macros.rs +++ b/services/flysim/crates/flybrain-gb/src/macros.rs @@ -264,6 +264,27 @@ pub trait MacroPalette: Send { /// Give up on whatever is running, because the sim loop is rolling the game back. The /// abandoned macro is reported by the next [`MacroPalette::take_finished`]. fn cancel(&mut self); + + /// Whether the last [`MacroPalette::observe`] saw the fly **nearer its objective than it has + /// been** since that objective was set, measured in map hops. + /// + /// The ratchet's stall window is reset by exploration -- one new tile + /// (`docs/design/ladder.md`, the 2026-09-17 progress rule) -- and a fly crossing a town it + /// has already covered to reach the rung's own door earns no new ground while it does it. + /// That is the rung-10 stall of 2026-09-22 in one line: two "Stuck" rollbacks inside half an + /// hour, both of them on a fly that was walking, both of them landing it back where it had + /// started. Getting nearer the objective than this run has ever been is the other thing that + /// is plainly progress, and it is a *level* rather than a counter so nothing is checkpointed + /// and nothing can drift: it is true on the frame the distance falls and false after. + /// + /// Read by the sim loop and by nothing else. No macro is ranked by it, no button is bound on + /// it and it presses nothing (`docs/design/macros.md` section 12): it is the loop's own + /// answer to "is this run getting somewhere". + /// + /// The default is `false`, which is a palette with no objective to be nearer to. + fn nearer_the_objective(&self) -> bool { + false + } } /// Every macro channel a game's palette can ever bind, in the contract's own order, or empty for 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 01d556e..de90495 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 @@ -364,6 +364,14 @@ pub trait MacroState: GameState { false } + /// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable. + /// + /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state + /// that cannot answer has proved nothing, which is a fresh session. + fn frontier_exhausted(&mut self) -> bool { + false + } + /// Whether the cartridge has pushed the fly off this tile of the loaded map. /// /// [`PushedLedger`] is the evidence and the measurement. The default is `false`: a state that @@ -465,6 +473,22 @@ pub trait MacroState: GameState { false } + /// Whether a text box is open at all: `wFontLoaded`'s bit, and nothing drawn. + /// + /// The one thing that tells a screen with words on it from a frame of the overworld the + /// cartridge happens to be driving, and [`super::palette::scene_set`] deals + /// [`Scene::Unknown`]'s pad on it (**section 12.13**). `Unknown` is two different states + /// wearing one name: a screen this crate cannot name -- the Pokedex, the trainer card, + /// OPTION -- where `NEXT` and `BACK` are the A and B that leave it; and a *scripted* overworld + /// frame, where `scene::detect` falls through to `Unknown` because the buttons are not + /// reaching the player, and where an A or a B press is a press into somebody else's script. + /// + /// The default is `false`, which narrows: with no reading, `Unknown` deals nothing and the + /// fly waits, which is what the doctrine says a scene with nothing to press does. + fn text_open(&mut self) -> bool { + false + } + /// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box. /// /// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the @@ -612,9 +636,13 @@ impl StoodLedger for NoStood { pub struct Stood(std::collections::BTreeSet<(u8, Tile)>); impl Stood { - /// Record the tile the fly is standing on. Idempotent. - pub fn record(&mut self, map: u8, tile: Tile) { - self.0.insert((map, tile)); + /// Record the tile the fly is standing on, and say whether it is ground this run had not + /// stood on before. Idempotent. + /// + /// The answer is what clears a map's frontier mark ([`Frontiers`], section 12.14): new + /// ground under the fly is the one thing that can have changed which tiles it can reach. + pub fn record(&mut self, map: u8, tile: Tile) -> bool { + self.0.insert((map, tile)) } /// How much ground this session has watched, for a log line and the tests. @@ -633,6 +661,75 @@ impl StoodLedger for Stood { } } +/// Maps whose frontier the run has proved it cannot reach any more. +/// +/// **The rung-10 museum and Pewter City, 2026-09-22.** `GO FRONTIER` ran 1,235 times in 47 +/// minutes over two museum floors and a town whose walkable ground the fly had already covered. +/// The ground it was aiming at was real -- the museum's exhibit hall behind the admission desk, +/// the far side of a fence -- and unreachable: 98 walkable tiles on map `0x34`, 62 of them +/// reachable from the door, 39 never stood on and all but a handful of those behind the desk. +/// A walk that can reach none of its goals refuses `no route` and writes every one of them to +/// the blocked ledger ([`Targets`]), which is a **window**: ten brain minutes later all forty +/// tiles were candidates again, the button was back on the pad, and the refusal happened again. +/// A window is right for a target a person is standing in front of and wrong for ground the map +/// has fenced off. +/// +/// So the refusal is remembered per map instead. It is written when a `GO FRONTIER` refuses for +/// want of a route -- the measured fact "from here, no unstood tile of this map can be walked +/// to" -- and it is cleared the moment the fly **stands somewhere on that map it has not stood +/// before**, because that is the only thing that can have changed the answer: a door opened, a +/// script carried the fly through, somebody moved out of a doorway. Not on re-entering the map, +/// which is the loop the window made. +/// +/// Session state beside [`Talked`], [`Stood`], [`Areas`] and [`Pushed`], never checkpointed: a +/// restored run tries the frontier once more, which is the honest answer for a ledger that did +/// not survive. +pub trait FrontierLedger { + /// Whether a `GO FRONTIER` has proved this map's remaining frontier unreachable. + fn frontier_exhausted(&self, map: u8) -> bool; +} + +/// A ledger that has proved nothing: every map's frontier is still worth a walk. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoFrontiers; + +impl FrontierLedger for NoFrontiers { + fn frontier_exhausted(&self, _map: u8) -> bool { + false + } +} + +/// The session's own record of which maps have nothing left to walk to. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Frontiers(std::collections::BTreeSet); + +impl Frontiers { + /// Record that `map`'s frontier could not be reached. Idempotent. + pub fn record(&mut self, map: u8) { + self.0.insert(map); + } + + /// Forget `map`'s mark, because the run has just stood somewhere on it that it had not. + pub fn clear(&mut self, map: u8) { + self.0.remove(&map); + } + + /// How many maps are marked, for a log line and the tests. + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FrontierLedger for Frontiers { + fn frontier_exhausted(&self, map: u8) -> bool { + self.0.contains(&map) + } +} + /// Tiles the cartridge has pushed the fly off, as the macros ask it. /// /// **The Viridian private-property tile** (2026-09-17, `infra/docs/macros-traps.md` row 37). diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 6eca02f..bde4466 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -18,10 +18,10 @@ use crate::macros::{ use super::super::mapgrid::MapGrids; use super::super::state::PokeState; -use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile}; +use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::geography; use super::executor::{MacroAbort, MacroMachine, Refusal}; -use super::palette::{MacroId, Palette}; +use super::palette::{self, MacroId, Palette}; use super::plan; use super::state::{GameState, Scene}; @@ -64,6 +64,15 @@ pub struct PokemonPalette { /// centre's own map -- which is the moment the errand is discharged. A purchase or a heal marks /// nothing: what the errand asked for was the visit. Session state, not checkpointed. areas: Areas, + /// Maps whose frontier a `GO FRONTIER` has proved unreachable (`docs/design/macros.md` + /// section 12.14). + /// + /// Owned here beside the other session ledgers and written from the same two places they are: + /// the machine's refusal on one side and the frame the fly is standing on new ground on the + /// other. Like [`Pushed`] it has no window -- ground the map has fenced off is still fenced + /// off ten brain minutes later -- and unlike it, it is *cleared*, by the only event that can + /// change the answer. + frontiers: Frontiers, /// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37). /// /// Owned here beside the other session ledgers and for the same reason. Unlike the target @@ -79,6 +88,17 @@ pub struct PokemonPalette { /// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the /// cartridge's own tables say about the ground. grids: MapGrids, + /// The fewest map hops between the fly and its objective this run has managed, and which + /// objective that was (`docs/design/macros.md` section 12.15). + /// + /// Session state beside the ledgers and never checkpointed, and unlike them it is not a fact + /// about the map at all: it is the one reading the *sim loop* takes from the macro layer, for + /// the ratchet's stall window. The objective is carried with the number because the ladder's + /// next rung changes as the run climbs, and "nearer" means nothing across two different + /// places. + nearest: Option<(u8, u32)>, + /// Whether the last `observe` was the frame that number fell on. + nearer: bool, /// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather @@ -101,8 +121,11 @@ impl PokemonPalette { targets: Targets::new(), stood: Stood::default(), areas: Areas::default(), + frontiers: Frontiers::default(), pushed: Pushed::default(), grids: MapGrids::default(), + nearest: None, + nearer: false, now_ms: 0.0, } } @@ -137,6 +160,11 @@ impl PokemonPalette { self.pushed.len() } + /// How many maps have proved their frontier unreachable, for a log line and the tests. + pub fn exhausted(&self) -> usize { + self.frontiers.len() + } + /// Take whatever the machine's last finished macro earned into the session's ledgers. fn record_talk(&mut self) { if let Some((map, target)) = self.machine.take_talked() { @@ -154,6 +182,11 @@ impl PokemonPalette { if let Some((map, tile)) = self.machine.take_pushed() { self.pushed.record(map, tile); } + // A frontier the walk could not reach any of: a fact about this map's ground, with no + // window on it (section 12.14). + if let Some(map) = self.machine.take_exhausted() { + self.frontiers.record(map); + } if let Some((map, target, closer)) = self.machine.take_timeout() { self.targets.record_timeout(map, target, closer); } @@ -167,14 +200,25 @@ impl MacroPalette for PokemonPalette { } fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { - let (scene, bindings, standing) = { + let (scene, bindings, standing, approach) = { let Self { - machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, .. + machine, + mode, + palette: cached, + talked, + targets, + stood, + areas, + frontiers, + pushed, + grids, + .. } = self; let mut state = PokeState::with_ledgers( memory, ledger, talked, targets, &*stood, &*areas, &*pushed, ) - .caching_grid(grids); + .caching_grid(grids) + .with_frontiers(&*frontiers); // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // palette and the scene the feed reports cannot disagree about which frame they are // for. @@ -193,13 +237,27 @@ impl MacroPalette for PokemonPalette { // -- the coordinates and the loaded map header are from different frames, and a tile // recorded from that pair is a tile of nowhere. let standing = (!state.scripted()).then(|| state.player()).flatten(); + // How far the objective is, over the same map graph `GO OBJECTIVE` walks (section + // 12.15). Read from the same frame and the same state everything else is, and only + // where the fly is its own master, for the same reason the ground is. + let approach = standing.and_then(|player| { + let objective = palette::objective_place(&mut state)?; + let hops = + geography::hops(geography::region_at(player.map, player.y), objective.map)?; + Some((objective.map, hops)) + }); *cached = Some(palette); - (scene, bindings, standing) + (scene, bindings, standing, approach) }; // Section 12.7: the macro layer's own answer to "has the run stood here", because the // adapter's reward ledger cannot record a doormat. if let Some(player) = standing { - self.stood.record(player.map, Tile::new(player.x, player.y)); + // New ground under the fly is the one thing that can change which tiles of this map + // it can reach, so it is what clears the map's frontier mark (section 12.14). A tile + // the ledger already had changes nothing and clears nothing. + if self.stood.record(player.map, Tile::new(player.x, player.y)) { + self.frontiers.clear(player.map); + } // Section 13's `areaVisited(kind, area)`: the errand is paid on *entering*, so the // ledger is written from the same frame that records the ground. Standing on the // building's own map is the whole test -- the fly is inside it -- and it is written @@ -212,6 +270,22 @@ impl MacroPalette for PokemonPalette { self.areas.record(kind, area); } } + // Section 12.15: nearer the objective than this run has ever been, which is the other + // thing that is plainly progress and which the ratchet's stall window cannot see in the + // exploration ledger. A level, true on the frame the number falls and false after, so + // there is nothing to checkpoint and nothing to drift. A different objective starts the + // measurement again: the ladder's next rung moves as the run climbs and "nearer" means + // nothing across two different places. + self.nearer = match (self.nearest, approach) { + (_, None) => false, + (None, Some(_)) => false, + (Some((was, best)), Some((map, hops))) => map == was && hops < best, + }; + self.nearest = match (self.nearest, approach) { + (_, None) => self.nearest, + (Some((was, best)), Some((map, hops))) if map == was => Some((map, best.min(hops))), + (_, Some(now)) => Some(now), + }; // The talked entry `observe_frame` may just have earned, into the ledger the next frame // reads. self.record_talk(); @@ -240,7 +314,8 @@ impl MacroPalette for PokemonPalette { &self.areas, &self.pushed, ) - .caching_grid(&mut self.grids); + .caching_grid(&mut self.grids) + .with_frontiers(&self.frontiers); self.machine.start(&palette, slot, &mut state) }; let started = match begun { @@ -281,7 +356,8 @@ impl MacroPalette for PokemonPalette { &self.areas, &self.pushed, ) - .caching_grid(&mut self.grids); + .caching_grid(&mut self.grids) + .with_frontiers(&self.frontiers); self.machine.step(&mut state) }; // A macro that just finished may have been the press that talked to something; the ledger @@ -305,6 +381,10 @@ impl MacroPalette for PokemonPalette { Some((name, outcome(abort))) } + fn nearer_the_objective(&self) -> bool { + self.nearer + } + fn cancel(&mut self) { self.machine.cancel(); // A cancelled macro talked to nothing, and a stale entry would silence a person for the @@ -317,8 +397,10 @@ impl MacroPalette for PokemonPalette { while self.machine.take_blocked().is_some() {} let _ = self.machine.take_timeout(); let _ = self.machine.take_reached(); - // A rollback is not the map pushing the fly anywhere. + // A rollback is not the map pushing the fly anywhere, nor its frontier going out of + // reach: the fly is about to be standing somewhere else. let _ = self.machine.take_pushed(); + let _ = self.machine.take_exhausted(); // The cached palette was dealt for a frame that is being thrown away. Dropping it makes // the next `start` before the next `observe` a nameless refusal, which presses nothing // and reports nothing, rather than a named refusal against a scene that no longer exists. @@ -373,6 +455,8 @@ mod tests { use crate::adapter::{MapEdge, MapExit}; use crate::macros::NoLedger; use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram}; + use crate::pokemon_red::macros::geography::Amenity; + use crate::pokemon_red::maps; use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState}; /// A ledger with one exit in it, for the wiring test below. @@ -401,6 +485,95 @@ mod tests { assert_eq!(palette.take_finished(), None); } + /// A ledger whose objective is one map, for the approach reading below. + struct Bound(u8); + + impl RunLedger for Bound { + fn exit_visited(&self, _exit: MapExit) -> bool { + false + } + + fn objective(&self) -> Option { + Some(crate::adapter::MapPlace { + map: self.0, + tile: None, + warp: None, + edge: None, + target: None, + }) + } + } + + #[test] + fn the_objective_getting_nearer_is_read_once_per_step_of_the_road() { + // Section 12.15, the rung-10 stall. The ratchet's stall window is reset by ground never + // stood on, and a fly walking a road it has already covered earns none -- so this is the + // other reading, and what it has to be is a *level* that is true on the frame the hop + // count falls and false on every frame after it. Pewter's own museum is the road: the + // upper floor is three hops from the gym, the ground floor two, the town one. + let mut wram = Wram::new(); + wram.started().map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6).facing(0).house_collision(); + let mut palette = PokemonPalette::new(7); + // Both of Pewter's errands discharged, so the objective is the rung's own place for the + // whole test: section 13 puts an unvisited mart or centre *ahead* of it, and that is a + // different place to be near. + palette.areas.record(Amenity::Mart, maps::PEWTER_CITY); + palette.areas.record(Amenity::Center, maps::PEWTER_CITY); + let ledger = Bound(maps::PEWTER_GYM); + + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "the first reading is a measurement, not a step"); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "standing still is not nearer"); + + wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(palette.nearer_the_objective(), "down the stairs is one hop nearer"); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "and it is read once, not held"); + + // Back upstairs is not progress, and it does not undo the number either: the measurement + // is the best this run has managed, so walking the road twice pays once. + wram.map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective()); + wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "ground already gained is not gained again"); + + wram.map(maps::PEWTER_CITY, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(palette.nearer_the_objective(), "out of the front door is nearer still"); + } + + #[test] + fn standing_on_new_ground_is_what_clears_a_maps_frontier_mark() { + // Section 12.14's other half, wired: the mark is written by a `GO FRONTIER` that could + // reach none of its goals and cleared by the fly standing somewhere on that map it had + // not stood on before -- the only event that can change which tiles it can reach. A tile + // the stood ledger already has changes nothing, which is what keeps the mark from being + // cleared by the fly pacing the ground it has covered. + let mut wram = Wram::overworld(); + let mut palette = PokemonPalette::new(7); + palette.frontiers.record(REDS_HOUSE_1F); + assert_eq!(palette.exhausted(), 1); + + palette.observe(&mut wram, &NoLedger); + assert_eq!(palette.exhausted(), 0, "the first frame is new ground, so it clears"); + + palette.frontiers.record(REDS_HOUSE_1F); + palette.observe(&mut wram, &NoLedger); + assert_eq!( + palette.exhausted(), + 1, + "standing on the same tile again is not new ground and clears nothing" + ); + + wram.map(REDS_HOUSE_1F, 4, 4, 3, 5); + palette.observe(&mut wram, &NoLedger); + assert_eq!(palette.exhausted(), 0, "a tile the run had not stood on clears it"); + } + #[test] fn every_bound_slot_carries_a_name_and_a_gloss() { let mut wram = Wram::overworld(); 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 4407ace..f7a6827 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 @@ -575,6 +575,14 @@ pub struct MacroMachine { /// that finds no route to *any* of its goals has failed at all of them, and each is a key. blocked: Vec<(u8, TargetKey)>, reached: Option<(u8, TargetKey)>, + /// A map whose frontier a `GO FRONTIER` has just proved unreachable, waiting to be taken + /// into the session's ledger ([`super::cartridge::Frontiers`], section 12.14). + /// + /// The same shape as `pushed_tile` and for the same reason: it is a fact about the *ground* + /// rather than about a target, so the blocked ledger's ten-minute window is the wrong home + /// for it -- the window is what brought forty unreachable museum tiles back every ten + /// minutes, once per hold, for hours. + exhausted: Option, /// A tile the cartridge pushed the fly off, waiting to be taken into the session's ledger. /// /// Row 37 of `infra/docs/macros-traps.md`: a scripted push-back is a fact about the *ground*, @@ -651,6 +659,7 @@ impl MacroMachine { outcome: None, blocked: Vec::new(), reached: None, + exhausted: None, pushed_tile: None, timed_out: None, resume: VecDeque::new(), @@ -695,6 +704,13 @@ impl MacroMachine { // could not reach is what empties the list and takes the button off the pad. if let Some(map) = state.player().map(|player| player.map) { self.blocked.extend(unreachable.into_iter().map(|key| (map, key))); + // And for the frontier, the same fact one level up: every tile of this map the + // run has not stood on is unreachable from where the fly is standing. The + // blocked ledger is a window and this is not -- ground the map has fenced off is + // still fenced off ten brain minutes later (section 12.14). + if spec.kind == MacroKind::GoFrontier { + self.exhausted = Some(map); + } } return refuse(self, Refusal::NoRoute); }; @@ -857,6 +873,11 @@ impl MacroMachine { self.reached.take() } + /// The map a `no route` from `GO FRONTIER` earned, taken rather than read (section 12.14). + pub fn take_exhausted(&mut self) -> Option { + self.exhausted.take() + } + /// The tile a scripted push-back earned, taken rather than read (row 37). pub fn take_pushed(&mut self) -> Option<(u8, Tile)> { self.pushed_tile.take() @@ -883,8 +904,10 @@ impl MacroMachine { // that so a cancelled queue cannot leak into the next macro's finish. self.blocked.clear(); self.reached = None; - // A rollback is not the map pushing the fly anywhere. + // A rollback is not the map pushing the fly anywhere, and it is not the frontier being + // out of reach either: the fly is about to be somewhere else entirely. self.pushed_tile = None; + self.exhausted = None; self.timed_out = None; // A rollback puts the fly somewhere else on the map, so every suspended route is a route // from a tile it is no longer standing on. `take_resume` would refuse them one at a time; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs index f32c354..117ed39 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs @@ -112,6 +112,14 @@ const LINKS: &[(u8, u8)] = &[ (maps::VIRIDIAN_FOREST_NORTH_GATE, maps::ROUTE_2), (maps::VIRIDIAN_FOREST_NORTH_GATE, maps::VIRIDIAN_FOREST), (maps::PEWTER_GYM, maps::PEWTER_CITY), + // The museum, both floors. It is on the graph for the same reason the gym is: the rung-10 + // loop of 2026-09-22 spent its hours on these two maps with `GO OBJECTIVE` off the pad + // entirely, because a map with no row here has no neighbours, so `next_hop` answers nothing + // and `area_of` answers nothing -- no road to the gym from indoors, and no errand either. + // Both rows are the cartridge's own warp table, surveyed from the rung-10 checkpoint: Pewter + // City names `$34` at (14, 7) and (19, 5), and `$34` names `$35` at (7, 7). + (maps::PEWTER_MUSEUM_1F, maps::PEWTER_CITY), + (maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F), (maps::PEWTER_MART, maps::PEWTER_CITY), (maps::PEWTER_POKECENTER, maps::PEWTER_CITY), (maps::MT_MOON_1F, maps::ROUTE_3), @@ -333,6 +341,39 @@ pub fn next_hop(from: Region, to: u8) -> Option { None } +/// How many hops the shortest known route from `from` to the map `to` takes, or `None` when none +/// is known. +/// +/// [`next_hop`]'s own breadth-first walk, counting instead of naming: `Some(0)` when the fly is +/// already on `to`, `Some(1)` for a door out of this map into it, and `None` for a map the table +/// cannot route to -- which is the same "nothing is guessed" [`next_hop`] answers with. +/// +/// What it is *for* is the ratchet (`docs/design/ladder.md`, the 2026-09-17 progress rule): the +/// stall window is reset by exploration, and a fly crossing a town it has already covered to +/// reach the rung's own door earns no new ground while it does it. "Nearer the objective than +/// this run has ever been" is the other thing that is plainly progress, and it is this number +/// falling. Nothing about the *choice* reads it: no macro is ranked by it and no button is bound +/// on it. +pub fn hops(from: Region, to: u8) -> Option { + if from.map == to { + return Some(0); + } + let mut seen: HashSet = HashSet::from([from]); + let mut queue: VecDeque<(Region, u32)> = VecDeque::new(); + queue.push_back((from, 0)); + while let Some((region, depth)) = queue.pop_front() { + if region.map == to { + return Some(depth); + } + for next in region_neighbours(region) { + if seen.insert(next) { + queue.push_back((next, depth + 1)); + } + } + } + None +} + /// A building an area has at most one of, and which this run may not have been into yet. /// /// `docs/design/macros.md` section 13: the two errands. A kind rather than two parallel tables @@ -551,6 +592,68 @@ mod tests { ); } + #[test] + fn the_museums_two_floors_know_the_road_to_the_gym() { + // The rung-10 loop of 2026-09-22: five and a half hours on `PEWTER_CITY` with the + // objective two doors away, and inside the museum `GO OBJECTIVE` was off the pad because + // the map had no row here at all. Both floors, because the fly spent the run on both. + let at = |map: u8| Region::whole(map); + assert_eq!( + next_hop(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM), + Some(maps::PEWTER_CITY), + "the way to the gym from the museum's ground floor is out of its front door" + ); + assert_eq!( + next_hop(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM), + Some(maps::PEWTER_MUSEUM_1F), + "and from the upper floor it is the staircase" + ); + assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(maps::PEWTER_GYM)); + // The other direction, which is what `GO OBJECTIVE` asks when the errand is the museum's + // own town: the museum is one hop from Pewter City and two from its upper floor. + assert_eq!( + next_hop(at(maps::PEWTER_CITY), maps::PEWTER_MUSEUM_2F), + Some(maps::PEWTER_MUSEUM_1F) + ); + // And the area, which is what puts the town's errands on the pad indoors. The upper + // floor has no area, exactly as `REDS_HOUSE_2F` has none: a floor with no front door of + // its own is not "in" anywhere, so it offers no errand -- and the road out is still the + // staircase above, which is what the fly needs there. + assert_eq!(area_of(maps::PEWTER_MUSEUM_1F), Some(maps::PEWTER_CITY)); + assert_eq!(area_of(maps::PEWTER_MUSEUM_2F), None); + // The museum is neither a mart nor a centre, so it is nobody's errand. + assert_eq!(amenity_at(maps::PEWTER_MUSEUM_1F), None); + assert_eq!(amenity_at(maps::PEWTER_MUSEUM_2F), None); + } + + #[test] + fn the_hop_count_is_the_road_measured_rather_than_named() { + let at = |map: u8| Region::whole(map); + assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_CITY), Some(0), "already there"); + assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(1), "one door"); + assert_eq!(hops(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM), Some(2)); + assert_eq!(hops(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM), Some(3)); + // The count agrees with the hop by hop answer, which is the thing it has to: walking the + // road one `next_hop` at a time takes exactly this many steps. + let mut here = at(maps::PEWTER_MUSEUM_2F); + let mut steps = 0; + while let Some(hop) = next_hop(here, maps::PEWTER_GYM) { + here = Region::whole(hop); + steps += 1; + assert!(steps < 10, "the road to the gym does not wander"); + } + assert_eq!(here.map, maps::PEWTER_GYM); + assert_eq!(steps, 3); + // A split map is measured from the piece the fly is standing in, exactly as `next_hop` is. + assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1)); + // Five from the south half, because the belt of trees between the halves needs CUT and + // the road is the forest: the south gate, the forest, the north gate, Route 2's north + // half, Pewter. + assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5)); + // And nothing is guessed. + assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None); + } + #[test] fn a_maps_area_is_its_town_indoors_and_out() { // Outdoors a map is its own area, including a route -- which has no amenity row, so it 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 b5fd2de..4f094e9 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 @@ -476,7 +476,26 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec { // Section 2 treats `Unknown` like a dialog -- advance only -- and `BACK` is the other half // of advancing, because `Unknown` is also where the Pokédex, the trainer card and OPTION // land (`docs/design/macros-wram.md`) and B is what leaves all three. - Scene::Unknown => vec![Next, Back], + // + // **Only while there is something on screen with words in it** (section 12.13, the + // rung-10 Pewter loop). `Unknown` is the detector's residue and it holds two states, not + // one: a screen this crate cannot name, where A and B are what leave it, and a frame of + // the *overworld* where the cartridge is driving -- a warp in flight, a scripted + // push-back, the museum guide walking the fly through the door -- which + // `scene::detect` calls `Unknown` because the buttons are not reaching the player. On the + // second, `NEXT` and `BACK` are an A and a B pressed into somebody else's script: they + // change nothing, they complete where the fly stands, and they are section 12.2's trap + // with no text box to advance. Measured live on rung 10: `BACK` **678** macro starts in + // 47 minutes, 189 of them on map `0x02` with no box on screen at all. So the pad is + // empty there and the fly waits, which is the doctrine's own answer for a scene with + // nothing sensible to press -- and the cartridge gives the buttons back by itself. + Scene::Unknown => { + if state.text_open() { + vec![Next, Back] + } else { + Vec::new() + } + } // `NEXT`, `YES`, `NO` for a plain box -- A and B both advance one, and what the three // buy is the fly being *able* to answer no. On the one box that **is** a choice, the pad // is the choice's own answers: section 12.12. @@ -1369,6 +1388,16 @@ pub fn frontier_aims(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { if counter_pending(state) { return Vec::new(); } + // **A map whose frontier this run has already proved it cannot reach** (section 12.14, the + // rung-10 museum). The unstood tiles are still there and still unstood -- the exhibit hall + // behind the admission desk, the far side of a fence -- and a walk that could not reach any + // of them refuses `no route`, writes them all to the blocked ledger and comes back ten brain + // minutes later when the window lapses, for ever. The mark has no window; it is cleared by + // the fly standing somewhere on this map it had not stood before, which is the only thing + // that can have changed the answer. + if state.frontier_exhausted() { + return Vec::new(); + } let mut local: Vec<(Tile, Facing)> = Vec::new(); for (tile, facing) in path::frontier(state) { // A tile the blocked ledger is resting, or one a script pushes the fly off (row 37). 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 67a7b67..9847925 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 @@ -115,6 +115,11 @@ struct World { areas: BTreeSet<(Amenity, u8)>, /// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37). pushes: BTreeSet, + /// Maps a `GO FRONTIER` has proved it cannot reach the frontier of (section 12.14). + /// + /// Written by [`drive`] from the machine, exactly as `PokemonPalette` writes it in the sim + /// loop, so a test sees the ledger the next decision would see. + exhausted: BTreeSet, visited: BTreeSet, /// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test. stood: BTreeSet, @@ -164,6 +169,13 @@ struct World { /// A frame at which the cartridge heals the party, which is what a Pokémon Center does while /// its text box is open (`docs/design/macros.md` section 13). heal_at: Option, + /// Whether a text box is drawn on a scene that is not [`Scene::Dialog`] + /// ([`MacroState::text_open`]). + /// + /// `Dialog` *is* an open box, so the reading is true there by construction; the field is for + /// `Unknown`, which holds both a screen with words on it -- the Pokedex, the trainer card, + /// OPTION -- and a frame of the overworld the cartridge is driving (section 12.13). + box_open: bool, /// Whether the two-option YES/NO box is the thing on screen ([`MacroState::yes_no_prompt`]). /// /// A field rather than a shape of the `list`, because on the cartridge it is a *drawn box* @@ -225,6 +237,7 @@ impl World { counters: BTreeSet::new(), areas: BTreeSet::new(), pushes: BTreeSet::new(), + exhausted: BTreeSet::new(), stock: Vec::new(), visited: BTreeSet::new(), stood: BTreeSet::new(), @@ -243,6 +256,7 @@ impl World { pending: None, switch: None, heal_at: None, + box_open: false, prompt: false, scripted: false, scripted_at: None, @@ -658,6 +672,17 @@ impl MacroState for World { self.scripted } + /// `wFontLoaded` is set for every dialogue box, which is what `Dialog` is; on `Unknown` the + /// fixture has to say, because that is the reading that tells a screen from a scripted + /// overworld frame (section 12.13). + fn text_open(&mut self) -> bool { + self.scene == Scene::Dialog || self.box_open + } + + fn frontier_exhausted(&mut self) -> bool { + self.exhausted.contains(&self.map) + } + /// A drawn box is what the reading rests on, so a prompt cannot be open with no box open: /// `pokemon_red::state::yes_no_prompt` gates on `wFontLoaded` before it looks at the tiles. fn yes_no_prompt(&mut self) -> bool { @@ -758,6 +783,9 @@ fn drive( while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } + if let Some(map) = machine.take_exhausted() { + world.exhausted.insert(map); + } return Err(refused); } // A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack. @@ -779,6 +807,9 @@ fn drive( while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } + if let Some(map) = machine.take_exhausted() { + world.exhausted.insert(map); + } if let Some((map, target, closer)) = machine.take_timeout() { world.targets.record_timeout(map, target, closer); } @@ -990,12 +1021,31 @@ fn the_dialog_row_is_next_yes_and_no() { #[test] fn an_unknown_scene_is_dialog_with_advance_only() { let mut world = World::room(); + world.box_open = true; let palette = Palette::for_scene(Scene::Unknown, &mut world); // Row 9 of `infra/docs/macros-traps.md`, closed by section 13.1: B is what leaves the Pokédex, - // the trainer card and OPTION, and all three read `Unknown`. + // the trainer card and OPTION, and all three read `Unknown` with a box drawn. assert_eq!(names(&palette), ["NEXT", "BACK"]); } +#[test] +fn an_unknown_frame_with_no_box_on_it_deals_nothing() { + // Section 12.13, the rung-10 Pewter loop. The other half of `Unknown` is the overworld with + // the cartridge driving -- a warp in flight, a push-back, the museum guide walking the fly in + // -- where `scene::detect` falls through because the buttons are not reaching the player. + // There is no box to advance and no screen to leave, so `NEXT` and `BACK` are an A and a B + // pressed into somebody else's script: they change nothing and they complete where the fly + // stands, which is section 12.2's trap. The pad is empty and the fly waits. + let mut world = World::room(); + world.scripted = true; + assert!(!world.text_open()); + let palette = Palette::for_scene(Scene::Unknown, &mut world); + assert_eq!(palette.bound(), 0, "an A and a B into a script are not buttons"); + // And the moment the cartridge draws something, both are back. + world.box_open = true; + assert_eq!(names(&Palette::for_scene(Scene::Unknown, &mut world)), ["NEXT", "BACK"]); +} + #[test] fn the_menu_row_is_close_confirm_back() { let mut world = World::room(); @@ -2525,6 +2575,7 @@ fn a_forced_switch_plans_one_entry_and_the_other_scenes_plan_their_one_move() { ] { let mut world = World::room(); world.scene = scene; + world.box_open = scene == Scene::Unknown; assert_eq!(plan(&mut world), entries, "{}", scene.label()); } @@ -3139,6 +3190,10 @@ fn no_playable_scene_deals_an_empty_pad() { ] { let mut world = World::room(); world.scene = scene; + // `Unknown` is dealt on what is drawn (section 12.13): a screen with words on it has + // `NEXT` and `BACK`, and a scripted overworld frame is the one deliberate empty pad, + // which `an_unknown_frame_with_no_box_on_it_deals_nothing` is about. + world.box_open = scene == Scene::Unknown; world.battle = matches!(scene, Scene::Battle { .. }) .then_some((BattleKind::Wild, false, false)); assert!( @@ -3154,6 +3209,109 @@ fn no_playable_scene_deals_an_empty_pad() { assert_eq!(plan::plan_for(Scene::Title, &mut title).bound(), 0); } +#[test] +fn the_badges_rung_resolves_to_the_person_standing_in_the_gym() { + // Rung 11 is BOULDER BADGE and `docs/design/ladder.md` gives its place as a *person* in the + // Pewter gym (12.5's `PlaceKind`). What the rung-10 loop needed was the two halves of that + // working together: the road into map `0x36` (`geography`, the museum rows and the gym's + // own), and, once inside, the objective naming somebody to walk to rather than a map to be + // on. This is the second half, on the map the rung is earned on. + let mut world = World::room(); + world.map = maps::PEWTER_GYM; + // Both of Pewter's errands discharged. Section 13 puts an unvisited mart or centre *ahead* + // of the rung's place, and the gym is inside Pewter's area like everything else in the town, + // so until they are paid the objective is a building and not the leader. That is the errand + // working, and it is why the ROM run below spends its first minutes in the town's shops. + world.areas.insert((Amenity::Mart, maps::PEWTER_CITY)); + world.areas.insert((Amenity::Center, maps::PEWTER_CITY)); + world.npcs = vec![Npc { slot: 1, picture: 0x05, x: 4, y: 2, facing: Facing::Down }]; + world.objective = Some(Objective { + map: maps::PEWTER_GYM, + tile: None, + warp: None, + edge: None, + target: Some(PlaceKind::Person), + }); + let targets = super::palette::objective_targets(&mut world); + assert_eq!(targets.len(), 1, "the person the rung names: {targets:?}"); + assert_eq!(targets[0].0, Tile::new(4, 2)); + // And the walk aims at standing beside them and turning to face them, which is `GO NPC`'s own + // arrival and all `GO OBJECTIVE` ever promises: the press is `TALK`'s and the fly's. + let goals: Vec = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect(); + assert!(goals.contains(&Tile::new(4, 3)), "a tile beside them: {goals:?}"); + assert!(goals.iter().all(|tile| tile.distance(Tile::new(4, 2)) == 1), "{goals:?}"); + // The room the rung is in is not left while the thing that earns it is standing in it (12.5). + assert!(ways(&mut world, Way::Exit).is_empty(), "the gym's door is not a candidate yet"); + // Talked to, and the objective has nothing left here: the button leaves the pad and the ways + // out come back, which is what carries the run on to the next rung. + world.talked.insert(targets[0].1); + assert!(super::palette::objective_targets(&mut world).is_empty()); + assert!(objective_goals(&mut world).is_empty()); +} + +#[test] +fn a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off() { + // Section 12.14, the rung-10 museum. The sealed pocket below is map `0x34` in miniature: the + // unstood ground is real and it is fenced off, so `GO FRONTIER` refuses `no route` and writes + // every tile it could not reach to the blocked ledger -- which is a *window*. Before this the + // window lapsed after ten brain minutes and all of it was a candidate again: 1,235 starts in + // 47 minutes over two museum floors and a town. The mark has no window. + let mut world = World::room(); + for y in 0..8 { + for x in 0..8 { + world.stood.insert(Tile::new(x, y)); + } + } + world.stood.remove(&Tile::new(7, 7)); + world.walls.insert(Tile::new(6, 7)); + world.walls.insert(Tile::new(6, 6)); + world.walls.insert(Tile::new(7, 5)); + world.player = Tile::new(0, 0); + assert!(!super::palette::frontier_aims(&mut world).is_empty(), "the pocket is a frontier"); + assert!(precondition(MacroKind::GoFrontier, &mut world), "so the button is on the pad"); + + let refused = run(&mut world, MacroKind::GoFrontier).expect_err("the pocket is sealed"); + assert_eq!(refused.reason, Refusal::NoRoute); + assert!(world.exhausted.contains(&world.map), "the map is marked: {:?}", world.exhausted); + assert!(super::palette::frontier_aims(&mut world).is_empty(), "nothing left to aim at"); + assert!(!precondition(MacroKind::GoFrontier, &mut world), "and the button is off the pad"); + + // Ten brain minutes later the blocked window has lapsed and every tile of the pocket is a + // candidate again -- and the button is still off the pad, because the ground has not moved. + world.targets.clock(11.0 * 60_000.0); + assert!(!world.targets.blocked(world.map, TargetKey::Tile(Tile::new(7, 6)))); + assert!( + super::palette::frontier_aims(&mut world).is_empty(), + "the mark is not a window" + ); + // A map the fly walks to instead is untouched: the mark is one map's. + world.map = 0x35; + assert!(!super::palette::frontier_aims(&mut world).is_empty(), "another map is its own"); +} + +#[test] +fn a_frontier_mark_is_the_stood_ledgers_to_clear() { + // The two halves of the rule as the types have them: the stood ledger answers "this is + // ground the run had not stood on", which is the only event that can change which tiles the + // fly can reach, and that answer is what clears the map's mark. + let mut stood = super::cartridge::Stood::default(); + assert!(stood.record(2, Tile::new(4, 4)), "the first time is new ground"); + assert!(!stood.record(2, Tile::new(4, 4)), "the second time is not"); + assert!(stood.record(0x34, Tile::new(4, 4)), "and a tile is a tile of one map"); + + use super::cartridge::FrontierLedger; + let mut frontiers = super::cartridge::Frontiers::default(); + assert!(!frontiers.frontier_exhausted(0x34)); + frontiers.record(0x34); + frontiers.record(0x34); + assert!(frontiers.frontier_exhausted(0x34), "idempotent"); + assert!(!frontiers.frontier_exhausted(0x35), "and one map's"); + assert_eq!(frontiers.len(), 1); + frontiers.clear(0x34); + assert!(!frontiers.frontier_exhausted(0x34)); + assert!(frontiers.is_empty()); +} + #[test] fn a_walk_that_could_only_get_closer_now_excludes_what_it_cannot_reach() { // Two readings of the same pocket, in the order they were measured @@ -4087,6 +4245,9 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() { ] { let mut world = World::room(); world.scene = scene; + // See the sweep in `no_playable_scene_deals_an_empty_pad`: `Unknown` with nothing drawn + // on it is the overworld being driven by the cartridge, and its pad is empty by design. + world.box_open = scene == Scene::Unknown; world.mons.clear(); worst(&mut world); } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/maps.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/maps.rs index 986b6f4..fd1b4e8 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/maps.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/maps.rs @@ -18,6 +18,13 @@ //! `PEWTER_MART` `$38` and `PEWTER_POKECENTER` `$3a` sit in the five ids between `PEWTER_GYM` //! `$36` and `MT_MOON_1F` `$3b`; `CERULEAN_POKECENTER` `$40` and `CERULEAN_MART` `$43` sit around //! `CERULEAN_GYM` `$41`. [`super::tests`] pins each against its neighbour rather than on its own. +//! +//! **2026-09-22, the rung-10 museum.** `PEWTER_MUSEUM_1F` `$34` and `PEWTER_MUSEUM_2F` `$35` are +//! the two ids between `VIRIDIAN_FOREST` `$33` and `PEWTER_GYM` `$36`, and both are confirmed by +//! the cartridge rather than by counting: Pewter City's warp table names `$34` at (14, 7) and at +//! (19, 5) -- the museum's two doors -- and `$34`'s own warp at (7, 7) names `$35`, which is the +//! staircase. Surveyed from the release container's rung-10 checkpoint +//! (`infra/docs/macros-traps.md`, 2026-09-22). // Outdoors: `$00`..`$24`. pub const PALLET_TOWN: u8 = 0x00; @@ -68,6 +75,8 @@ pub const VIRIDIAN_GYM: u8 = 0x2d; pub const VIRIDIAN_FOREST_NORTH_GATE: u8 = 0x2f; pub const VIRIDIAN_FOREST_SOUTH_GATE: u8 = 0x32; pub const VIRIDIAN_FOREST: u8 = 0x33; +pub const PEWTER_MUSEUM_1F: u8 = 0x34; +pub const PEWTER_MUSEUM_2F: u8 = 0x35; pub const PEWTER_GYM: u8 = 0x36; pub const PEWTER_MART: u8 = 0x38; pub const PEWTER_POKECENTER: u8 = 0x3a; 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 f2913c3..5eb6109 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -25,8 +25,9 @@ use crate::adapter::{MapEdge, MapExit, MapTile, MemoryReader}; use crate::macros::{RunLedger, NoLedger}; use super::macros::cartridge::{ - AreaLedger, Edge, ExitId, MacroState, NoAreas, NoPushed, NoStood, NoTalk, NoTargets, - Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile, + AreaLedger, Edge, ExitId, FrontierLedger, MacroState, NoAreas, NoFrontiers, NoPushed, NoStood, + NoTalk, NoTargets, Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, + TargetLedger, Tile, }; use super::macros::geography::Amenity; use super::mapgrid::{self, MapGrids}; @@ -937,6 +938,33 @@ impl GridRefusal { /// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with /// the picture buffer and a battle is exactly when the blocks under it are somebody else's. pub fn map_grid(memory: &mut dyn MemoryReader) -> Result { + // The decode first, so a frame with no header answers `NoHeader` rather than whatever the + // player's coordinates happen to read as: the refusals are in the order they are checked. + let grid = map_grid_decode(memory)?; + let player = player(memory).ok_or(GridRefusal::NoPlayer)?; + // The cross-check. `map_tile_id` reads the screen buffer at the offset + // `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for + // is agreeing with the cartridge's own reading of the same ground. + let mut checked = 0; + for (x, y) in neighbourhood(player.x, player.y) { + let Some(screen) = map_tile_id(memory, x, y) else { continue }; + if grid.tile_id(x, y) != Some(screen) { + return Err(GridRefusal::ScreenDisagrees); + } + checked += 1; + } + if checked == 0 { + return Err(GridRefusal::NoScreen); + } + Ok(grid) +} + +/// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded. +/// +/// Split out so [`grid_disagreement`] can say what the decode answered on a frame the check +/// refused. Nothing outside this module and the probes may use it: a grid that has not been +/// checked against the screen is exactly the reading section 15 refuses to trust. +fn map_grid_decode(memory: &mut dyn MemoryReader) -> Result { let size = map_size(memory).ok_or(GridRefusal::NoHeader)?; let player = player(memory).ok_or(GridRefusal::NoPlayer)?; let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?; @@ -979,23 +1007,28 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result { if grid.width() != size.width || grid.height() != size.height { return Err(GridRefusal::NoHeader); } - // The cross-check. `map_tile_id` reads the screen buffer at the offset - // `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for - // is agreeing with the cartridge's own reading of the same ground. - let mut checked = 0; - for (x, y) in neighbourhood(player.x, player.y) { - let Some(screen) = map_tile_id(memory, x, y) else { continue }; - if grid.tile_id(x, y) != Some(screen) { - return Err(GridRefusal::ScreenDisagrees); - } - checked += 1; - } - if checked == 0 { - return Err(GridRefusal::NoScreen); - } Ok(grid) } +/// The decode and the screen, tile by tile, for the five tiles [`map_grid`] cross-checks. +/// +/// The diagnostic half of [`GridRefusal::ScreenDisagrees`]: the refusal says the two readings +/// disagree and this says *where* and *by how much*, which is the difference between "the grid is +/// off on this map" and "this frame was mid-warp". `(x, y, decoded, screen)`, with `None` for a +/// tile either reading cannot answer for. It decodes the map a second time rather than being +/// folded into [`map_grid`], because the check's job on the hot path is to refuse and this is only +/// ever asked by a probe. +pub fn grid_disagreement(memory: &mut dyn MemoryReader) -> Vec<(u8, u8, Option, Option)> { + let Some(player) = player(memory) else { return Vec::new() }; + let grid = map_grid_decode(memory).ok(); + neighbourhood(player.x, player.y) + .into_iter() + .map(|(x, y)| { + (x, y, grid.as_ref().and_then(|grid| grid.tile_id(x, y)), map_tile_id(memory, x, y)) + }) + .collect() +} + /// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on. /// /// The map id, the map header and the block data are written by different parts of a warp, so @@ -1164,6 +1197,10 @@ pub struct PokeState<'a> { /// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a /// refcount instead of a map. grids: Option<&'a mut MapGrids>, + /// Which maps have proved their frontier unreachable (`docs/design/macros.md` section + /// 12.14). A builder rather than a constructor parameter, exactly as the grid cache is: the + /// sim loop passes one and everything else narrows to "nothing proved". + frontiers: &'a dyn FrontierLedger, } impl<'a> PokeState<'a> { @@ -1182,6 +1219,7 @@ impl<'a> PokeState<'a> { areas: &NoAreas, pushed: &NoPushed, grids: None, + frontiers: &NoFrontiers, } } @@ -1196,6 +1234,7 @@ impl<'a> PokeState<'a> { areas: &NoAreas, pushed: &NoPushed, grids: None, + frontiers: &NoFrontiers, } } @@ -1213,7 +1252,7 @@ impl<'a> PokeState<'a> { areas: &'a dyn AreaLedger, pushed: &'a dyn PushedLedger, ) -> Self { - Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None } + Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None, frontiers: &NoFrontiers } } /// Keep the decoded map grid in `grids` instead of decoding it per question. @@ -1226,6 +1265,16 @@ impl<'a> PokeState<'a> { self.grids = Some(grids); self } + + /// Answer [`MacroState::frontier_exhausted`] from `frontiers` instead of "nothing proved". + /// + /// A builder for the same reason the grid cache is one: it is the sim loop's own session + /// state ([`super::macros::driver::PokemonPalette`]) and every other caller -- the tests, the + /// probes, the ROM harnesses -- wants the narrowing. + pub fn with_frontiers(mut self, frontiers: &'a dyn FrontierLedger) -> Self { + self.frontiers = frontiers; + self + } } impl GameState for PokeState<'_> { @@ -1310,6 +1359,14 @@ impl MacroState for PokeState<'_> { !controllable(self.memory) } + fn text_open(&mut self) -> bool { + text_box(self.memory).open + } + + fn frontier_exhausted(&mut self) -> bool { + player(self.memory).is_some_and(|player| self.frontiers.frontier_exhausted(player.map)) + } + fn yes_no_prompt(&mut self) -> bool { yes_no_prompt(self.memory) } diff --git a/services/flysim/crates/flybrain-gb/src/ratchet.rs b/services/flysim/crates/flybrain-gb/src/ratchet.rs index cca7aba..39e73f7 100644 --- a/services/flysim/crates/flybrain-gb/src/ratchet.rs +++ b/services/flysim/crates/flybrain-gb/src/ratchet.rs @@ -186,6 +186,44 @@ impl Ratchet { game_over: bool, capture: F, ) -> bool + where + F: FnOnce() -> Snapshot, + { + self.observe_with_progress(safe, rank, coverage, now, game_over, false, capture) + } + + /// [`Ratchet::observe_with_game_over`] plus a progress signal the coverage figure cannot + /// carry. + /// + /// `progressed` is the caller saying "something happened on this sample that is plainly + /// progress, and it is not a number that only grows". The stall window is restarted by it + /// exactly as a rise in `coverage` restarts it, and nothing else changes: no budget is + /// spent, no snapshot is taken and no trigger is skipped. + /// + /// It exists because coverage is the *only* progress the window could see, and coverage is + /// ground never stood on. A fly crossing a town it has already covered to reach the rung's + /// own door is getting somewhere and earns none. Measured on rung 10, 2026-09-22: two + /// "Stuck" rollbacks inside half an hour, both on a fly that was walking, each one landing + /// it back where it had started. What the Pokemon loop passes here is "nearer the objective, + /// in map hops, than this run has ever been" (`docs/design/macros.md` section 12.15), which + /// can only fire as many times as the road is long. + /// + /// Nothing in this module knows what the signal means, which is the same bargain `coverage` + /// is: the ladder, the map graph and the objective all belong to the adapter. + // One argument past clippy's seven, and the argument is the point: this is the whole of one + // sample. A struct for it would be a type that exists only to be destructured at the one + // call site the sim loop has, which is the same idiom `decoder.rs` and `lif.rs` take here. + #[allow(clippy::too_many_arguments)] + pub fn observe_with_progress( + &mut self, + safe: bool, + rank: u64, + coverage: u64, + now: u64, + game_over: bool, + progressed: bool, + capture: F, + ) -> bool where F: FnOnce() -> Snapshot, { @@ -200,7 +238,7 @@ impl Ratchet { } else { self.unsafe_since = None; } - if coverage > self.state.coverage { + if coverage > self.state.coverage || progressed { self.state.last_progress = now; } self.state.coverage = self.state.coverage.max(coverage); @@ -458,4 +496,51 @@ mod tests { assert!(!ratchet.observe(true, 1, 2, 230_000, || snapshot(1))); assert!(ratchet.observe(true, 1, 2, 239_001, || snapshot(1))); } + + #[test] + fn a_map_the_run_has_never_stood_on_is_coverage_and_resets_the_window() { + // The half of the 2026-09-17 rule that already held, pinned because the other half is + // new: entering a map for the first time is ground never stood on, so it arrives here as + // a rise in `coverage` and needs nothing of its own. Re-entering a map the run has + // covered is not, which is exactly the rung-10 museum and why the signal below exists. + let mut ratchet = Ratchet::new(); + ratchet.observe(true, 1, 100, 0, || snapshot(1)); + // A new map: eighty tiles nobody had stood on. + assert!(!ratchet.observe(true, 1, 180, 100_000, || snapshot(1))); + // Walking it again for two minutes adds none, and the window ages from the arrival. + assert!(!ratchet.observe(true, 1, 180, 219_000, || snapshot(1))); + assert!(ratchet.observe(true, 1, 180, 220_001, || snapshot(1))); + } + + #[test] + fn getting_nearer_the_objective_resets_the_stall_window_without_spending_anything() { + // The rung-10 rollbacks of 2026-09-22: a fly walking a town it has already covered + // toward the rung's own door earns no coverage while it does it, and the window aged out + // twice in half an hour. A caller's progress signal restarts the window exactly as + // coverage does -- and does nothing else. + let mut ratchet = Ratchet::new(); + ratchet.observe(true, 1, 100, 0, || snapshot(1)); + // 119 s of walking ground the run has covered, and then one step nearer the gym. + assert!(!ratchet.observe_with_progress(true, 1, 100, 119_000, false, true, || snapshot(1))); + let state = ratchet.state; + assert_eq!(state.last_progress, 119_000); + assert_eq!((state.recoveries, state.attempts), (0, 0), "progress spends no budget"); + // The window now runs from there rather than from the start. + assert!(!ratchet.observe(true, 1, 100, 238_000, || snapshot(1))); + assert!(ratchet.observe(true, 1, 100, 239_001, || snapshot(1))); + } + + #[test] + fn a_progress_signal_cannot_conjure_a_recovery_or_skip_a_trigger() { + // It restarts the window; it is not a trigger and it is not a snapshot. With nothing + // archived there is nothing to recover to, and an unsafe sample still never recovers. + let mut ratchet = Ratchet::new(); + assert!(!ratchet.observe_with_progress(true, 0, 0, 0, false, true, || snapshot(1))); + assert!(ratchet.snapshot.is_none(), "progress captures nothing"); + let mut armed = Ratchet::new(); + armed.observe(true, 1, 1, 0, || snapshot(1)); + // Unsafe, stalled, and told that something progressed: still no recovery, because an + // unsafe sample would restore on top of whatever made it unsafe. + assert!(!armed.observe_with_progress(false, 1, 1, 400_000, false, true, || snapshot(1))); + } } diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 37c447b..d33beeb 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -31,6 +31,8 @@ //! | `FLY_PROBE_FRAMES` | 200000 | frames to drive before giving up | //! | `FLY_PROBE_STUCK` | 600 | consecutive frames in one non-overworld scene that count as stuck | +use std::collections::BTreeMap; + use flybrain_core::decoder::PopulationDecoder; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros; use flybrain_core::ordered::NumberMap; @@ -161,6 +163,16 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { // same call `MacroState::map_grid` makes, and the only reading that can say *which* of section // 15's refusals a frame is. let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err(); + // Which tile the decode and the screen disagree about, when that is the refusal. The label + // alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the + // two want opposite fixes (`docs/design/macros.md` section 15). Read here, beside the refusal + // itself, because everything below holds a borrow of the emulator. + let disagreement = match refusal { + Some(flybrain_gb::pokemon_red::state::GridRefusal::ScreenDisagrees) => { + flybrain_gb::pokemon_red::state::grid_disagreement(gb) + } + _ => Vec::new(), + }; let ledger = AdapterLedger(adapter); let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); let state: &mut dyn MacroState = &mut poke; @@ -212,6 +224,22 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { ); } } + // Which tile the decode and the screen disagree about, when that is the refusal. The label + // alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the + // two want opposite fixes (`docs/design/macros.md` section 15). + if !disagreement.is_empty() { + println!("- the decode against the screen, tile by tile:"); + for (x, y, decoded, screen) in disagreement { + println!( + " - ({x:2}, {y:2}) decoded {decoded:?} screen {screen:?}{}", + if decoded.is_some() && screen.is_some() && decoded != screen { + " <- the disagreement" + } else { + "" + } + ); + } + } // The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The // session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's // palette, which this probe does not reach into, so a doormat the running fly has already @@ -710,6 +738,25 @@ fn main() { && surveyed >= 60 { println!("\nThe fly reached map {map:#04x} at frame {frame}."); + // Stand still for a while and count how many of those frames the whole-map grid can be + // decoded on. A walking fly is mid-step on most frames -- `wYCoord` is the tile it is + // walking *to* while the background is still scrolling -- and the screen buffer the + // cross-check reads is the one that is a tile behind, so "is the grid refused on this + // map" and "is the grid refused while the fly is moving" are different questions with + // different fixes (`docs/design/macros.md` section 15). + let mut refusals: BTreeMap<&'static str, usize> = BTreeMap::new(); + for _ in 0..env_usize("FLY_PROBE_SETTLE", 120) { + gb.set_buttons(flybrain_gb::buttons::NONE); + gb.run_frame().expect("a frame should complete"); + ms += MS_PER_FRAME; + adapter.sample(&mut gb, ms); + let label = match flybrain_gb::pokemon_red::state::map_grid(&mut gb) { + Ok(_) => "decoded", + Err(refusal) => refusal.label(), + }; + *refusals.entry(label).or_insert(0) += 1; + } + println!("\nStanding still on it, frame by frame: {refusals:?}"); cartridge(&mut gb, &adapter, "The save on the surveyed map"); pad(&mut gb, &adapter, "The pad on the surveyed map"); return; diff --git a/services/flysim/crates/flysim/src/macros.rs b/services/flysim/crates/flysim/src/macros.rs index a93c45c..95f5be5 100644 --- a/services/flysim/crates/flysim/src/macros.rs +++ b/services/flysim/crates/flysim/src/macros.rs @@ -500,6 +500,15 @@ impl MacroLayer { /// The sim loop passes this to [`flybrain_core::decoder::PopulationDecoder::decode_bound`] on /// the same frame, which is why it is the layer's to answer: the bindings are what the last /// `observe` dealt, and nothing else in the loop knows them. + /// Whether this frame saw the fly nearer its objective than the run has managed before. + /// + /// Straight through from the palette ([`MacroPalette::nearer_the_objective`]), for the + /// ratchet's stall window and for nothing else: the macro layer neither reads it back nor + /// presses anything because of it. + pub fn nearer_the_objective(&self) -> bool { + self.palette.nearer_the_objective() + } + pub fn bound_channels(&self) -> Vec { self.bindings .iter() diff --git a/services/flysim/crates/flysim/src/simloop.rs b/services/flysim/crates/flysim/src/simloop.rs index 8a1e852..ecf3440 100644 --- a/services/flysim/crates/flysim/src/simloop.rs +++ b/services/flysim/crates/flysim/src/simloop.rs @@ -1054,12 +1054,20 @@ impl Sim { } else { None }; - let recover = self.ratchet.observe_with_game_over( + // The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17 + // rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a + // town it has already covered to reach the rung's own door earns none of it while it is + // plainly getting somewhere. The macro layer answers with the map graph it already walks + // (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no + // objective, and the answer is false. + let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective); + let recover = self.ratchet.observe_with_progress( safe, u64::from(progress.rank), progress.unique_locations as u64, ms as u64, self.adapter.game_over(), + nearer, || captured.expect("the ratchet only captures when a snapshot was prepared"), ); if recover { diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 633d0b3..e1ab6e6 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -63,6 +63,10 @@ const VIRIDIAN_FOREST: u32 = 0x33; const PEWTER_POKECENTER: u32 = 0x3a; /// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside. const MUSEUM_2F: u32 = 0x35; +/// Pewter City and its two museum floors and gym: the rung-10 loop's whole world. +const PEWTER_CITY: u32 = 0x02; +const MUSEUM_1F: u32 = 0x34; +const PEWTER_GYM: u32 = 0x36; /// 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; @@ -266,6 +270,31 @@ struct Run { /// /// The door into the ring (section 12.12). `false` is the claim. talk_at_a_rested_nurse: bool, + /// `BACK` starts on a frame the scene detector called `dialog` or `unknown` (section 12.13). + /// + /// The same attribution `examples/trap_hunt.rs` makes, so the number is comparable with the + /// residual it comes from: **189** `BACK` in a text box on map `0x02` in twenty brain + /// minutes, and 678 starts in the 47 live minutes. `BACK` is on no overworld and no dialog + /// pad, so every one of them was dealt by `Unknown`. + back_in_a_box: u32, + /// Frames where the scene was `unknown`, nothing was drawn, and the pad was **not** empty. + /// + /// Section 12.13's claim, and it is zero: a frame of the overworld the cartridge is driving + /// has nothing an A or a B press can do, so it deals nothing and the fly waits. + unknown_pads_with_no_box: u32, + /// Maps on whose overworld pad `TALK` was ever bound: where the fly stood facing somebody + /// this run has not talked to. + /// + /// Rung 11 is BOULDER BADGE and its place is a *person* in map `0x36`, so "the fly reaches + /// the leader" is this map being in the set: the fly inside the gym, in front of one of its + /// people, with the press that talks to them on the pad. Whether it presses is the fly's + /// (section 12). Read off the shipping pad rather than recomputed, so it cannot disagree + /// with what the fly was actually offered. + talk_on_pad_by_map: std::collections::BTreeSet, + /// `TALK` starts by map, for the record beside it. + talk_starts_by_map: std::collections::BTreeMap, + /// `GO FRONTIER` starts by map, for the museum (section 12.14). + frontier_by_map: std::collections::BTreeMap, } impl Run { @@ -362,6 +391,11 @@ impl Run { prompt_frames: 0, next_on_a_prompt: false, talk_at_a_rested_nurse: false, + back_in_a_box: 0, + unknown_pads_with_no_box: 0, + talk_on_pad_by_map: std::collections::BTreeSet::new(), + talk_starts_by_map: std::collections::BTreeMap::new(), + frontier_by_map: std::collections::BTreeMap::new(), menu_alternation: 0, last_start: None, } @@ -463,6 +497,11 @@ impl Run { prompt_frames: 0, next_on_a_prompt: false, talk_at_a_rested_nurse: false, + back_in_a_box: 0, + unknown_pads_with_no_box: 0, + talk_on_pad_by_map: std::collections::BTreeSet::new(), + talk_starts_by_map: std::collections::BTreeMap::new(), + frontier_by_map: std::collections::BTreeMap::new(), menu_alternation: 0, last_start: None, } @@ -766,6 +805,19 @@ impl Run { *self.started_on_the_first_map.entry(name).or_insert(0) += 1; } *self.started.entry(name).or_insert(0) += 1; + // Section 12.13: which of the two `Unknown`s a `BACK` was dealt on, attributed + // exactly as the trap hunt attributes it so the numbers can be compared. + if name == "BACK" && matches!(self.layer.scene_name(), "dialog" | "unknown") { + self.back_in_a_box += 1; + } + if name == "GO FRONTIER" { + let map = self.map(); + *self.frontier_by_map.entry(map).or_insert(0) += 1; + } + if name == "TALK" { + let map = self.map(); + *self.talk_starts_by_map.entry(map).or_insert(0) += 1; + } } self.gb.set_buttons(mask as u8); self.gb.run_frame().expect("a frame should complete"); @@ -810,6 +862,13 @@ impl Run { } } } + // Section 12.13's claim, per frame: an `Unknown` with nothing drawn deals nothing. + if self.layer.scene_name() == "unknown" + && !flybrain_gb::pokemon_red::state::text_box(&mut self.gb).open + && !self.layer.bound_channels().is_empty() + { + self.unknown_pads_with_no_box += 1; + } if self.layer.scene_name() == "overworld" && self.rested_nurse() && self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk") @@ -827,6 +886,11 @@ impl Run { if dealt.iter().any(|channel| channel.as_str() == "macro_menu") { self.menu_on_pad.insert(map); } + // Standing in front of somebody this run has not talked to, which on the gym's own + // map is standing in front of one of the people rung 11's place names. + if dealt.iter().any(|channel| channel.as_str() == "macro_talk") { + self.talk_on_pad_by_map.insert(map); + } if dealt.is_empty() { self.empty_overworld_pads.insert(map); } @@ -2181,3 +2245,139 @@ fn the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint() { run.macros_on_the_first_map ); } + +/// The rung-10 Pewter checkpoint, or `None` to skip. +fn pewter_checkpoint() -> Option { + std::env::var_os("FLY_PEWTER_CHECKPOINT").map(|path| { + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope") + }) +} + +/// From the rung-10 Pewter checkpoint: the fly gets out of the museum and into the gym. +/// +/// **What was live** (2026-09-22, v0.4.5, rank 10 PEWTER CITY, five and a half hours on the +/// rung): the fly on **map 0x34**, the museum's ground floor, and since the 11:30 restart the +/// macro starts were `GO FRONTIER` **1,235**, `BACK` **678**, `GO OBJECTIVE` 267, `GO OUT` 242, +/// `YES` 169 -- with the objective, rung 11's BOULDER BADGE, two doors away in map `0x36`. Two +/// "Stuck" rollbacks fired inside the last half hour and put the fly back where it started. +/// +/// **What the survey found** (`infra/docs/macros-traps.md`, 2026-09-22, and +/// `examples/scene_probe.rs`): three things, none of them the text box the brief expected. +/// +/// - `BACK` was not in a text box at all. It is on no overworld and no dialog pad, so every one +/// of those 678 was `Scene::Unknown` -- which is *also* what a frame of the overworld reads as +/// while the cartridge drives the fly through a door. An A and a B press into somebody else's +/// script (section 12.13). +/// - the museum had **no row on the map graph**, so from inside it `next_hop` answered nothing +/// and `GO OBJECTIVE` had no goal: the road to the gym did not exist indoors (12.7's rule, and +/// the residual the previous review named). +/// - the museum's frontier is 39 tiles the run has never stood on, of which almost all are +/// behind the admission desk: unreachable, refused `no route`, excluded for ten brain minutes +/// by the blocked ledger and then offered again, once per hold (section 12.14). +/// +/// The claims, none of them about which button the fly presses: +/// +/// - the fly reaches **map 0x36**, the gym's own interior, on a bounded number of macros; +/// - it stands **facing the thing the rung names** while it is there, which is all `GO OBJECTIVE` +/// ever promises (12.5); +/// - `BACK` on a `dialog` or `unknown` frame stays **under five**, against 189 in the hunt; +/// - no frame deals a pad on an `Unknown` with nothing drawn on it. +/// +/// ```sh +/// FLY_ROM=/path/to/pokemon-red.gb \ +/// FLY_PEWTER_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \ +/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture +/// ``` +#[test] +fn the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint() { + let rom = skip_without_rom!(); + let Some(checkpoint) = pewter_checkpoint() else { + eprintln!("skipped: no FLY_PEWTER_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint); + let from = run.map(); + assert_eq!(from, MUSEUM_1F, "the checkpoint is the room the stream stalled in"); + + let mut reached = None; + let mut macros_to_the_gym = 0; + for frame in 0..200_000u32 { + run.frame(); + if reached.is_none() { + macros_to_the_gym = run.started.values().sum::(); + if run.map() == PEWTER_GYM { + reached = Some(frame); + } + } + } + eprintln!( + "from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, GO FRONTIER by \ + map {:?}, TALK on the pad on {:?}, TALK starts {:?}, BACK in a box {}, unknown pads \ + with no box {}", + run.ms / 60_000.0, + run.route, + run.started, + run.frontier_by_map, + run.talk_on_pad_by_map, + run.talk_starts_by_map, + run.back_in_a_box, + run.unknown_pads_with_no_box + ); + + let Some(frame) = reached else { + panic!( + "the fly never reached the gym (map {:#04x}, route {:?}, macros {:?})", + run.map(), + run.route, + run.started + ) + }; + let progress = run.adapter.progress(); + eprintln!( + "it reached map {PEWTER_GYM:#04x} on frame {frame}, on {macros_to_the_gym} macros; \ + the run ends on rank {} ({}) with {} badges", + progress.rank, + progress.rank_label, + run.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wObtainedBadges).count_ones() + ); + assert!( + macros_to_the_gym < 600, + "reaching the gym cost {macros_to_the_gym} macros: {:?}", + run.started + ); + assert!( + run.talk_on_pad_by_map.contains(&PEWTER_GYM), + "the fly never stood in front of one of the gym's people: {:?}", + run.talk_on_pad_by_map + ); + assert!( + run.objective_on_pad.contains(&PEWTER_GYM), + "`GO OBJECTIVE` had nothing to aim at inside the gym: {:?}", + run.objective_on_pad + ); + // Section 12.13, against the 189 the hunt measured on map 0x02 alone. + assert!( + run.back_in_a_box < 5, + "`BACK` was dealt {} times on a dialog or unknown frame", + run.back_in_a_box + ); + assert_eq!( + run.unknown_pads_with_no_box, 0, + "a pad was dealt on an unknown frame with nothing drawn on it" + ); + // Section 12.14: the museum's frontier is proved unreachable once and then left alone. The + // live run started GO FRONTIER 1,235 times over these two floors and a town. + let museum: u32 = run.frontier_by_map.get(&MUSEUM_1F).copied().unwrap_or(0) + + run.frontier_by_map.get(&MUSEUM_2F).copied().unwrap_or(0); + assert!( + museum < 40, + "`GO FRONTIER` started {museum} times on the museum's floors: {:?}", + run.frontier_by_map + ); + assert!( + run.route.contains(&PEWTER_CITY), + "the road to the gym is out of the museum's front door: {:?}", + run.route + ); +}