diff --git a/docs/design/macros.md b/docs/design/macros.md index 085a8d8..851b4ea 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -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 | diff --git a/docs/stream-mvp-plan.md b/docs/stream-mvp-plan.md index 8b15444..3df398c 100644 --- a/docs/stream-mvp-plan.md +++ b/docs/stream-mvp-plan.md @@ -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. diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index ca58158..9e5918e 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -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_` 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/ \ + 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. 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 d56dab7..c22664e 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 @@ -518,9 +518,21 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec { } }, // 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 { 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() 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 d8cfd3b..b284906 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 @@ -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, active: Option, + /// 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, 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(); diff --git a/services/flysim/crates/flysim/examples/trap_hunt.rs b/services/flysim/crates/flysim/examples/trap_hunt.rs index f3e4bdb..31a4fde 100644 --- a/services/flysim/crates/flysim/examples/trap_hunt.rs +++ b/services/flysim/crates/flysim/examples/trap_hunt.rs @@ -220,9 +220,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>, 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, @@ -357,6 +390,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, }; @@ -413,6 +449,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); @@ -426,6 +470,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, @@ -850,6 +897,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::>() + .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 { diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 7eef520..1a4ce8d 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -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, + /// 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, } 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 { + 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 { + 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