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.
This commit is contained in:
acamilo 2026-09-22 06:14:59 +00:00
parent 05c2552d78
commit 520298e5c3
3 changed files with 434 additions and 29 deletions

View file

@ -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.

View file

@ -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();

View file

@ -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<String>,
battle_next_where: std::collections::BTreeSet<String>,
/// 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<flysim::store::Checkpoint> {
)
}
/// 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<flysim::store::Checkpoint> {
/// `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