From 05c2552d784a9e6650d54d00654a919ab845e23c Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 06:14:33 +0000 Subject: [PATCH 1/3] macros: NEXT off every own-turn pad, and the bag is the turn its cursor says it is Live on rung 9 thirty-five minutes after v0.4.2: NEXT 1264 macro starts, BACK 1241, the log alternating NEXT start/done, BACK start/done every hold, on map 51. NEXT on the top-level battle menu is an A press on the cursor, and the cursor sits on FIGHT, so it opened the move list; BACK on the move list closed it again. Each button was legitimate where it stood, and the pair undid itself with nothing else changing, so the turn never resolved. NEXT is now on no pad with a cursor accepting input. It keeps the between-turns row alone and the forced switch, which has no BACK beside it to undo it. MOVE 1 is the top-level menu's backstop instead: the question over that menu is 12.8's "is there a move list to open", FIGHT always opens, and the script there is confirm FIGHT and stop, which reads no move at all. The bag is the fly's own turn, because a cursor accepting input is one, and its pad is the bag's own three answers: ITEM, THROW BALL, BACK. Section 12.10. --- .../src/pokemon_red/macros/palette.rs | 64 ++++++++++++------- .../src/pokemon_red/macros/state.rs | 16 +++-- .../flybrain-gb/src/pokemon_red/state.rs | 12 ++-- 3 files changed, 58 insertions(+), 34 deletions(-) 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 c22664e..218bb11 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 @@ -504,35 +504,44 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec { // `SWITCH` needs a Pokémon to switch *to* and a forced switch with none leaves a menu the // game will not let the fly cancel and a pad with nothing on it (row 8). Scene::Battle { forced_switch: true, .. } => vec![Switch, Next], - // The fly's turn, by which menu of it is accepting input (section 12.6). `NEXT` is on none - // of the two lists: an A press on whatever the cursor happens to be sitting on is what the - // live loop was made of. + // The fly's turn, by which menu of it is accepting input (section 12.6). **`NEXT` is on + // no own-turn pad at all** (section 12.10): it is the A that advances *text*, and on a menu + // that is accepting input the same A press opens or confirms whatever the cursor happens to + // be sitting on, which is never one of the answers to that menu. + // + // Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts and + // `BACK` 1241, the log alternating `NEXT start/done, BACK start/done` every hold. `NEXT` on + // the *top-level* menu pressed A on FIGHT and opened the move list; `BACK` on the *move + // list* closed it again; neither spent a turn, and the two of them were half the pad + // between them. Two buttons that undo each other with nothing else changing are section + // 12.2's trap spread over two sub-states of one turn. Scene::Battle { own_turn: true, .. } => match battle_menu(state) { BattleMenu::Moves { .. } => vec![Move1, Move2, Move3, Move4, Back], BattleMenu::Party { .. } => vec![Switch, Back], - // The bag reads as nobody's turn (`state::battle`), so it cannot land here; the arm - // exists because `own_turn` is a boolean and this match is over the menu. - BattleMenu::Bag { .. } => vec![Confirm, Back], + // The bag, which is the fly's turn since 12.10. Its three answers: use the thing the + // cursor is on (`ITEM`), throw the ball (`THROW BALL`), or leave the list (`BACK`). + // `CONFIRM` was an A press on whatever the cursor held, which is the same press + // `NEXT` was and reads nothing; the two scripts navigate the list's own cursor. + BattleMenu::Bag { .. } => vec![Item, ThrowBall, Back], + // `None` cannot land here -- `own_turn` is false for it -- and the arm exists because + // `own_turn` is a boolean and this match is over the menu. `MOVE 1` is what keeps this + // pad from ever being one the fly cannot end the turn from: FIGHT is one of these four + // entries and it always opens (section 12.8), which is the backstop `NEXT` was. BattleMenu::Main { .. } | BattleMenu::None => { - vec![Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run, Next] + vec![Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run] } }, // 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a - // 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. + // press exactly like a dialog. // - // **`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], - }, + // **One button, and it is the A that advances text.** Section 13.1 added `BACK` here for + // the bag, because a bag read as nobody's turn; 12.9 narrowed it back to the bag alone; + // 12.10 moves the bag to the own turn where its cursor says it belongs, so nothing is open + // on this row any more and nothing but `NEXT` is on it. A frame that reaches here has no + // cursor accepting input -- text, an animation, a turn resolving -- so there is nothing to + // back out of (`BACK` would be 12.2's trap, the rung-9 loop of 12.9) and nothing for an A + // press to open (`NEXT` here cannot be the press that opened a menu, which is 12.10). + Scene::Battle { .. } => 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 @@ -1579,7 +1588,18 @@ pub const fn move_index(kind: MacroKind) -> Option { /// one at all -- which nothing in the game reaches -- is the one case that still answers `false`. pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool { let Some(index) = move_index(kind) else { return false }; - let Some(own) = state.battle().and_then(|battle| battle.own) else { return false }; + let Some(battle) = state.battle() else { return false }; + // Over the **top-level menu** the question is section 12.8's -- "is there a move list to + // open" -- and FIGHT always opens: what is behind it is the cartridge's business, because + // `CheckPlayerHasUsableMoves` prints "has no moves left!" and sets Struggle without opening + // the list. `MOVE 1`'s script over that menu is "confirm FIGHT and stop" and reads no move at + // all, so the button is bound there whatever the seam can make of the battler. That is the + // backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has + // a button that ends the turn, and it is never one that merely reopens a list. + if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) { + return true; + } + let Some(own) = battle.own else { return false }; let holds = |slot: usize| -> Option<&Move> { own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) }; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs index 02b5695..de10e31 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs @@ -41,7 +41,8 @@ pub enum Scene { Dialog, /// Start menu or one of its submenus, outside battle. Menu, - /// In a battle. `own_turn` is the top-level FIGHT/PKMN/ITEM/RUN menu waiting for input; + /// In a battle. `own_turn` is any battle menu waiting for input -- the top-level + /// FIGHT/PKMN/ITEM/RUN one, the move list, the party list or the bag; /// `forced_switch` is the party list the game opens when the active Pokémon has fainted, which /// cannot be backed out of. Battle { own_turn: bool, forced_switch: bool }, @@ -234,10 +235,10 @@ pub enum BattleMenu { /// The bag, opened from a battle's ITEM entry: `wListMenuID` is `ITEMLISTMENU`. /// /// Not one of the three `docs/design/macros.md` section 12.6 named, and the gap was - /// observable: the bag list is not the top-level menu, so `own_turn` is false and the frame - /// reads as a battle between turns, whose pad is `NEXT` and `BACK` -- and `NEXT` was an A - /// press on whatever the cursor held. What the list needed is a *cursor* the scripts can read, - /// which is what `ITEM` and section 14's `THROW BALL` navigate by (2026-09-17). + /// observable twice over. First the list needed a *cursor* the scripts can read, which is what + /// `ITEM` and section 14's `THROW BALL` navigate by (2026-09-17). Then it needed to be the + /// fly's *turn*: a bag reading as nobody's turn landed on the between-turns row, whose `NEXT` + /// is the A that advances text and on an open bag is the A that uses an item (12.10). Bag { cursor: u8, count: u8 }, } @@ -245,8 +246,9 @@ pub enum BattleMenu { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Battle { pub kind: BattleKind, - /// A battle menu is open and waiting for the fly: the top-level one, the move list, or the - /// party list outside a forced switch (`pokemon_red::state::battle`). + /// A battle menu is open and waiting for the fly: the top-level one, the move list, the bag, + /// or the party list outside a forced switch (`pokemon_red::state::battle`). Every frame with a + /// cursor accepting input is one of these, which is section 12.10's invariant. pub own_turn: bool, /// The party list is open because the active Pokémon fainted; it cannot be cancelled. pub forced_switch: bool, diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 025608d..e8eaf6c 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -459,11 +459,13 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option { // frame is between turns, which is what it was before this change. BattleMenu::Moves { cursor, .. } => cursor.is_some(), BattleMenu::Party { .. } => !forced_switch, - // The bag is a list the fly opened *during* its turn, but it is not the turn's own menu: - // the pad that belongs to it is the list's two answers, which is what the between-turns - // row deals (`NEXT`, `BACK`). Reporting it as the own turn would put the move buttons on - // a screen they cannot press. - BattleMenu::Bag { .. } => false, + // The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting + // input, so by the rule above it is the fly's turn (2026-09-22, section 12.10). Reading it + // as nobody's turn put it on the between-turns row, whose one button is the `NEXT` that + // advances *text* -- and on an open bag that same A press *uses* whatever the cursor + // happens to be sitting on. The pad that belongs to a bag is the bag's own three answers, + // `ITEM`, `THROW BALL` and `BACK`, which is what `palette::scene_set` deals here now. + BattleMenu::Bag { .. } => true, BattleMenu::None => false, }; Some(Battle { From 520298e5c30fbc9e461e91c19f2e46f6269f78fc Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 06:14:59 +0000 Subject: [PATCH 2/3] tests: the pad invariant, the bag's turn, and the rung-nine battle on the cartridge Unit: no battle pad holds both NEXT and BACK, over every sub-state with and without a potion and a ball, and NEXT is on none with a cursor accepting input (the assertion the top-level menu failed before this branch). A battle frame with a cursor accepting input is the fly's turn, against real WRAM, for all four menus plus the two frames that correctly are not one. The bag deals ITEM / THROW BALL / BACK, and BACK alone when the bag is empty. ROM-gated, from the live rung-9 forest checkpoint: no pad with both buttons, no NEXT while a menu accepts input, the longest NEXT/BACK alternation under four, and every battle entered also left, each on a bounded number of macros. The battle rules ask the scene the pad was dealt for rather than wIsInBattle, so the $ff frame a lost battle passes through -- which reads Unknown, whose pad is NEXT and BACK by contract -- is not accused of a battle rule. The test fails on v0.4.2 from the same checkpoint with "NEXT was on the pad while a battle menu was accepting input", NEXT dealt on battle/main, and a run that never leaves map 51. --- .../src/pokemon_red/macros/tests.rs | 163 +++++++++++-- .../src/pokemon_red/state/tests.rs | 73 ++++++ .../crates/flysim/tests/rom_macros_mode.rs | 227 +++++++++++++++++- 3 files changed, 434 insertions(+), 29 deletions(-) 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 e2a44ce..0a69c8d 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 @@ -924,17 +924,22 @@ fn the_menu_row_is_close_confirm_back() { } #[test] -fn the_battle_row_is_the_move_buttons_switch_item_and_the_backstop() { +fn the_battle_row_is_the_move_buttons_switch_item_and_never_next() { let mut world = World::battle(); world.bag = vec![(item::POTION, 1)]; let scene = world.scene(); // Section 13.1: `RUN` is bound only in a wild battle the fly is *losing*, and `World::battle` // has an eighteen-of-twenty Pokémon on the bench -- something healthier to send in, so the // fight is still worth having and the slot is empty. + // + // Section 12.10: **`NEXT` is gone from this row.** It was the backstop for a turn where every + // other button dropped, and it was an A press on the cursor -- which sits on FIGHT, so it + // opened the move list, whose `BACK` closed it again: `NEXT` 1264 starts and `BACK` 1241 on + // rung 9 after v0.4.2. `MOVE 1` is the backstop now, and it ends the turn. let palette = Palette::for_scene(scene, &mut world); assert_eq!( names(&palette), - ["NEXT", "MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"], + ["MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"], "three moves with PP, an empty fourth slot, a healthy bench and a potion" ); @@ -943,15 +948,17 @@ fn the_battle_row_is_the_move_buttons_switch_item_and_the_backstop() { let scene = world.scene(); assert_eq!( names(&Palette::for_scene(scene, &mut world)), - ["NEXT", "MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"] + ["MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"] ); } #[test] fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() { - // Section 14: one button per move slot. `World::battle`'s Pokémon has three moves and an empty - // fourth slot, so three buttons are on the pad and the fourth never is. + // Section 14: one button per move slot, asked **over the open move list**, which is where a + // slot is a thing to aim at. `World::battle`'s Pokémon has three moves and an empty fourth + // slot, so three buttons are on the list's pad and the fourth never is. let mut world = World::battle(); + world.list = List::Moves(3); assert!(move_slot_bound(&mut world, MacroKind::Move1)); assert!(move_slot_bound(&mut world, MacroKind::Move2)); assert!(move_slot_bound(&mut world, MacroKind::Move3)); @@ -975,12 +982,25 @@ fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() { .is_some() ); - // A Pokémon with no move in slot one at all is the one case that answers no. Nothing in the - // game reaches it, and inventing a press for it is what this crate does not do. + // A Pokémon with no move in slot one at all answers no over the list: there is no slot to aim + // at, and inventing a press for it is what this crate does not do. world.mons[0] = mon(0, 4, 20, &[]); assert!(!move_slot_bound(&mut world, MacroKind::Move1)); let scene = world.scene(); assert_eq!(Palette::for_scene(scene, &mut world).slot(MacroId(MacroKind::Move1.slot())), None); + + // Over the **top-level menu** the question is a different one -- section 12.8's "is there a + // move list to open" -- and FIGHT always opens, so `MOVE 1` is bound there whatever the seam + // makes of the battler. That is what carries the turn now that `NEXT` is off this row + // (section 12.10): the script over this menu is "confirm FIGHT and stop" and reads no move. + world.list = List::BattleMain; + assert!(move_slot_bound(&mut world, MacroKind::Move1), "FIGHT is always pressable"); + assert!(!move_slot_bound(&mut world, MacroKind::Move2), "and it is MOVE 1 that carries it"); + world.active = None; + assert!( + move_slot_bound(&mut world, MacroKind::Move1), + "a battler the seam cannot read is not a reason to take the turn's one button away" + ); } #[test] @@ -1097,16 +1117,21 @@ fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text() world.list = List::None; let scene = world.scene(); // 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 + // opens, which read 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`. + // Section 12.10: the bag does not land on this arm any more. It is a cursor accepting input, + // so it is the fly's turn, and its `NEXT` would have been the A that *uses* what the cursor + // holds rather than the A that advances text. Nothing is open here, so nothing but `NEXT` is. world.list = List::BattleBag; + world.battle = Some((BattleKind::Wild, true, false)); + world.scene = Scene::Battle { own_turn: true, forced_switch: false }; + world.bag = vec![(item::POTION, 1)]; let palette = Palette::for_scene(world.scene(), &mut world); - assert_eq!(names(&palette), ["NEXT", "BACK"]); + assert_eq!(names(&palette), ["BACK", "ITEM"], "a hurt Pokémon, a potion, and no ball"); } #[test] @@ -2356,10 +2381,11 @@ fn the_battle_plan_attacks_first_and_switches_only_under_a_quarter() { let mut healthy = World::battle(); healthy.mons[0] = mon(0, 20, 20, &[(33, 30)]); assert!(!plan::failing(&mut healthy)); - // `NEXT` is unconditional: a battle frame always has a press that advances it, which is what - // keeps the own-turn pad from being empty when every other button drops out (2026-09-17). - // `ITEM` needs a potion and low HP, so it is absent here; this Pokémon has one move. - assert_eq!(plan(&mut healthy), ["NEXT", "MOVE 1", "SWITCH"]); + // `MOVE 1` is what keeps the own-turn pad from being empty when every other button drops out: + // FIGHT is one of this menu's four entries and it always opens (12.8), where the `NEXT` that + // used to carry the job merely reopened the list `BACK` had just closed (12.10). `ITEM` needs + // a potion and low HP, so it is absent here; this Pokémon has one move. + assert_eq!(plan(&mut healthy), ["MOVE 1", "SWITCH"]); } #[test] @@ -2372,7 +2398,7 @@ fn the_battle_plan_runs_from_a_wild_battle_only_when_the_whole_party_is_weak() { // the next hit ends the battle, which is the state section 9 puts `RUN` in. world.mons = vec![mon(0, 4, 20, &[(33, 30)]), mon(1, 3, 20, &[(33, 30)])]; assert!(plan::party_weak(&mut world)); - assert_eq!(plan(&mut world), ["NEXT", "MOVE 1", "SWITCH", "RUN"]); + assert_eq!(plan(&mut world), ["MOVE 1", "SWITCH", "RUN"]); // A trainer battle has no RUN at all, in the plan or in the palette. world.battle = Some((BattleKind::Trainer, true, false)); @@ -3244,7 +3270,11 @@ fn each_battle_menu_deals_its_own_pad() { let pad = names(&plan::plan_for(main.scene(), &mut main)); assert!(pad.contains(&"MOVE 1"), "{pad:?}"); assert!(pad.contains(&"SWITCH"), "{pad:?}"); - assert!(pad.contains(&"NEXT"), "the top-level menu keeps its backstop: {pad:?}"); + // Section 12.10: the top-level menu is a list accepting input too, so `NEXT` is off it as + // well. Its backstop is `MOVE 1`, which is bound here whatever the battler reads as, because + // FIGHT always opens (12.8) -- and unlike `NEXT` it ends the turn instead of opening the list + // that `BACK` closes again. + assert!(!pad.contains(&"NEXT"), "never NEXT on a menu accepting input: {pad:?}"); // `ITEM` and `RUN` have their own preconditions -- a potion, and a party with nothing healthy // left -- and this fixture satisfies neither; row 7 covers the turn where all four drop. @@ -3321,11 +3351,8 @@ fn back_is_on_a_battle_pad_only_where_a_list_is_open() { 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. + // The bag, which is the fly's own turn since section 12.10 because its cursor accepts input. 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); @@ -3344,6 +3371,93 @@ fn back_is_on_a_battle_pad_only_where_a_list_is_open() { without(&mut forced); } +/// Section 12.10: **no battle pad deals a pair of buttons that undo each other.** +/// +/// Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts, `BACK` +/// 1241, and the event log alternating `NEXT start/done, BACK start/done` every hold on map 51. +/// The pair was split across two sub-states of one turn -- `NEXT` on the top-level menu was an A +/// press on FIGHT, which opened the move list, and `BACK` on the move list closed it again -- so +/// neither the pad rule of 12.9 nor the "no `BACK` without a list" rule caught it: both buttons +/// were legitimate where they stood, and between them they were a 2-cycle that never spent a turn. +/// +/// The rule that closes it is about the pair rather than about either button: `NEXT` is the A that +/// advances **text**, so it belongs only on a frame with no cursor accepting input, and `BACK` is +/// the B that leaves a **list**, so it belongs only on a frame that has one. The two conditions are +/// exclusive, so no pad can hold both -- and the forced switch, which keeps `NEXT` as row 8's +/// backstop, is the one arm with a cursor and no `BACK` at all, because it cannot be cancelled. +#[test] +fn no_battle_pad_holds_both_next_and_back() { + // Every battle sub-state the seam can report, on a turn where every precondition is satisfied + // (a potion, a ball, a hurt Pokémon, a bench) and on one where none is. + let sub_states = [ + (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleMain), + (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::Moves(3)), + (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleParty), + (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleBag), + (Scene::Battle { own_turn: false, forced_switch: false }, false, false, List::None), + (Scene::Battle { own_turn: false, forced_switch: true }, false, true, List::BattleParty), + ]; + for stocked in [false, true] { + for (scene, own_turn, forced, list) in sub_states { + let mut world = World::battle(); + world.scene = scene; + world.battle = Some((BattleKind::Wild, own_turn, forced)); + world.list = list; + if stocked { + world.bag = vec![(item::POTION, 1), (item::POKE_BALL, 3)]; + world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 }); + } + let pad = names(&plan::plan_for(world.scene(), &mut world)); + let next = pad.contains(&"NEXT"); + let back = pad.contains(&"BACK"); + assert!( + !(next && back), + "{list:?} (stocked {stocked}) deals a pair that undoes itself: {pad:?}" + ); + // And the pair is not the only way to waste a hold: a pad of one button that cannot + // end the turn is the shape row 34 had, so every sub-state is checked for having one. + assert!( + !pad.is_empty(), + "{list:?} (stocked {stocked}) deals nothing: {pad:?}" + ); + // `NEXT` is on a frame with no cursor accepting input, or on the forced switch that + // has no other answer (row 8). Nowhere else. + if next { + assert!( + forced || list == List::None, + "NEXT on a menu accepting input: {list:?} deals {pad:?}" + ); + } + } + } +} + +/// Section 12.10: the bag inside a battle is the fly's turn, and its pad is the bag's answers. +/// +/// `NEXT` on an open bag is the A press that **uses** whatever the cursor is sitting on, which is +/// not one of the answers to a list, and `CONFIRM` beside `BACK` was the same press by another +/// name. The two scripts that navigate this list by reading its cursor are `ITEM` and +/// `THROW BALL`, and they are what the row deals. +#[test] +fn the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses() { + let mut world = World::battle(); + world.list = List::BattleBag; + world.bag = vec![(item::POTION, 2), (item::POKE_BALL, 4)]; + world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 }); + // `World::battle` is a wild battle with the active Pokémon on 4 of 20, so `ITEM`'s two facts + // hold and `THROW BALL`'s four do: a wild battle, a ball, room in a party of three, and an + // enemy species the party does not hold. + assert_eq!( + names(&plan::plan_for(world.scene(), &mut world)), + ["BACK", "ITEM", "THROW BALL"] + ); + // Nothing in the bag: leaving the list is the press, and there is no `NEXT` to use a thing + // that is not there. + world.bag.clear(); + let pad = names(&plan::plan_for(world.scene(), &mut world)); + assert_eq!(pad, ["BACK"]); +} + #[test] fn a_spent_slot_is_off_the_pad_and_a_slot_with_pp_is_on_it_over_an_open_list() { // The live state row 34 came from: slot 0 is TACKLE with 0 of 40 PP and the cursor is on it. @@ -3823,6 +3937,15 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() { cornered.mons = vec![mon(0, 20, 20, &[])]; cornered.battle = Some((BattleKind::Trainer, true, false)); worst(&mut cornered); + + // And every battle sub-state of that same cornered turn, which since section 12.10 includes + // the bag: an empty bag deals `BACK` and nothing else, which is row 34a's answer -- there is + // nothing to choose, so leaving the list is the press, and what it leaves to is a menu with + // `MOVE 1` on it. + for list in [List::BattleMain, List::Moves(1), List::BattleParty, List::BattleBag] { + cornered.list = list; + worst(&mut cornered); + } } /// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index a371e73..ac81071 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -248,6 +248,79 @@ fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() { assert!(cursor(&mut wram).cancellable()); } +/// Section 12.10: **a battle frame with a cursor accepting input is the fly's turn.** +/// +/// The four menus a battle waits on are the top-level one, the move list, the party list and the +/// bag, and each of them is the game asking the player to choose. `own_turn` answered `false` for +/// the bag, which put it on the between-turns row whose one button is the `NEXT` that advances +/// text -- and on an open bag that same A press uses whatever the cursor holds. +/// +/// The two frames that are correctly *not* the fly's turn are here too: a move list whose cursor +/// the seam cannot place (row 30b, a battle's opening frames) and a frame with no menu at all. +#[test] +fn a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn() { + let battler = |wram: &mut Wram| { + wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]); + wram.party_mon(1, 16, 8, 24, 24, 0, &[(33, 35)]); + wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)]) + .enemy_mon(19, 3, 5, 11) + .battle(1); + }; + + // The top-level menu. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.battle_menu(false, 0); + assert!(battle(&mut wram).unwrap().own_turn, "the top-level menu"); + + // The move list, with a cursor the seam can place. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(1, 3); + assert!(battle(&mut wram).unwrap().own_turn, "the move list"); + + // The party list, chosen rather than forced. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.party_list(1, false); + let fight = battle(&mut wram).unwrap(); + assert!(!fight.forced_switch); + assert!(fight.own_turn, "the party list outside a forced switch"); + + // The bag, which `DisplayListMenuID` opens from the menu's ITEM entry. It is the one menu that + // read as nobody's turn, and it is what section 12.10 is about. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.bag(&[(crate::pokemon_red::macros::cartridge::item::POTION, 2)]).set(ram::wListMenuID, poke::ITEM_LIST_MENU); + let fight = battle(&mut wram).unwrap(); + assert_eq!(fight.menu, BattleMenu::Bag { cursor: 0, count: 1 }); + assert!(fight.own_turn, "the battle bag is a cursor accepting input"); + + // And the two frames that are not a choice. A move list whose cursor cannot be placed is a + // battle's opening frames (row 30b), and no menu at all is text, an animation or a turn + // resolving. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(0, 3).set(ram::wCurrentMenuItem, 0); + let fight = battle(&mut wram).unwrap(); + assert_eq!(fight.menu, BattleMenu::Moves { cursor: None, count: 3 }); + assert!(!fight.own_turn, "a cursor the seam cannot place is not accepting input"); + + let mut wram = Wram::overworld(); + battler(&mut wram); + assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None); + assert!(!battle(&mut wram).unwrap().own_turn, "no menu, no turn"); + + // A forced switch is a cursor accepting input and it is *not* the own turn, because it has a + // pad of its own: the exception 12.6 named, kept here so the invariant reads honestly. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.party_list(1, true); + let fight = battle(&mut wram).unwrap(); + assert!(fight.forced_switch); + assert!(!fight.own_turn, "a forced switch has its own pad"); +} + #[test] fn a_text_box_is_open_from_the_font_flag_and_waiting_from_the_box() { let mut wram = Wram::overworld(); diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 1a4ce8d..f9319dc 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -178,8 +178,40 @@ struct Run { /// 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, + /// Where a `BACK` or a `NEXT` on a battle frame was dealt, as `scene/sub-state`. + /// + /// The bool above says a rule broke; this says on which frame, which is the difference + /// between a pad rule to fix and a scene the detector cannot name. + battle_back_where: std::collections::BTreeSet, + battle_next_where: std::collections::BTreeSet, /// Whether `THROW BALL` ever started at a species the party already held (section 12.9). threw_at_a_held_species: bool, + /// Whether one battle pad ever held both `NEXT` and `BACK` (section 12.10). + /// + /// The pair is the trap: `NEXT` is the A that advances text, `BACK` is the B that leaves a + /// list, and a pad with both has two buttons that undo each other with nothing else changing. + next_and_back_on_one_pad: bool, + /// Whether `NEXT` was ever on the pad while a battle menu was accepting input. + /// + /// The live v0.4.2 shape: `NEXT` on the top-level menu was an A press on FIGHT, so it opened + /// the move list that `BACK` closed again -- 1264 starts against 1241 in 71 hours. + next_on_a_menu_accepting_input: bool, + /// The longest chain of battle macro starts that alternated `NEXT`, `BACK`, `NEXT`, `BACK`. + /// + /// Two is an accident of the rotation; the live log did it for seventy-one hours. Only the + /// previous battle start has to be kept to measure the chain. + longest_next_back_alternation: u32, + alternation: u32, + last_battle_start: Option<&'static str>, + /// Macros started inside the battle that is running, and the worst any *finished* battle cost. + /// + /// The claim the rung-9 loop breaks is that a battle **ends**, and that it ends on a bounded + /// number of macros rather than on however many holds the 2-cycle takes to fall out of. + macros_this_battle: u32, + worst_battle_macros: u32, + battles_entered: u32, + battles_ended: u32, + was_in_battle: 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 @@ -256,7 +288,19 @@ impl Run { move_button_on_battle_pad: false, battle_pad: None, back_without_a_list: false, + battle_back_where: std::collections::BTreeSet::new(), + battle_next_where: std::collections::BTreeSet::new(), threw_at_a_held_species: false, + next_and_back_on_one_pad: false, + next_on_a_menu_accepting_input: false, + longest_next_back_alternation: 0, + alternation: 0, + last_battle_start: None, + macros_this_battle: 0, + worst_battle_macros: 0, + battles_entered: 0, + battles_ended: 0, + was_in_battle: false, objective_on_pad: std::collections::BTreeSet::new(), } } @@ -332,7 +376,19 @@ impl Run { move_button_on_battle_pad: false, battle_pad: None, back_without_a_list: false, + battle_back_where: std::collections::BTreeSet::new(), + battle_next_where: std::collections::BTreeSet::new(), threw_at_a_held_species: false, + next_and_back_on_one_pad: false, + next_on_a_menu_accepting_input: false, + longest_next_back_alternation: 0, + alternation: 0, + last_battle_start: None, + macros_this_battle: 0, + worst_battle_macros: 0, + battles_entered: 0, + battles_ended: 0, + was_in_battle: false, objective_on_pad: std::collections::BTreeSet::new(), } } @@ -365,6 +421,37 @@ impl Run { /// /// 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. + /// Whether the pad on this frame is a **battle** pad. + /// + /// The scene the palette was dealt for, not `wIsInBattle`. The two differ on the `$ff` frame a + /// lost battle passes through and on a Safari or tutorial battle, where `state::battle` reads + /// nothing and `scene::detect` answers `Unknown` -- whose pad is `NEXT, BACK` by contract + /// (section 12.2, row 9: B is what leaves the Pokédex, the trainer card and OPTION). Asking + /// the cartridge byte instead accused that row of a battle rule it is not under. + fn battle_pad(&self) -> bool { + self.layer.scene_name() == "battle" || self.layer.scene_name() == "battle-switch" + } + + /// The battle sub-state the seam reports, as a word, for the record and the failure message. + fn battle_sub_state(&mut self) -> &'static str { + 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); + match state.battle() { + None => "no-battle", + Some(battle) => match battle.menu { + BattleMenu::Main { .. } => "main", + BattleMenu::Moves { cursor: Some(_), .. } => "moves", + BattleMenu::Moves { cursor: None, .. } => "moves-unplaceable", + BattleMenu::Party { .. } if battle.forced_switch => "party-forced", + BattleMenu::Party { .. } => "party", + BattleMenu::Bag { .. } => "bag", + BattleMenu::None => "between-turns", + }, + } + } + fn battle_list_open(&mut self) -> bool { use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState}; let ledger = AdapterLedger(&self.adapter); @@ -483,9 +570,30 @@ impl Run { } // 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"); + if self.battle_pad() { + let back = bound.iter().any(|channel| channel.as_str() == "macro_back"); + if back { + let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state()); + self.battle_back_where.insert(where_); + } + if back && !self.battle_list_open() { + self.back_without_a_list = true; + } + } + // Section 12.10, on the cartridge: no battle pad holds a pair that undoes itself, and + // `NEXT` is on no frame with a cursor accepting input. The second is the stronger of the + // two -- the live pair was split across two sub-states, so no single pad held both. + if self.battle_pad() { + let next = bound.iter().any(|channel| channel.as_str() == "macro_next"); + let back = bound.iter().any(|channel| channel.as_str() == "macro_back"); + self.next_and_back_on_one_pad |= next && back; + if next { + let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state()); + self.battle_next_where.insert(where_); + } + if next && self.own_turn() { + self.next_on_a_menu_accepting_input = true; + } } // 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")); @@ -506,11 +614,32 @@ impl Run { .collect(); (decision.mask, started) }; + let in_battle_now = self.in_battle() != 0; + let on_a_battle_pad = self.battle_pad(); 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; } + // Section 12.10's own signature, as the live event log printed it: `NEXT start/done, + // BACK start/done`, every hold, for seventy-one hours. Measured as the longest chain + // of consecutive battle starts drawn from those two alone and strictly alternating. + if in_battle_now { + self.macros_this_battle += 1; + } + if on_a_battle_pad { + let two = name == "NEXT" || name == "BACK"; + self.alternation = match self.last_battle_start { + Some(last) if two && (last == "NEXT" || last == "BACK") && last != name => { + self.alternation.max(1) + 1 + } + _ if two => 1, + _ => 0, + }; + self.longest_next_back_alternation = + self.longest_next_back_alternation.max(self.alternation); + self.last_battle_start = Some(name); + } *self.started.entry(name).or_insert(0) += 1; } self.gb.set_buttons(mask as u8); @@ -522,6 +651,24 @@ impl Run { let ledger = AdapterLedger(&self.adapter); let _ = self.layer.observe(&mut self.gb, &ledger, ms); } + // Battle boundaries, after the frame: what a battle cost in macros, and whether it ended. + let now_in_battle = self.in_battle() != 0; + match (self.was_in_battle, now_in_battle) { + (false, true) => { + self.battles_entered += 1; + self.macros_this_battle = 0; + self.last_battle_start = None; + self.alternation = 0; + } + (true, false) => { + self.battles_ended += 1; + self.worst_battle_macros = + self.worst_battle_macros.max(self.macros_this_battle); + self.macros_this_battle = 0; + } + _ => {} + } + self.was_in_battle = now_in_battle; let map = self.map(); if self.route.last() != Some(&map) && map != u32::MAX { self.route.push(map); @@ -744,7 +891,8 @@ fn forest_checkpoint() -> Option { ) } -/// From the rung-9 forest checkpoint: the turns advance, and `BACK` is never a battle's whole pad. +/// From the rung-9 forest checkpoint: the turns advance, the battles end, and no two buttons on a +/// battle pad undo each other. /// /// **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 @@ -754,9 +902,18 @@ fn forest_checkpoint() -> Option { /// `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. +/// **What was live again** (2026-09-22, thirty-five minutes after v0.4.2): the same rung, the same +/// forest, and the macro starts since the restart were `NEXT` 1264, `BACK` 1241, `THROW BALL` 5 and +/// `GO WARP` 3, the event log alternating `NEXT start/done, BACK start/done` every hold. The pair +/// was split across two sub-states of one turn, so 12.9's rule held and the loop survived it: +/// `NEXT` on the top-level menu was an A press on FIGHT, which **opened** the move list, and `BACK` +/// on the move list **closed** it again. Section 12.10 takes `NEXT` off every pad with a cursor +/// accepting input and gives the bag its own; `MOVE 1` is the backstop the top-level menu keeps. +/// +/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that it +/// ends on a bounded number of macros, that the fly's own presses are what ends it, and that none +/// of the three 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 \ @@ -813,9 +970,34 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() { 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"); + // The traps, as assertions on the cartridge. + eprintln!( + "`BACK` in a battle was dealt on {:?}; `NEXT` on {:?}", + run.battle_back_where, run.battle_next_where + ); + assert!( + !run.back_without_a_list, + "`BACK` was on a battle pad with no list open: {:?}", + run.battle_back_where + ); assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds"); + // Section 12.10, the two halves of it. + assert!( + !run.next_and_back_on_one_pad, + "a battle pad held both `NEXT` and `BACK`: two buttons that undo each other" + ); + assert!( + !run.next_on_a_menu_accepting_input, + "`NEXT` was on the pad while a battle menu was accepting input, where A opens rather \ + than advances" + ); + // And the shape the log had, rather than only the pads it came from. Two in a row is the + // rotation happening to deal the pair; the live run did it for seventy-one hours. + assert!( + run.longest_next_back_alternation < 4, + "`NEXT`/`BACK` alternated {} times in a row", + run.longest_next_back_alternation + ); // The turn moves: the fly's own battle presses happen, and a battle this run entered or // resumed finishes. @@ -828,6 +1010,33 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() { .sum(); assert!(battle_presses > 0, "no move and no ball: {:?}", run.started); assert!(ended > 0, "no battle ever ended: {battles} entered"); + // **Every battle that started, finished**, and each one on a bounded number of macros. That is + // the claim the 2-cycle breaks, and the way it breaks it is the opposite of a slow fight: the + // battle never leaves the fly's own turn at all, so the count grows with the *run*. On v0.4.2 + // from this same checkpoint the trap hunt spent all twenty of its brain minutes -- 71,673 + // frames, 1,489 macros, 73 of 73 windows flagged -- inside **one** battle that never ended, + // with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu. Four hundred + // is generous against the 275 this run's worst battle measured and far under an unbounded + // cycle. + assert!( + run.battles_ended + 1 >= run.battles_entered, + "{} battles entered and only {} left: a battle was entered and never got out", + run.battles_entered, + run.battles_ended + ); + assert!( + run.worst_battle_macros > 0 && run.worst_battle_macros < 400, + "the worst battle cost {} macros over {} that ended", + run.worst_battle_macros, + run.battles_ended + ); + eprintln!( + "battles: {} entered, {} ended, worst {} macros; longest NEXT/BACK alternation {}", + run.battles_entered, + run.battles_ended, + run.worst_battle_macros, + run.longest_next_back_alternation + ); // `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 From f0cebd25746806d5ec2248277a9eba523b20513b Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 06:15:09 +0000 Subject: [PATCH 3/3] docs: section 12.10, and rows 42 and 43 of the trap audit The contract: no pair of buttons on any battle pad may undo each other with nothing else changing, which is section 12.2's rule stated at the pad instead of at one macro. Section 13.1's table takes NEXT off the own-turn main menu, gives the bag a row of its own on the own turn, and records MOVE 1 as the backstop that row keeps. The audit carries the reproduction -- 71,673 frames in one battle, 73 of 73 windows flagged, BACK 739 starts on the move list against NEXT 739 on the main menu -- the ROM-gated run before and after, the trap hunt before and after (1 distinct tile to 260, 73 of 73 flagged windows to 68 of 73, 0 overworld frames to 20,394), and the residuals: NEXT on a move list whose cursor the seam cannot place is now the largest source of it, which is row 30b and wants a WRAM reading rather than a pad change. --- docs/design/macros.md | 64 ++++++++++++++- infra/docs/macros-traps.md | 163 +++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 4 deletions(-) diff --git a/docs/design/macros.md b/docs/design/macros.md index ef0d405..b246c1a 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -859,6 +859,61 @@ reproduction and the numbers; two macros, and section 12.2's one rule between th The decoder, the reward catalog, the adapter version and the compatibility string are untouched. +### 12.10 No two buttons on a battle pad undo each other (2026-09-22, rung 9, seventy-one hours) + +Thirty-five minutes after v0.4.2 deployed, the watchdog flagged the same rung again. Rank 9 +(VIRIDIAN FOREST, next PEWTER CITY), seventy-one hours on it, and since the restart the macro +starts were `NEXT` **1264**, `BACK` **1241**, `THROW BALL` 5 and `GO WARP` 3 -- the event log +alternating `NEXT start/done, BACK start/done` every hold, on map 51. + +Reproduced from the live checkpoint with the real cartridge and the real brain +(`infra/docs/macros-traps.md` has the numbers): twenty brain minutes, **71,673 frames of them all +in one battle**, 73 of 73 windows flagged, one distinct tile, and the two counts that name the +mechanism -- `BACK` **739 starts on the move list** and `NEXT` **739 on the top-level menu**. + +- **The pair was split across two sub-states of one turn, which is why 12.9 did not catch it.** + `NEXT`'s script is one press of A. On a frame of battle text that A advances the text, which is + what the button is for. On the *top-level battle menu* the same A press **confirms whatever the + cursor is sitting on**, and the cursor sits on FIGHT, so `NEXT` opened the move list -- whose + `BACK` closed it again. Each button was legitimate where it stood: 12.9's rule is about `BACK` + and a list, and there *was* a list to leave. What is a trap is the **pair**: two buttons that + undo each other with nothing else changing, so the roll lands on one of them nearly every hold + and the turn never resolves. Section 12.2's rule, stated at the pad instead of at one macro: + **no pair of buttons on any battle pad may undo each other with nothing else changing.** +- **`NEXT` is the A that advances text, so it belongs only on a frame with no cursor accepting + input.** It is off every own-turn pad: the top-level menu, the move list, the party list and the + bag. The between-turns row keeps it alone, and the forced switch keeps it as row 8's backstop -- + the one arm with a cursor and no `BACK` at all, because a forced switch cannot be cancelled. +- **`MOVE 1` is the backstop the top-level menu keeps.** `NEXT` was there for the turn where every + other button drops (row 7), and it was the wrong button for the job twice over: it does not end + a turn, and what it does instead is reopen the list `BACK` had just closed. `MOVE 1`'s + precondition over that menu is 12.8's -- "is there a move list to open", and FIGHT always opens, + because `CheckPlayerHasUsableMoves` sets Struggle without opening it -- so it is bound there + whatever the seam makes of `wBattleMon*`, and its script over that menu is "confirm FIGHT and + stop", which reads no move at all. A battler the seam cannot read is not a reason to take the + turn's one ending button away. +- **The bag is the fly's turn, and its pad is the bag's own answers.** `Battle::own_turn` answered + `false` for it, which was the last frame in the game with a cursor accepting input and no own + turn -- so it landed on the between-turns row, and that row's `NEXT` on an open bag is the A + press that *uses* whatever the cursor holds. The invariant is now whole: **a battle frame with a + cursor accepting input is the fly's turn**, the forced switch excepted because it has a pad of + its own. The bag's row is `ITEM` (use the thing) / `THROW BALL` / `BACK`; `CONFIRM` is gone from + it, because it was the same blind A press under another name, and both scripts already navigated + this list by reading its cursor (7.1 of `macros-wram.md`). +- **`wListMenuID` is not stale, which is why the bag reading stands.** It is zeroed by + `DisplayTextIDInit` at the start of every text display (`macros-wram.md`), so a battle's text + frames cannot inherit an `ITEMLISTMENU` from a bag the fly closed. Checked because a stale byte + there would have put the bag's pad on every frame of battle text. +- **What the harness now holds, rather than the pads alone.** The ROM-gated run from this + checkpoint asserts that no battle pad deals both buttons, that `NEXT` is on no frame with a + cursor accepting input, that the longest `NEXT`/`BACK` alternation is under four, and that every + battle it enters *ends* -- 25 of 25, worst 275 macros -- where v0.4.2 spent a whole hunt inside + one that never did. The battle rules are asked of the **scene the pad was dealt for** and not of + `wIsInBattle`: the `$ff` frame a lost battle passes through reads `Unknown`, whose pad is + `NEXT, BACK` by contract (row 9), and it is not under a battle's rules. + +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.") @@ -936,12 +991,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, 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, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as | | Battle, own turn, move list | MOVE 1..4, BACK | as above | | Battle, own turn, 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` 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 | +| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds | +| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it | +| Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 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. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text | | 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 | @@ -983,6 +1038,7 @@ measured where it cannot. | a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette | | the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt | | a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change | +| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens | And because "closed where a macro can close it" is not "closed": `game.padEmptyMs` publishes how long a **playable** scene has had nothing on the pad, in brain diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 9e5918e..984cbb2 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -1359,3 +1359,166 @@ Four things about it that are not improvements, recorded rather than buried. - `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. + +## 2026-09-22, rows 42 and 43: the pair that undid itself + +Thirty-five minutes after v0.4.2 deployed, the release watchdog flagged the same rung. Rank 9 +(VIRIDIAN FOREST, next PEWTER CITY), seventy-one hours on it, and since the restart the macro +starts were `NEXT` **1264**, `BACK` **1241**, `THROW BALL` 5 and `GO WARP` 3, with the event log +alternating + +``` +NEXT start, NEXT done, BACK start, BACK done +``` + +every hold on map 51. It is the residual the previous review named and left -- "a `NEXT, BACK` +2-cycle x75 closes the run inside one battle... worth watching" -- at full scale, and the reason +that review's rule did not catch it is that **each of the two buttons was legitimate where it +stood**. `docs/design/macros.md` section 12.10 is the contract this closed against. + +### The mechanism, measured + +Twenty brain minutes from the release container's own rung-9 checkpoint, real cartridge, real +brain as the readout. The whole run -- **71,673 frames, all of them `battle`** -- was one battle, +on one tile, with 73 of 73 windows flagged and every window reading `NEXT, BACK` x74 or x75. The +two rows of the hunt's own sub-state table are the mechanism: + +| macro start | battle sub-state | n | +| --- | --- | ---: | +| `BACK` | move list | **739** | +| `NEXT` | **main menu** | **739** | +| `NEXT` | move list, no cursor | 8 | +| `BACK` | party list | 1 | +| `MOVE 1` | main menu | 1 | +| `THROW BALL` | main menu | 1 | + +1. **`NEXT` on the top-level battle menu is not the press that advances text.** `NEXT`'s script is + one press of A. Between turns that A advances the text box, which is the button's whole + purpose. On the top-level menu the same A press **confirms whatever the cursor sits on**, and + the cursor sits on FIGHT -- so `NEXT` *opened the move list*. It was put there as row 7's + backstop for a turn where every other button drops out, and it is the wrong button for that + job twice over: it does not end a turn, and what it does instead is reopen a list. +2. **`BACK` on the move list closed it again**, which is 12.9's contract and correct: backing out + of a list is one of exactly two answers to one. So neither rule was broken and the loop was a + *pair*: main menu -> `NEXT` -> move list -> `BACK` -> main menu, 739 times each, at 2.4 macros + per brain second, with the turn never resolving and the battle never ending. Section 12.2's + rule restated at the pad: **no pair of buttons on any battle pad may undo each other with + nothing else changing.** +3. **The bag was the last frame in the game with a cursor accepting input and no own turn.** + `Battle::own_turn` answered `false` for it (12.6 had no observable for the bag at all; 13.1 gave + it a cursor), so it landed on the between-turns row -- whose `NEXT` on an open bag is the A + press that *uses* whatever the cursor holds. Not what the live loop was made of, and the same + defect: an A press dealt where A does not advance text. + +### What changed, all of it inside the macros + +- **`NEXT` is off every own-turn pad**: the top-level menu, the move list, the party list and the + bag. It stays alone on the between-turns row and stays as row 8's backstop on the forced switch, + which is the one arm with a cursor up and no `BACK` at all, because it cannot be cancelled. +- **`MOVE 1` is the top-level menu's backstop instead**, and it is bound there whatever the seam + makes of `wBattleMon*`: the question over that menu is 12.8's "is there a move list to open", + FIGHT always opens, and `MOVE 1`'s script over that menu is "confirm FIGHT and stop", which reads + no move at all. A battler the seam cannot read is not a reason to take the turn's one ending + button away. Over an **open list** the per-slot PP rule is unchanged (row 30a, row 34). +- **The bag is the fly's turn** (`state::battle`), and its pad is the bag's own three answers: + `ITEM` (use what the cursor is on), `THROW BALL`, `BACK`. `CONFIRM` is gone from it -- the same + blind A press under another name -- and both scripts already navigated this list by reading its + cursor (`macros-wram.md` 7.1). The invariant is now whole: **a battle frame with a cursor + accepting input is the fly's turn**, the forced switch excepted because it has a pad of its own. +- **`wListMenuID` was checked for staleness rather than assumed sound**: it is zeroed by + `DisplayTextIDInit` at the start of every text display (`macros-wram.md`), so a battle's text + frames cannot inherit an `ITEMLISTMENU` from a bag the fly closed, and the bag reading stands. +- **The harness asks the scene the pad was dealt for, not `wIsInBattle`.** The `$ff` frame a lost + battle passes through reads `Unknown`, whose pad is `NEXT, BACK` by contract (row 9); the first + version of the new assertion accused that row of a battle rule it is not under. + +| # | trap | trigger | test | fix, or why it is left | +| ---: | --- | --- | --- | --- | +| 42 | two buttons on a battle pad undo each other with nothing else changing: `NEXT` on the top-level menu opens the move list, `BACK` on the move list closes it | every wild battle, every turn -- 739 starts each in twenty brain minutes, one battle, 73 of 73 windows flagged | `no_battle_pad_holds_both_next_and_back`, `the_battle_row_is_the_move_buttons_switch_item_and_never_next`, `each_battle_menu_deals_its_own_pad`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated: it **fails on v0.4.2** with `NEXT` dealt on `battle/main`) | **fixed**: `NEXT` is off every pad with a cursor accepting input, and `MOVE 1` is the top-level menu's backstop, bound there whatever the battler reads as. `NEXT` keeps the between-turns row and the forced switch | +| 43 | the battle bag reads as nobody's turn, so its pad is the between-turns `NEXT` -- an A press that *uses* what the cursor holds | choosing ITEM in a battle; rare, because `ITEM` needs a potion and a hurt Pokemon | `a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn`, `the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses`, `back_is_on_a_battle_pad_only_where_a_list_is_open` | **fixed**: `own_turn` is true for the bag, and its pad is `ITEM` / `THROW BALL` / `BACK`. This is row 30c closed -- it was left in 12.6 for want of an observable, given a cursor in 13.1, and given the right *turn* here | + +### The ROM-gated run, before and after + +Same test, same checkpoint, sixty-seven brain minutes of the game-blind rotation, `v0.4.2` +(`592c264`) against this branch. + +| measure | before (v0.4.2) | after | +| --- | --- | --- | +| verdict | **FAILED**: "`NEXT` was on the pad while a battle menu was accepting input" | **passed** | +| `NEXT` dealt on | `battle/between-turns`, **`battle/main`**, `battle/moves-unplaceable` | `battle/between-turns`, `battle/moves-unplaceable` | +| `BACK` dealt on | `battle/moves`, `battle/party` | `battle/bag`, `battle/moves`, `battle/party` | +| maps visited | **[51]** -- never left the forest | 51, 0, 12, 1, 41, 44, 42, 13, 50 (nine) | +| battles entered / ended | 14 / 15 | 26 / 25 | +| worst battle, in macros | not measurable: the run never left the fly's own turn | **275** | +| longest `NEXT`/`BACK` alternation | (the assertion fires first) | **1** | +| `MOVE 1..4` starts | 96 | **718** | +| `NEXT` / `BACK` starts | 1235 / 1315 of 3944 | 526 / 313 of 2090 | +| own-turn frames | 20,914 | 143,180 | +| `GO OBJECTIVE` on an overworld pad | map 51 only | nine maps | + +### The trap hunt, before and after + +Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge, +from the release container's rung-9 checkpoint, **driven by the brain** rather than by +`FLY_TRAP_STUB`: this branch adds no macro type, so 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. + +| measure | before (v0.4.2) | after | +| --- | ---: | ---: | +| distinct (map, tile) | **1** | **260** | +| windows flagged | 73/73 | **68/73** | +| windows under 4 tiles | **73** | **2** | +| longest repeated sequence in a window | **`NEXT, BACK` x75** | `NEXT` x20 | +| macros started | 1489 | 797 | +| frames in `battle` | **71,673** (one battle, the whole run) | 50,600 | +| frames in `overworld` | **0** | **20,394** | +| `NEXT` starts on the main menu | **739** | **0** | +| `BACK` starts on the move list | 739 | 263 | +| `MOVE n` starts | **1** | **113** | +| `THROW BALL` starts | 1 | 63 | +| `RUN` starts | 0 | 7 | + +The before arm never left the battle it resumed in and never left the tile it stood on. The after +arm fought fifty thousand frames of battle *and* walked twenty thousand frames of overworld across +260 tiles. + +Raw reports: `hunt-before-20260922T0459.md` and `hunt-after-20260922T0459.md` in the coordination +state's `runs/` directory. + +### Residuals, named rather than worked around + +- **`NEXT` on a move list whose cursor the seam cannot place is now the largest source of it** -- + 142 of the after arm's 248 `NEXT` starts, over 8,687 frames. That frame is *correctly* not the + fly's turn (row 30b: `MoveSelectionMenu`'s coordinates appear before the engine has copied the + active Pokemon into `wBattleMon*`), so `NEXT` there is the between-turns press and it advances. + It is not a 2-cycle -- the ROM-gated run measures the longest `NEXT`/`BACK` alternation at + **1** -- but it is the same unplaceable cursor 12.6 named, and the honest fix is still a WRAM + reading rather than a pad change. +- **`BACK` is 326 of 797 starts**, all of them over an open list: 263 on the move list and 63 on a + party list. Row 34's contract, and where it leads is a menu with `MOVE 1` on it. +- **The 68 windows still flagged are the sequence rule's dominance arm, not a cycle.** They read + `NEXT` x11 to x20 or `BACK` x11 to x14 over windows holding **5 to 86** distinct tiles, where + every window of the before arm read `NEXT, BACK` x74 on **one** tile. Only two windows of the + after arm are under four tiles, against seventy-three before. A window spent in a long battle is + one tile by construction, which is the known false-positive shape of the tile rule and why this + row is proved on the pad and sub-state tables as well. +- **Row 41 is not reproduced here and is not closed.** The after arm's text boxes are 140 frames + at (6, 30) of map 0x33 and smaller counts on seven other tiles, with no Pokemon Center in the + run at all; the nurse's `YES` loop needs its own checkpoint to measure. +- **The road north is still reported rather than asserted.** The ROM-gated run leaves the forest + and reaches maps 50, 13, 12, 1, 41, 44, 42 and 0 with `GO OBJECTIVE` on the overworld pad of all + nine, but it does not reach map 47 -- the forest's north gate -- in sixty-seven brain minutes of + the game-blind rotation. Row 40's note stands. + +### 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.2 on this box with the same assertion (a debug build of the + service does not finish booting inside the test's window here). Pre-existing and unrelated to + the macro layer. +- `cargo clippy --all-targets`: clean. +- `infra/tests/lint.sh`: all checks passed, de-PII guard included. +- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1 and + v0.4.2. Decoder, reward catalog, adapter version and roles untouched.