Compare commits

...

6 commits

Author SHA1 Message Date
acamilo
592c2640a3 docs: v0.4.2 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 04:12:59 +00:00
acamilo
e2082e3699 Merge fix/loop-20260922T0254: between-turns pad without a listless BACK, THROW BALL skips held species 2026-09-22 04:12:58 +00:00
acamilo
18e16819ca docs: section 12.9, and rows 38 to 41 of the trap audit
The rung-9 forest review: what was live, the mechanism of all three
observations, the audit rows, and the trap hunt before and after from the
release container's own checkpoint.

The hunt is brain-driven rather than stubbed, which is legitimate here and
was not for sections 13 and 14: this branch adds no macro type, so no
population is re-dealt and the two arms differ in the macro code alone.
Distinct tiles 216 to 286, windows flagged 70 of 73 to 61, windows under
four tiles 21 to 5, BACK starts with no list open 175 to 0, MOVE n starts
36 to 68.

Four residuals are named rather than buried: BACK is still 359 starts of
830 over open lists, which is row 34's contract; a NEXT/BACK two-cycle
closes the run inside one battle, from a move list whose cursor the seam
cannot place on its opening frames; a window spent in a battle is flagged
by the tile rule whatever happens in it; and the THROW BALL precondition
is inert in this run because the save carries a party of one.

Row 41 is the loop behind this one: in the before arm the fly answered the
nurse's box YES 474 times on one tile of a Pokemon Center for the last
eight brain minutes of the run. The after arm never enters it, so it is
neither reproduced nor fixed here. It is the next thing to measure.
2026-09-22 04:09:05 +00:00
acamilo
7d5c788876 tests: the rung-9 forest checkpoint, on the cartridge
A ROM-gated run from the checkpoint the stream stalled on, gated on its
own variable because the two checkpoint tests beside it assert the map
their envelope is on and one envelope cannot be both.

What it holds: the objective is Pewter City, the road there is on the pad,
BACK is never on a battle pad with no list open, no ball is thrown at a
species the party holds, the fly gets turns, a move or a ball is chosen,
and a battle ends. Which move and which way it walks are the fly's, so
they are reported rather than asserted -- including the forest's north
gate, which this run does not reach.
2026-09-22 03:28:28 +00:00
acamilo
ce02a89922 macros: BACK belongs to a list, and no ball at a species the party holds
Two traps from the rung-9 forest checkpoint, both section 12.2's rule: a
macro that completes without moving because its precondition is already
satisfied where the fly stands.

BACK was dealt by the between-turns row, which section 13.1 gave NEXT and
BACK for the sake of the battle bag -- the bag reads as nobody's turn, so
it lands there -- and which is also every frame of battle text. With
nothing open there is nothing to back out of, so the press completes in
fifteen frames on the tile it started on: 163 of 959 macro starts in
twenty brain minutes from the checkpoint, plus twelve more on a move list
whose cursor the seam cannot place. The row is now the sub-state's: BACK
with the bag open, NEXT alone otherwise. The move list and the party list
keep it, and the top-level menu never had it.

THROW BALL gains a fourth fact: the species on the other side is not one
the party already holds. The party is the caught set, because it is the
cartridge's own lifetime record in the same internal numbering the enemy
is read in; wPokedexOwned is by Pokedex number and the table that converts
one to the other is in a ROM bank this crate cannot read. An enemy the
seam cannot place leaves the button where it was.

RUN is unchanged. Its blocked outcomes are the script and not the
cartridge: a cursor macro started as the menu closes waits out CURSOR_WAIT
pressing nothing (mean 184 frames over ten starts), which is audit row 19.

The decoder, the reward catalog, the adapter version and the compatibility
string are untouched: --print-compatibility is byte-identical at 648 bytes.
2026-09-22 03:28:20 +00:00
acamilo
097a11196b trap hunt: report the battle sub-state, its pad and the starts on it
A scene histogram says "battle" and a battle deals five different pads
(docs/design/macros.md 12.6, 13.1), so a loop inside one of them is
invisible in the tables the hunt already prints. Three more: frames per
sub-state, every macro channel ever bound in each one, and the macro
starts by the sub-state they started on.
2026-09-22 03:13:27 +00:00
7 changed files with 630 additions and 13 deletions

View file

@ -806,6 +806,59 @@ list, whose only bound button with one Pokémon is `BACK`. Six hundred thousand
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.9 `BACK` belongs to a list, and a ball is not thrown at what the party already has (2026-09-22, rung 9, sixty-nine hours)
Rank 9 (VIRIDIAN FOREST, next PEWTER CITY), sixty-nine hours on the rung, the ratchet's three
attempts spent, the fly on Route 2 and in the forest and mostly in wild battles. Since the restart
the macro starts were `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 and `GO ROUTE` 5, and the
event log repeated `RUN blocked, BACK start, BACK done`. `infra/docs/macros-traps.md` has the
reproduction and the numbers; two macros, and section 12.2's one rule between them.
- **`BACK` is a button only where there is a list to leave.** The top-level battle menu never had
it and does not now: FIGHT, PKMN, ITEM and RUN are the four answers to it and B is not a fifth.
What dealt it was the *between-turns* row, which section 13.1 gave `NEXT, BACK` for the sake of
the battle bag -- the bag reads as nobody's turn (12.6), so it lands there -- and which is also
every frame of battle text, every animation and every turn resolving. On those frames there is
nothing open, so the B press changes nothing the `NEXT` beside it does not, the macro completes
in a handful of frames on the tile it started on, and half the pad is a button that cannot move
the game on. That is exactly **a macro that completes without moving because its precondition is
already satisfied where the fly stands** (12.2). The row is now the sub-state's, like the own
turn's: `BACK` with the bag open, `NEXT` alone otherwise, and the move list and the party list
keep it as before.
- **`RUN blocked` was the script, not the cartridge.** Not "can't escape": `RUN` is one cursor
navigation, and a cursor macro whose list is not accepting input *waits* rather than pressing
blind (section 4), for `CURSOR_WAIT` = 180 frames before it reports `Blocked`. So a `RUN` that
won a hold as the menu closed spent three brain seconds pressing nothing and then gave up --
row 19 of the audit, bounded and left, and the reason it was visible at all is the `BACK` above
filling the frames on either side of it. `RUN`'s own precondition is unchanged (13.1: only a
wild battle the fly is losing).
- **A ball is not thrown at a species the party already holds.** `THROW BALL`'s precondition was
three facts -- a wild battle, a ball in the bag, room in the party -- and it now has a fourth:
the Pokémon on the other side is not one the party already has. Viridian Forest holds five
species and the fly had caught its own, so every throw spent a ball on a Caterpie or a Weedle it
was carrying, and a catch opens the nickname screen, which reads `Unknown`, needs the START the
pad has no button for (row 14), and is the one screen in the game neither `NEXT` nor `BACK`
leaves. The party is the caught set here and it is the honest one: it is the cartridge's own
lifetime record, it survives the restart the session ledgers do not, and it is in the same
numbering the enemy is read in -- the internal species index. **`wPokedexOwned` is not asked**,
because that bitset is by Pokédex *number* and the table converting an internal index into one
lives in a ROM bank this crate cannot read: `docs/design/ladder.md`'s rule is that an unverified
number does not go in. It costs nothing measurable -- the button is already off the pad while
the party is full and nothing in the vocabulary deposits into a box (row 17), so every species
this run has caught is in the party this reads. An enemy the seam cannot place leaves the button
where it was: a precondition this crate cannot observe is not a precondition (13.1).
- **The ratchet is not the backstop, and on this rung it cannot be.** `budget_spent` precedes both
triggers and three attempts on rank 9 were spent sixty-nine hours ago, so no recovery can fire
again until the rank *improves* -- row 22, contract, and the reason the trap hunt runs on the dev
box before a release. What has to carry the run is the road: rung 9's place stays
`VIRIDIAN_FOREST`, the objective is the lowest **unearned** rung (12.4), which is rung 10's
`PEWTER_CITY`, and `geography::next_hop` from the forest answers `VIRIDIAN_FOREST_NORTH_GATE`
(47), then Route 2's north piece, then Pewter (2) -- already asserted hop by hop in
`macros::geography`'s own tests since 12.7. `GO OBJECTIVE` is on the overworld pad there, which
the ROM-gated run from this checkpoint holds.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.")
@ -883,12 +936,12 @@ observe is not a precondition, it is a guess.
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged. The cursor is the fly's to move with the **raw** D-pad, which still reaches the cartridge in macros mode; a SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for |
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT | four move buttons for `ATTACK` (section 14); THROW BALL added; **RUN gated**, below |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu |
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
| Battle, own turn, party list | SWITCH, BACK | unchanged |
| Battle, the bag | NEXT, **BACK** | **BACK added**, and the list now reports a *cursor* (`macros-wram.md` 7.1) — before this `ITEM` could open the bag and had nothing to read |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8) |
| Battle, between turns | NEXT, **BACK** | **BACK added** |
| Battle, between turns | NEXT | `BACK` was added here for the bag (the row above reads as nobody’s turn) and **taken back out by section 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2 |
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
| Title | nothing | unchanged, by contract: the readout's boot variant applies |

View file

@ -610,3 +610,8 @@ then purge; then the stale-doc pass.
- 2026-09-22 02:22 UTC (v0.4.1): first release cut from the public repository (fresh history at
v0.4.0); the DESCRIBE card carries the repository URL. Deployed to the release container with a
flysim and flystage restart, checkpoint carried over at rank 9, lag 0, encoder output clean.
- 2026-09-22 (v0.4.2, loop review, auto): rung 9 for 69 h. The between-turns battle row dealt
BACK with no list open (175 of 959 starts, instant, net nothing) and THROW BALL threw at species
the party already held. Fixed: BACK only where a list is open; THROW BALL skips held species.
Trap hunt: tiles 216 -> 286, listless BACK 175 -> 0, dialog frames 23,548 -> 0. Ethos check
held. Row 41 named: a nurse box answered YES 474 times on one tile.

View file

@ -1230,3 +1230,132 @@ somewhere else, and the script fires on any frame the fly stands there.
so the only thing said about it here is that it exists and is exported. Whether a playable scene
ever deals nothing on the release box is the watchdog's gauge to answer, over a run longer than
twenty brain minutes.
## 2026-09-22, rows 38 and 39: `BACK` between turns, and a ball at what the party already has
Rank 9 (VIRIDIAN FOREST, next PEWTER CITY), **sixty-nine hours** on the rung, ratchet attempts 3 of
3 spent, the fly on Route 2 and in the forest and mostly in wild battles. Since the last restart the
macro starts were `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 and `GO ROUTE` 5, and the event
log repeated:
```
RUN blocked, BACK start, BACK done
```
`docs/design/macros.md` section 12.9 is the contract this closed against. Both traps are section
12.2's one rule — *a macro that completes without moving because its precondition is already
satisfied where the fly stands* — and the reproduction is the hunt from the release container's own
checkpoint, twenty brain minutes, the real brain as the readout.
### The mechanism
Three facts, and the first one is the whole of it.
1. **`BACK` was half of the pad the fly spends a wild battle looking at, and it could not change
anything.** The top-level battle menu never dealt it: `scene_set`'s own-turn arm is
`MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT` and B is not a fifth answer to a four-entry
menu. What dealt it was the **between-turns** row, `Scene::Battle { own_turn: false, .. }`, which
section 13.1 gave `NEXT, BACK` for the sake of the battle bag — the bag reads as nobody's turn
(12.6), so it lands on that arm — and which is *also* every frame of battle text, every
animation and every turn resolving. On those frames nothing is open, so `BACK`'s single B press
changes nothing the `NEXT` beside it does not: measured at **mean 16 frames, 1.0 tiles, net 0**,
163 starts between turns plus 12 more on a move list whose cursor the seam cannot place, out of
959 macros in twenty brain minutes. Half a pad of two, once per hold, while the turn did not
move.
2. **`RUN blocked` was the script, not the cartridge.** Not "can't escape": `RUN` is one cursor
navigation, and a cursor macro whose list is not accepting input **waits** rather than pressing
blind (section 4), for `CURSOR_WAIT` = 180 frames before it reports `Blocked`. Measured: ten
`RUN` starts, ten `Blocked`, **mean 184 frames**, every one of them started on the top-level
menu. So a `RUN` that won a hold as the menu closed spent three brain seconds pressing nothing
and gave up. That is **row 19**, bounded and left, and the only reason it was legible in the
event log is the `BACK` above filling the frames on either side of it. `RUN`'s precondition is
unchanged (13.1: only a wild battle the fly is losing).
3. **`THROW BALL` had no reading of what it was throwing at.** Its three facts were a wild battle,
a ball in the bag and room in the party, and none of them is "this is not one I already have".
Viridian Forest holds five species; a throw at one the party carries spends a ball for nothing,
and a catch opens the nickname screen, which reads `Unknown`, needs the START the pad has no
button for, and is the one screen neither `NEXT` nor `BACK` leaves (**row 14**). So the trap is
paid for twice.
| # | trap | trigger | test | fix, or why it is left |
| ---: | --- | --- | --- | --- |
| 38 | `BACK` on a battle pad with no list open completes without changing anything, once per hold | every frame of battle text, every animation, every turn resolving — which is most of a wild battle | `back_is_on_a_battle_pad_only_where_a_list_is_open`, `a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated) | **fixed**: the between-turns row is the sub-state's, like the own turn's — `NEXT, BACK` with the bag open, `NEXT` alone otherwise. The move list and the party list keep `BACK`, because backing out of a list is one of exactly two answers to one, and the top-level menu never had it |
| 39 | `THROW BALL` throws at a species the party already holds | any wild battle after the first catch of that species | `throw_ball_refuses_a_species_the_party_already_holds`, and the ROM-gated run above asserts it never happens on the cartridge | **fixed**: a fourth fact in the precondition. The **party** is the caught set — the cartridge's own lifetime record, in the same internal species numbering the enemy is read in. `wPokedexOwned` is *not* asked: that bitset is by Pokédex number and the table converting an internal index into one lives in a ROM bank this crate cannot read, so it would be an unverified number (`docs/design/ladder.md`). It costs nothing measurable, because the button is already off the pad while the party is full and nothing in the vocabulary deposits into a box (row 17). An enemy the seam cannot place leaves the button where it was |
| 40 | the ratchet cannot help on a rung sixty-nine hours old | `budget_spent()` precedes both triggers and three attempts on rank 9 were spent | `ratchet.rs`'s own tests ("three attempts per rank") | **left, contract**, and this is row 22 measured again: no recovery can fire until the rank *improves*. What carries the run is the road, and the road is checked rather than assumed — the objective is the lowest **unearned** rung (12.4), which is rung 10's `PEWTER_CITY`; `geography::next_hop` from the forest answers `VIRIDIAN_FOREST_NORTH_GATE` (47), then Route 2's north piece, then Pewter (2), asserted hop by hop since 12.7; and the ROM-gated run reads `GO OBJECTIVE` on the overworld pad of maps 13, 50 and 51. **The run does not reach map 47** in 67 brain minutes of the game-blind rotation, so that is reported and not asserted |
| 41 | the nurse's box answered `YES` four hundred and seventy-four times | measured in the **before** arm only: from 11.5 brain minutes the fly stood on one tile of a Pokémon Center and the windows read `YES` x21 to the end of the run | — | **named, not worked**: 23,548 of the before arm's 71,673 frames were a text box on map `0x29` at (3, 3), 474 of them answered `YES`, and the run ended there. The after arm never enters it — `dialog` frames go **23,548 → 0** — so it is neither reproduced nor fixed by this branch, and it is *the next thing to measure*. Rows 1, 2b, 23 and 24 were each found this way: a loop behind the loop in front of it |
### The trap hunt, before and after
Twenty brain minutes, seed 20260917, 4 sweep threads, same connectome and same cartridge, from the
release container's rung-9 checkpoint. **Driven by the brain rather than by `FLY_TRAP_STUB`**, which
is legitimate here and was not for sections 13 and 14: this branch adds no macro type, so
`tools/build_flywire.py` re-deals nothing, the thirty-one `macro_<type>` populations are the same
function in both arms, and `--print-compatibility` is byte-identical at 648 bytes. The two arms
differ in the macro code and in nothing else.
```sh
FLY_ROM=".../pokemon-red.gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/<the rung-9 forest checkpoint> \
FLY_TRAP_MINUTES=20 cargo run --release -p flysim --example trap_hunt
```
| measure | before (v0.4.1) | after (`fix/loop-20260922T0254`) |
| --- | ---: | ---: |
| distinct (map, tile) over 20 brain minutes | 216 | **286** |
| windows flagged | 70 of 73 | **61 of 73** |
| windows under 4 distinct tiles | 21 | **5** |
| macros started | 959 | 830 |
| done / blocked / timeout | 873 / 80 / 5 | 726 / 97 / 6 |
| frames in `battle` | 27,525 | 48,756 |
| frames in `dialog` | 23,548 | **0** |
| `MOVE n` starts | 36 | **68** |
| `BACK` starts with **no list open** | **175** | **0** |
| `THROW BALL` starts | 47 | 46 |
**Both of `docs/loop-review.md`'s two criteria improve**: a third more ground (216 → 286 tiles) and
nine fewer flagged windows, with the windows that hold fewer than four tiles down from 21 to 5. The
pad tables the hunt gained for this work are the direct reading:
| battle sub-state | pad before | pad after |
| --- | --- | --- |
| main menu | `move_1..4 next run throw_ball` | unchanged |
| move list | `back move_1..4` | unchanged |
| move list, no cursor | `back next` | **`next`** |
| party list | `back` | unchanged |
| between turns | `back next` | **`next`** |
Four things about it that are not improvements, recorded rather than buried.
- **`BACK` is still 359 starts of 830**, all of them over an open list: 306 on the move list and 46
on the party list, which with a party of one is `BACK` alone by design (row 34 — "what changed is
where `BACK` leads: a menu with `ATTACK` on it"). The trap was the *listless* `BACK`, and that is
the row that went to zero.
- **A `NEXT, BACK` two-cycle closes the run**, x75 over the last four brain minutes inside one
battle: the move list opens before its cursor can be placed, `NEXT` advances, the cursor appears,
`BACK` closes the list, and round again. It is bounded — the battle is real, `MOVE n` wins holds
in it, and the turn does move — and it replaces a `BACK` x13 cycle in the same windows of the
before arm. Worth watching; the honest fix is a move list whose cursor the seam can place on its
opening frames, which is a WRAM reading and not a pad change.
- **A window spent in a battle is flagged by the tile rule whatever happens in it.** A battle does
not move the fly, so `tiles = 1` is what a long fight looks like from outside, and the after arm
spends 48,756 of 71,673 frames in one. That is the known false-positive shape of the *tile* rule,
the mirror of the sequence rule's legitimately-repeating explorer, and it is why the pad and
sub-state tables above are what this row is proved on.
- **The `THROW BALL` precondition is inert in this run and is still right.** The save carries a
party of **one** — the starter — so no forest species is ever in it and the new fact never fires;
the 46 starts are all `Blocked` at mean 67 frames, which is row 19's cursor wait again. What the
ROM-gated test holds is the invariant (`threw_at_a_held_species` is never set), not a drop in the
count. The live 28 starts were the same shape.
### Gates
- `cargo test --workspace` with `FLY_ROM` set: green except
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
which fails **identically on v0.4.1** on this box with the same assertion (`/status` reports
`total: 1` with the service still on `BOOT` 54 s after start, where the test wants the 38-rung
ladder). A debug build of the service does not finish booting inside the test's window here.
Pre-existing and unrelated to the macro layer; measured on both sides rather than assumed.
- `cargo clippy --all-targets`: clean.
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter
version / roles untouched.

View file

@ -518,9 +518,21 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
}
},
// 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a
// press exactly like a dialog. Section 13.1 adds `BACK`, because the bag list a battle's
// ITEM entry opens is none of `BattleMenu`'s three and reads here.
Scene::Battle { .. } => vec![Next, Back],
// press exactly like a dialog. Section 13.1 added `BACK` here for the bag a battle's ITEM
// entry opens, which is none of `BattleMenu`'s three and reads as nobody's turn -- and
// section 12.9 narrows it back to that list.
//
// **`BACK` is a button only where there is a list to leave.** Live on rung 9, 69 hours in
// Viridian Forest: `BACK` was 135 of 183 macro starts since the restart, `RUN blocked,
// BACK start, BACK done` over and over. Between turns there is nothing open to back out
// of, so the B press changes nothing the `NEXT` beside it does not, the macro completes on
// the tile it started on in a handful of frames, and the roll lands on it most holds while
// the turn does not move. That is section 12.2's trap exactly: a macro that completes
// without moving because its precondition is already satisfied where the fly stands.
Scene::Battle { .. } => match battle_menu(state) {
BattleMenu::Bag { .. } => vec![Next, Back],
_ => vec![Next],
},
// Section 13: the shop's buttons are the four purchases, plus the two answers any list has.
Scene::Shop => vec![BuyPotion, BuyBall, BuyAntidote, BuyRepel, Confirm, Leave],
// Section 13.1: `CONFIRM` as well as `LEAVE`, so a PC the fly opened is a list it can
@ -1600,6 +1612,29 @@ pub fn throw_slot(state: &mut dyn MacroState) -> Option<u8> {
if state.party().mons.len() >= PARTY_CAPACITY {
return None;
}
// **Not a species this run already has** (section 12.9). Live on rung 9, 69 hours in Viridian
// Forest: `THROW BALL` was 28 of 183 macro starts, spending balls on the Caterpie and Weedle
// already in the party -- and a catch opens the nickname screen, which reads `Unknown` and
// needs the START the pad has no button for (row 14 of `infra/docs/macros-traps.md`), so the
// throw costs a ball and then a stall.
//
// The *party* is the caught set here, and it is the honest one: it is the cartridge's own
// lifetime record, it survives a restart the session ledgers do not, and it is in the same
// numbering the enemy is read in -- the internal species index
// (`docs/design/macros-wram.md`). `wPokedexOwned` is not usable for this: that bitset is by
// Pokédex *number*, the table that converts an internal index to one is in a ROM bank this
// crate cannot read, and `docs/design/ladder.md`'s rule is that an unverified number does not
// go in. It costs nothing measurable: the button is already off the pad while the party is
// full, nothing in the macro vocabulary deposits into a box (row 17), so every species this
// run has caught is in the party this reads.
//
// An enemy species the seam could not place leaves the button where it was -- a precondition
// this crate cannot observe is not a precondition, it is a guess (section 13.1).
if let Some(species) = battle.enemy.map(|enemy| enemy.species).filter(|species| *species != 0)
&& state.party().mons.iter().any(|mon| mon.species == species)
{
return None;
}
let index = state
.bag()
.iter()

View file

@ -35,9 +35,9 @@ use super::path::{self, Exit, Way};
use super::super::maps;
use super::plan;
use super::state::{
Battle, BattleKind, BattleMenu, Connections, Cursor, Facing, GameState, MapSize, Mon, Move,
Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox, Walkable,
Warp,
Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState, MapSize,
Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox,
Walkable, Warp,
};
/// `SPRITE_POKE_BALL`, the first still sprite: the picture id a fake gives an object rather than a
@ -99,6 +99,11 @@ struct World {
battle: Option<(BattleKind, bool, bool)>,
mons: Vec<Mon>,
active: Option<u8>,
/// The Pokémon on the other side, when a test is about which one it is.
///
/// `None` is the seam answering nothing, which is what most of these fixtures want: the
/// species only matters to `THROW BALL`'s precondition (section 12.9).
enemy: Option<EnemyMon>,
money: u32,
bag: Vec<(u8, u8)>,
@ -196,6 +201,7 @@ impl World {
cursor_max: 0,
grid: false,
battle: None,
enemy: None,
mons: vec![mon(0, 20, 20, &[(33, 30)])],
active: None,
money: 0,
@ -532,7 +538,7 @@ impl GameState for World {
_ => BattleMenu::None,
};
let own = self.active.and_then(|slot| self.mons.iter().find(|mon| mon.slot == slot)).copied();
Some(Battle { kind, own_turn, forced_switch, menu, own, enemy: None })
Some(Battle { kind, own_turn, forced_switch, menu, own, enemy: self.enemy })
}
fn text_box(&mut self) -> TextBox {
@ -1084,13 +1090,22 @@ fn a_forced_switch_binds_switch_five_times_and_nothing_on_b() {
#[test]
fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text() {
// 2026-09-16 hotfix: battle text waits for a press like a dialog (live deadlock on Route 1).
let mut world = World::battle();
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
world.battle = Some((BattleKind::Wild, false, false));
world.list = List::None;
let scene = world.scene();
// 2026-09-16 hotfix: battle text waits for a press like a dialog (live deadlock on Route 1).
// Section 13.1 adds `BACK`: the bag list a battle's ITEM entry opens is none of
// `BattleMenu`'s three, so it reads here, and `NEXT` alone could only press A at it.
// Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry
// opens, which reads as nobody's turn -- but on a frame of text there is no list to leave, and
// a `BACK` that changes nothing is the trap of section 12.2 (live, rung 9: 135 of 183 macro
// starts).
let palette = Palette::for_scene(scene, &mut world);
assert_eq!(names(&palette), ["NEXT"]);
// The bag is the one sub-state on this arm with a list open in it, and it keeps `BACK`.
world.list = List::BattleBag;
let palette = Palette::for_scene(world.scene(), &mut world);
assert_eq!(names(&palette), ["NEXT", "BACK"]);
}
@ -3271,6 +3286,62 @@ fn each_battle_menu_deals_its_own_pad() {
between.list = List::None;
let pad = names(&plan::plan_for(between.scene(), &mut between));
assert_eq!(pad.iter().filter(|name| **name == "NEXT").count(), 1, "{pad:?}");
assert!(!pad.contains(&"BACK"), "nothing is open to back out of: {pad:?}");
}
/// Section 12.9: `BACK` is on a battle's pad only where a list is open.
///
/// **Live on rung 9**, 69 hours in Viridian Forest: `BACK` was 135 of 183 macro starts since the
/// restart and the event log repeated `RUN blocked, BACK start, BACK done`. Between turns there is
/// no list to leave, so the B press changes nothing the `NEXT` beside it does not and the macro
/// completes on the tile it started on -- section 12.2's trap, on the pad the fly spends most of a
/// wild battle looking at.
///
/// The three lists keep it, because backing out of a list is one of exactly two answers to one.
#[test]
fn back_is_on_a_battle_pad_only_where_a_list_is_open() {
let with_back = |world: &mut World| {
let pad = names(&plan::plan_for(world.scene(), world));
assert!(pad.contains(&"BACK"), "a list can be left: {pad:?}");
};
let without = |world: &mut World| {
let pad = names(&plan::plan_for(world.scene(), world));
assert!(!pad.contains(&"BACK"), "nothing to back out of: {pad:?}");
assert!(plan::plan_for(world.scene(), world).bound() > 0, "and never an empty pad");
};
// The top-level menu: FIGHT, PKMN, ITEM and RUN are the four answers and B is not a fifth.
let mut main = World::battle();
without(&mut main);
// The three lists.
let mut moves = World::battle();
moves.list = List::Moves(3);
with_back(&mut moves);
let mut party = World::battle();
party.list = List::BattleParty;
with_back(&mut party);
// The bag reads as nobody's turn (`state::battle`), so it lands on the between-turns arm --
// and it is the one sub-state there with a list open in it.
let mut bag = World::battle();
bag.scene = Scene::Battle { own_turn: false, forced_switch: false };
bag.battle = Some((BattleKind::Wild, false, false));
bag.list = List::BattleBag;
with_back(&mut bag);
// Text, an animation, a turn resolving: no list, no `BACK`.
let mut between = World::battle();
between.scene = Scene::Battle { own_turn: false, forced_switch: false };
between.battle = Some((BattleKind::Wild, false, false));
between.list = List::None;
without(&mut between);
// And a forced switch, which could never be backed out of anyway (row 8).
let mut forced = World::battle();
forced.scene = Scene::Battle { own_turn: false, forced_switch: true };
forced.battle = Some((BattleKind::Wild, false, true));
forced.list = List::BattleParty;
without(&mut forced);
}
#[test]
@ -3872,6 +3943,51 @@ fn throw_ball_needs_a_wild_battle_a_ball_and_room_in_the_party() {
assert!(!on_the_pad(&mut room, MacroKind::ThrowBall));
}
/// Section 12.9: a ball is not thrown at a species the party already holds.
///
/// **Live on rung 9**, 69 hours in Viridian Forest: `THROW BALL` was 28 of 183 macro starts, and
/// the forest holds Caterpie, Weedle, Metapod, Kakuna and Pidgey -- the fly had caught its own and
/// went on throwing at them. Every throw spends a ball, and a catch opens the nickname screen,
/// which reads `Unknown` and needs a START the pad has no button for (row 14).
///
/// The party is the caught set: the cartridge's own lifetime record, in the same internal species
/// numbering the enemy is read in. `wPokedexOwned` is by Pokédex number and the conversion is in a
/// ROM bank this crate cannot read, so it is not asked.
#[test]
fn throw_ball_refuses_a_species_the_party_already_holds() {
// Two distinct internal species indices -- the forest's Weedle and Caterpie, whose exact
// numbers nothing below depends on.
let weedle = 0x70;
let caterpie = 0x7b;
let mut world = World::battle();
world.mons.truncate(1);
world.mons[0].species = caterpie;
world.bag = vec![(item::POKE_BALL, 5)];
// A species the party does not hold: the button is on the pad, as before.
world.enemy = Some(EnemyMon { species: weedle, level: 6, hp: 20, max_hp: 20 });
assert_eq!(throw_slot(&mut world), Some(0));
assert!(on_the_pad(&mut world, MacroKind::ThrowBall));
// The one that is already in the party: off the pad, whatever the bag holds.
world.enemy = Some(EnemyMon { species: caterpie, level: 6, hp: 20, max_hp: 20 });
assert_eq!(throw_slot(&mut world), None, "a Caterpie is already in the party");
assert!(!on_the_pad(&mut world, MacroKind::ThrowBall));
// Any party slot counts, not only the one that is out.
world.mons.push(mon(1, 20, 20, &[(33, 30)]));
world.mons[1].species = weedle;
world.enemy = Some(EnemyMon { species: weedle, level: 6, hp: 20, max_hp: 20 });
assert_eq!(throw_slot(&mut world), None, "a Weedle is on the bench");
// A species the seam could not place leaves the button where it was: an unobservable
// precondition is a guess, and this crate does not guess (section 13.1).
world.enemy = None;
assert_eq!(throw_slot(&mut world), Some(0), "no reading is not a refusal");
world.enemy = Some(EnemyMon { species: 0, level: 0, hp: 0, max_hp: 0 });
assert_eq!(throw_slot(&mut world), Some(0), "species 0 is not a species");
}
#[test]
fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
let mut world = World::battle();

View file

@ -214,9 +214,42 @@ struct Trace {
font_corners_no_border: u64,
font_no_corners: u64,
corners_no_font: u64,
/// Frames spent in each battle sub-state, by [`battle_sub_state`]'s name.
///
/// The scene histogram says "battle" and a battle has five sub-states with five different
/// pads (`docs/design/macros.md` section 12.6, 13.1), so a loop inside one of them is
/// invisible above. This is what names it.
battle_frames: BTreeMap<&'static str, u64>,
/// Macro starts by battle sub-state: which button the fly pressed on which of the five pads.
battle_starts: BTreeMap<(&'static str, &'static str), u64>,
/// Every macro channel that was bound in each battle sub-state, over the whole run.
///
/// The pad's composition, measured rather than read off the table: "which sub-state offers
/// `BACK`" is a question about the build under test and not about the document.
battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
wall_seconds: f64,
}
/// Which sub-state of a battle this frame is, or `None` when no battle is running.
///
/// The five the pad is dealt by: the top-level menu, the move list, the party list, the bag, the
/// forced switch, and the frames between turns where no list is accepting input.
fn battle_sub_state(emulator: &mut flybrain_gb::Emulator) -> Option<&'static str> {
use flybrain_gb::pokemon_red::macros::state::BattleMenu;
let battle = flybrain_gb::pokemon_red::state::battle(emulator)?;
if battle.forced_switch {
return Some("forced switch");
}
Some(match battle.menu {
BattleMenu::Main { .. } => "main menu",
BattleMenu::Moves { cursor: Some(_), .. } => "move list",
BattleMenu::Moves { cursor: None, .. } => "move list, no cursor",
BattleMenu::Party { .. } => "party list",
BattleMenu::Bag { .. } => "bag",
BattleMenu::None => "between turns",
})
}
/// The macro that owns the buttons, with where it began and the ground it has covered since.
struct Running {
name: &'static str,
@ -350,6 +383,9 @@ fn run(
font_corners_no_border: 0,
font_no_corners: 0,
corners_no_font: 0,
battle_frames: BTreeMap::new(),
battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(),
wall_seconds: 0.0,
};
@ -406,6 +442,14 @@ fn run(
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
.flatten();
let battle_sub = battle_sub_state(&mut emulator);
if let Some(sub) = battle_sub {
*trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = trace.battle_pads.entry(sub).or_default();
for channel in bound.as_deref().unwrap_or_default() {
pad.insert(channel.clone());
}
}
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
@ -419,6 +463,9 @@ fn run(
if let Some(map) = dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
running = Some(Running {
name: event.name,
from: location,
@ -801,6 +848,31 @@ fn main() {
}
}
if !trace.battle_frames.is_empty() {
println!("\n| battle sub-state | frames | pad |");
println!("| --- | ---: | --- |");
for (sub, frames) in &trace.battle_frames {
let pad = trace
.battle_pads
.get(sub)
.map(|set| {
set.iter()
.map(|c| c.strip_prefix("macro_").unwrap_or(c).to_string())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
println!("| {sub} | {frames} | {pad} |");
}
println!("\n| macro start | battle sub-state | n |");
println!("| --- | --- | ---: |");
let mut rows: Vec<((&str, &str), u64)> =
trace.battle_starts.iter().map(|(key, n)| (*key, *n)).collect();
rows.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
for ((name, sub), n) in rows {
println!("| {name} | {sub} | {n} |");
}
}
println!("\n| scene | frames | longest run | run began (brain min) |");
println!("| --- | ---: | ---: | ---: |");
for (scene, frames) in &trace.scenes {

View file

@ -59,6 +59,9 @@ const VIRIDIAN_MART: u32 = 0x2a;
/// Route 2's southern forest gate and the forest north of it, which is rung 9's own road.
const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32;
const VIRIDIAN_FOREST: u32 = 0x33;
/// The 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;
/// `constants/event_constants.asm`, by bit index: the parcel picked up at the mart, and the parcel
/// delivered to Oak. The save that stalled has the first and not the second.
@ -170,6 +173,18 @@ struct Run {
/// and the party list it fell into dealt one button.
move_button_on_battle_pad: bool,
battle_pad: Option<usize>,
/// Whether `BACK` was ever on the pad in a battle with **no list open** (section 12.9).
///
/// The rung-9 trap: between turns there is nothing to back out of, so the press completes
/// where the fly stands and the turn does not move. `false` is the assertion.
back_without_a_list: bool,
/// Whether `THROW BALL` ever started at a species the party already held (section 12.9).
threw_at_a_held_species: bool,
/// Maps on whose *overworld* pad `GO OBJECTIVE` was ever bound.
///
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
/// north is whatever `GO FRONTIER` stumbles into.
objective_on_pad: std::collections::BTreeSet<u32>,
}
impl Run {
@ -240,6 +255,9 @@ impl Run {
pads: std::collections::BTreeMap::new(),
move_button_on_battle_pad: false,
battle_pad: None,
back_without_a_list: false,
threw_at_a_held_species: false,
objective_on_pad: std::collections::BTreeSet::new(),
}
}
@ -313,6 +331,9 @@ impl Run {
pads: std::collections::BTreeMap::new(),
move_button_on_battle_pad: false,
battle_pad: None,
back_without_a_list: false,
threw_at_a_held_species: false,
objective_on_pad: std::collections::BTreeSet::new(),
}
}
@ -340,6 +361,51 @@ impl Run {
state.battle().is_some_and(|battle| battle.own_turn && !battle.forced_switch)
}
/// Whether a battle list is open and accepting input: the move list, the party list or the bag.
///
/// Section 12.9's question, through the same seam the macros read: `BACK` is a button where
/// there is a list to leave and nowhere else.
fn battle_list_open(&mut self) -> bool {
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
let ledger = AdapterLedger(&self.adapter);
let mut state =
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
state.battle().is_some_and(|battle| {
matches!(
battle.menu,
BattleMenu::Moves { cursor: Some(_), .. }
| BattleMenu::Party { .. }
| BattleMenu::Bag { .. }
)
})
}
/// Whether the Pokémon on the other side is a species the party already holds.
///
/// The internal species index on both sides, which is the one numbering they share
/// (`docs/design/macros-wram.md`).
fn enemy_species_in_party(&mut self) -> bool {
use flybrain_gb::pokemon_red::macros::state::GameState;
let ledger = AdapterLedger(&self.adapter);
let mut state =
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
let Some(species) =
state.battle().and_then(|battle| battle.enemy).map(|enemy| enemy.species)
else {
return false;
};
species != 0 && state.party().mons.iter().any(|mon| mon.species == species)
}
/// The rung the macros are walking toward, as the objective reads it.
fn objective_map(&mut self) -> Option<u8> {
use flybrain_gb::pokemon_red::macros::MacroState;
let ledger = AdapterLedger(&self.adapter);
let mut state =
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
state.objective().map(|objective| objective.map)
}
/// `wIsInBattle`: 0 out of battle, 1 wild, 2 a trainer.
fn in_battle(&mut self) -> u8 {
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle)
@ -415,6 +481,12 @@ impl Run {
let dealt = bound.len();
self.battle_pad = Some(self.battle_pad.map_or(dealt, |seen| seen.min(dealt)));
}
// Section 12.9, on the cartridge: `BACK` belongs to a list. A battle frame with no list
// accepting input and `BACK` on the pad is the rung-9 trap itself.
if self.in_battle() != 0 && !self.battle_list_open() {
self.back_without_a_list |=
bound.iter().any(|channel| channel.as_str() == "macro_back");
}
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
if talk_bound && !self.talk_on_pad {
@ -435,6 +507,10 @@ impl Run {
(decision.mask, started)
};
for name in started {
// Section 12.9's other half: a ball is never thrown at a species the party holds.
if name == "THROW BALL" && self.enemy_species_in_party() {
self.threw_at_a_held_species = true;
}
*self.started.entry(name).or_insert(0) += 1;
}
self.gb.set_buttons(mask as u8);
@ -454,7 +530,11 @@ impl Run {
// dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box
// is a pad of its own.
if map != u32::MAX && self.layer.scene_name() == "overworld" {
let dealt = self.layer.bound_channels().len();
let dealt = self.layer.bound_channels();
if dealt.iter().any(|channel| channel.as_str() == "macro_go_objective") {
self.objective_on_pad.insert(map);
}
let dealt = dealt.len();
let seen = self.pads.entry(map).or_insert(dealt);
*seen = (*seen).min(dealt);
}
@ -652,6 +732,133 @@ fn macros_mode_completes_its_walks_from_the_stalled_checkpoint() {
);
}
/// The rung-9 forest checkpoint, or `None` to skip.
///
/// Its own variable rather than `FLY_TRAP_CHECKPOINT`, because the two checkpoint tests above
/// assert the map their checkpoint is on: one envelope cannot be both.
fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_FOREST_CHECKPOINT")?;
Some(
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope"),
)
}
/// From the rung-9 forest checkpoint: the turns advance, and `BACK` is never a battle's whole pad.
///
/// **What was live** (2026-09-22, `infra/docs/macros-traps.md`): rank 9, VIRIDIAN FOREST, 69 hours
/// on the rung, the ratchet's three attempts spent, and since the restart the macro starts were
/// `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 — the event log repeating `RUN blocked, BACK
/// start, BACK done`. `BACK` on a battle frame with no list open completes in a handful of frames
/// without changing anything, so the roll landed on it most holds and the turn did not move; and
/// `THROW BALL` spent balls on the species already in the party, each catch opening a nickname
/// screen the pad cannot leave.
///
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that the
/// fly's own presses are what ends it, and that neither of the two traps is on the pad any more.
/// Which move it picks and whether it wins are the fly's.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_FOREST_CHECKPOINT=.local/checkpoints/release-forest-rung9.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
/// ```
#[test]
fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = forest_checkpoint() else {
eprintln!("skipped: no FLY_FOREST_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
let from = run.map();
assert!(
from == VIRIDIAN_FOREST || from == ROUTE_2 || from == VIRIDIAN_FOREST_SOUTH_GATE,
"the checkpoint is the one the stream stalled on, got map {from:#04x}"
);
// Rung 9 is stood on, so the objective is rung 10 — Pewter City — and the road there is north
// through the forest (`macros::geography`, asserted as hops in that module's own tests).
assert_eq!(
run.objective_map(),
Some(0x02),
"the objective is Pewter City, whatever errand is in front of it"
);
// Every battle this run passes through, and how it left.
let (mut battles, mut ended, mut in_battle) = (0u32, 0u32, run.in_battle() != 0);
let mut own_turns = 0u32;
for _ in 0..240_000 {
run.frame();
let now = run.in_battle() != 0;
match (in_battle, now) {
(false, true) => battles += 1,
(true, false) => ended += 1,
_ => {}
}
in_battle = now;
if now && run.own_turn() {
own_turns += 1;
}
if run.route.contains(&VIRIDIAN_FOREST_NORTH_GATE) {
break;
}
}
let counts = run.layer.counts();
eprintln!(
"from map {from:#04x} in {:.1} brain minutes: route {:?}, battles {battles} ({ended} ended), own-turn frames {own_turns}, macros {counts:?}, by name {:?}, objective on the pad on {:?}",
run.ms / 60_000.0,
run.route,
run.started,
run.objective_on_pad
);
// The two traps, as assertions on the cartridge.
assert!(!run.back_without_a_list, "`BACK` was on a battle pad with no list open");
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
// The turn moves: the fly's own battle presses happen, and a battle this run entered or
// resumed finishes.
assert!(own_turns > 0, "the fly never got a turn");
let battle_presses: u32 = run
.started
.iter()
.filter(|(name, _)| name.starts_with("MOVE ") || **name == "THROW BALL")
.map(|(_, n)| *n)
.sum();
assert!(battle_presses > 0, "no move and no ball: {:?}", run.started);
assert!(ended > 0, "no battle ever ended: {battles} entered");
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this
// harness's game-blind rotation it is a fact about the rotation and not about the macros,
// because every bound channel wins about equally often.
let backs = run.started.get("BACK").copied().unwrap_or(0);
eprintln!(
"`BACK` was {backs} of {} macro starts, none of them with no list open",
counts.started
);
// North is the road, and the gate is the first hop. Asserted when the run reaches it and
// reported when it does not: which way the fly walks is its own, and the objective being on
// the pad is what this harness can hold it to.
if run.route.contains(&VIRIDIAN_FOREST_NORTH_GATE) {
eprintln!("the fly left the forest north through the gate at 0x2f");
} else {
eprintln!(
"the forest's north gate was not reached in this run; route {:?}",
run.route
);
}
assert!(
run.objective_on_pad.contains(&VIRIDIAN_FOREST)
|| run.objective_on_pad.contains(&ROUTE_2)
|| run.objective_on_pad.contains(&VIRIDIAN_FOREST_NORTH_GATE),
"GO OBJECTIVE was on no overworld pad on the road north: {:?}",
run.objective_on_pad
);
}
/// From the stalled checkpoint, nothing the map draws is read as a text box.
///
/// **What this was written to settle** (2026-09-17, `infra/docs/macros-traps.md`): with the walk