diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 96e49cd..a29bf76 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -272,12 +272,44 @@ pub enum TargetKey { Tile(Tile), } -/// A cursor the current scene's macros navigate: where it is, and how far it can go. +/// Which list the shared cursor belongs to right now. +/// +/// Red keeps one cursor for every menu in the game (`wCurrentMenuItem`), so "where is the cursor" +/// is only half a question: a script that opens the bag from the battle menu and then navigates to +/// a bag index has to know that the index it is aiming at belongs to the *bag* and not to the four +/// entries it was reading a moment ago. Measured on the cartridge 2026-09-22: `THROW BALL` was +/// **63 starts and 63 `blocked`**, mean sixty-nine frames, because the bag takes longer than the +/// twenty settle frames to draw -- so the step that should have walked the bag list read the +/// battle menu's `max` of 3, found the ball's bag index above it, and gave up at once +/// (`docs/design/macros.md` section 12.11). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListKind { + /// FIGHT / PKMN / ITEM / RUN. + BattleMain, + /// The move list. + BattleMoves, + /// The party list, inside a battle. + BattleParty, + /// The bag, inside a battle. + BattleBag, + /// The start menu. + StartMenu, + /// A mart's counter, on whichever of its screens is up. + Shop, + /// A PC. + Pc, +} + +/// A cursor the current scene's macros navigate: which list it is, where it is, and how far it can +/// go. /// /// Derived from whichever of agent A's menus is up, so a script asks "where is the cursor" once -/// and does not care whether it is in a battle, a mart or the start menu. +/// and does not care whether it is in a battle, a mart or the start menu -- but it can ask *which* +/// list answered, which is what a script that crosses from one list into another needs +/// ([`ListKind`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Listing { + pub kind: ListKind, pub current: u8, pub max: u8, } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs index 74ba77d..1b8e45b 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs @@ -25,7 +25,8 @@ use crate::adapter::MemoryReader; use crate::emulator::buttons; use super::cartridge::{ - FACINGS, MacroState, TalkTarget, TargetKey, Tile, battle_entry, button, item, opposite, + FACINGS, ListKind, MacroState, TalkTarget, TargetKey, Tile, battle_entry, button, item, + opposite, }; use super::geography::Amenity; use super::palette::{ @@ -411,14 +412,32 @@ struct Walk { struct Cursor { target: u8, confirm: bool, - /// The directions to try, in order, aimed at the target by the first comparison. - order: [Facing; 4], + /// Which list `target` indexes into, when the script knows -- and it always does when it has + /// just pressed A to open one. + /// + /// `None` navigates whatever list is up, which is what a step that does not cross a boundary + /// wants: the shop's own screens are one [`ListKind`] and the step after a purchase's A press + /// is still the counter's. + /// + /// While the list up is a *different* one, the step waits rather than pressing at it, exactly + /// as it waits for a list that reports no cursor at all (section 4: "never by counting + /// presses"). Section 12.11, measured: `THROW BALL` pressed ITEM, spent its twenty settle + /// frames, and then read the battle *menu*'s `max` of 3 -- so a ball at bag index 4 was + /// "off the end of the list" and the macro reported `blocked` on the first frame of the step, + /// 63 times out of 63. + want: Option, + /// The directions to try, in order, aimed at the target from where the cursor actually is. + /// + /// `None` until the list this step is for is accepting input, because "which way is the + /// target" is a fact about that list and not about whatever was up when the script was built. + order: Option<[Facing; 4]>, /// Which of them is being tried now. at: u8, /// The cursor value the current pulse started from. before: u8, - /// Presses left in the budget. - left: u8, + /// Presses left in the budget, `None` until the list is up: twice the list plus slack, and + /// the list whose length that is has to be the one being walked. + left: Option, phase: u32, /// Frames spent waiting for the list to accept input. waited: u32, @@ -1370,9 +1389,19 @@ fn goal_tile(walk: &Walk) -> Tile { /// which is what section 4 means by "never by counting presses": a list that is a column moves /// under DOWN and UP, Red's two-by-two battle menu also needs RIGHT and LEFT, and this finds out /// which by watching rather than by knowing. A list that is not accepting input yet is *waited* -/// for, never pressed at. +/// for, never pressed at -- and since section 12.11 so is a list that is up but is **not the one +/// this step is walking**, because Red keeps one cursor for every menu in the game and reading the +/// wrong one is not reading. fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress { - let Some(list) = listing(state) else { + // A confirming press that has begun finishes, and it is read before the list: the press is + // what *answers* the list, so on the very frames it is being issued the cartridge is already + // drawing the next one, and waiting for the list this step walked would wait for a list the + // step has just left. + if cursor.confirmed { + return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP); + } + let list = listing(state).filter(|list| cursor.want.is_none_or(|want| want == list.kind)); + let Some(list) = list else { cursor.waited += 1; return if cursor.waited > CURSOR_WAIT { Progress::Blocked @@ -1381,9 +1410,6 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress { }; }; let here = list.current; - if cursor.confirmed { - return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP); - } if here == cursor.target { if !cursor.confirm { return Progress::Next; @@ -1392,18 +1418,25 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress { cursor.phase = 0; return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP); } - if cursor.left == 0 || cursor.target > list.max { + // The first frame the list this step is for is accepting input is where the navigation is + // aimed from and budgeted by. Both are facts about *this* list, and a script that opened it + // could not have known either when it was built. + let aimed = aim_order(cursor.target, here); + let order = *cursor.order.get_or_insert(aimed); + let sized = list.max.saturating_add(1).saturating_mul(2).saturating_add(CURSOR_SLACK); + let budget = *cursor.left.get_or_insert(sized); + if budget == 0 || cursor.target > list.max { return Progress::Blocked; } if cursor.phase == 0 { cursor.before = here; } - let facing = cursor.order[usize::from(cursor.at) % cursor.order.len()]; + let facing = order[usize::from(cursor.at) % order.len()]; match pulse(button(facing), &mut cursor.phase, PRESS_HOLD, PRESS_GAP) { Progress::Next => { let closer = here.abs_diff(cursor.target) < cursor.before.abs_diff(cursor.target); cursor.at = if closer { 0 } else { (cursor.at + 1) % 4 }; - cursor.left -= 1; + cursor.left = Some(budget.saturating_sub(1)); cursor.phase = 0; Progress::Hold(buttons::NONE) } @@ -1411,6 +1444,19 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress { } } +/// The directions a cursor tries, in order, to get from `here` to `target`. +/// +/// A column moves under DOWN and UP; Red's two-by-two battle menu needs RIGHT and LEFT as well, +/// and which of the four works is found by watching the cursor rather than by knowing the +/// geometry, so this only decides which to try *first*. +const fn aim_order(target: u8, here: u8) -> [Facing; 4] { + if target > here { + [Facing::Down, Facing::Right, Facing::Up, Facing::Left] + } else { + [Facing::Up, Facing::Left, Facing::Down, Facing::Right] + } +} + /// Build the script for one macro, or `None` when there is nothing to walk to. /// /// Every target is computed here, once, from the state the macro started in — which move, which @@ -1520,7 +1566,7 @@ fn script( } else { cursor_at.filter(|at| *at < count)? }; - steps.push(cursor(state, target, true)); + steps.push(cursor_on(target, true, Some(ListKind::BattleMoves))); return Some((steps.into(), aimed)); } // From the menu above, FIGHT has to be chosen first, whether or not anything has PP: @@ -1529,7 +1575,7 @@ fn script( if !in_main_menu(state) { return None; } - steps.push(cursor(state, battle_entry::FIGHT, true)); + steps.push(cursor_on(battle_entry::FIGHT, true, Some(ListKind::BattleMain))); steps.push(settle()); // What happens after FIGHT is the cartridge's own answer and it is measured rather // than assumed (row 34): with a move that has PP the list opens and this slot is @@ -1538,7 +1584,7 @@ fn script( // second cursor step would press A at text. `MOVE 1` is the button that reaches that // state, because it is the only one bound there. if slot_has_pp(state, slot) { - steps.push(cursor(state, slot, true)); + steps.push(cursor_on(slot, true, Some(ListKind::BattleMoves))); } steps } @@ -1550,10 +1596,13 @@ fn script( let bag_slot = throw_slot(state)?; let mut steps = Vec::new(); if in_main_menu(state) { - steps.push(cursor(state, battle_entry::ITEM, true)); + steps.push(cursor_on(battle_entry::ITEM, true, Some(ListKind::BattleMain))); steps.push(settle()); } - steps.push(cursor(state, bag_slot, true)); + // The bag, and it has to *be* the bag: this is the step that reported `blocked` 63 + // times out of 63 on the cartridge while it was allowed to read the battle menu's + // cursor instead (section 12.11). + steps.push(cursor_on(bag_slot, true, Some(ListKind::BattleBag))); steps } MacroKind::Switch => { @@ -1562,10 +1611,10 @@ fn script( // A forced switch is already looking at the party list; a chosen switch has to get // there through the battle menu's PKMN entry first. if in_main_menu(state) { - steps.push(cursor(state, battle_entry::PKMN, true)); + steps.push(cursor_on(battle_entry::PKMN, true, Some(ListKind::BattleMain))); steps.push(settle()); } - steps.push(cursor(state, slot, true)); + steps.push(cursor_on(slot, true, Some(ListKind::BattleParty))); steps.push(settle()); // The party entry's action list opens on SWITCH. steps.push(press(buttons::A)); @@ -1576,17 +1625,19 @@ fn script( let active = state.battle().and_then(|battle| battle.own).map(|mon| mon.slot)?; let mut steps = Vec::new(); if in_main_menu(state) { - steps.push(cursor(state, battle_entry::ITEM, true)); + steps.push(cursor_on(battle_entry::ITEM, true, Some(ListKind::BattleMain))); steps.push(settle()); } - steps.push(cursor(state, bag_slot, true)); + steps.push(cursor_on(bag_slot, true, Some(ListKind::BattleBag))); steps.push(settle()); // Which Pokémon to heal: the one that is out, because that is the HP the precondition // measured. - steps.push(cursor(state, active, true)); + steps.push(cursor_on(active, true, Some(ListKind::BattleParty))); steps } - MacroKind::Run => vec![cursor(state, battle_entry::RUN, true)], + MacroKind::Run => { + vec![cursor_on(battle_entry::RUN, true, Some(ListKind::BattleMain))] + } MacroKind::BuyPotion => shop_plan(state, item::POTION)?, MacroKind::BuyBall => shop_plan(state, item::POKE_BALL)?, MacroKind::BuyAntidote => shop_plan(state, item::ANTIDOTE)?, @@ -1675,24 +1726,31 @@ fn settle() -> Step { Step::Settle { phase: 0 } } -/// A cursor step aimed at `target`, with its press order and budget taken from the list that is -/// up right now. -fn cursor(state: &mut dyn MacroState, target: u8, confirm: bool) -> Step { - let list = listing(state); - let from = list.map_or(0, |list| list.current); - let max = list.map_or(target, |list| list.max); +/// A cursor step aimed at `target` in whichever list is accepting input when it runs. +/// +/// For a step that does not cross from one list into another: the shop's own screens, and the +/// first step of any script, which reads the list its macro was dealt on. +fn cursor(target: u8, confirm: bool) -> Step { + cursor_on(target, confirm, None) +} + +/// A cursor step aimed at `target` in `want`, waiting for that list rather than pressing at +/// whichever one happens to be up. +/// +/// The press order and the budget are taken from the list on the first frame it accepts input +/// ([`cursor_frame`]) and not from here, because a script that has just pressed A to open a list +/// is still reading the list it pressed A *in*: twenty settle frames is not always enough for the +/// cartridge to draw the next one, and the one it has is the wrong length and points the wrong way +/// (section 12.11). +fn cursor_on(target: u8, confirm: bool, want: Option) -> Step { Step::Cursor(Cursor { target, confirm, - order: if target > from { - [Facing::Down, Facing::Right, Facing::Up, Facing::Left] - } else { - [Facing::Up, Facing::Left, Facing::Down, Facing::Right] - }, + want, + order: None, at: 0, - before: from, - // Twice the list, plus slack for a press the game swallows while a menu draws. - left: max.saturating_add(1).saturating_mul(2).saturating_add(CURSOR_SLACK), + before: target, + left: None, phase: 0, waited: 0, confirmed: false, @@ -1902,10 +1960,10 @@ fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option> { let mut steps = Vec::new(); if shop_screen(state)? == ShopScreen::BuySellQuit { // BUY is the counter menu's first entry. - steps.push(cursor(state, 0, true)); + steps.push(cursor(0, true)); steps.push(settle()); } - steps.push(cursor(state, index, true)); + steps.push(cursor(index, true)); steps.push(settle()); // The quantity prompt opens on one, and the price confirmation opens on YES. steps.push(press(buttons::A)); 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 4cb61f1..d133701 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 @@ -10,8 +10,8 @@ use super::cartridge::{ CHEAPEST_PURCHASE, FACINGS, opposite, - ExitId, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget, TargetKey, - Tile, item, outdoors, + ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget, + TargetKey, Tile, item, outdoors, }; use crate::adapter::PlaceKind; @@ -1735,28 +1735,42 @@ pub fn throw_slot(state: &mut dyn MacroState) -> Option { pub fn listing(state: &mut dyn MacroState) -> Option { if let Some(battle) = state.battle() { return match battle.menu { - BattleMenu::Main { cursor } => Some(Listing { current: cursor, max: 3 }), - BattleMenu::Moves { cursor: Some(cursor), count } => { - Some(Listing { current: cursor, max: count.saturating_sub(1) }) + BattleMenu::Main { cursor } => { + Some(Listing { kind: ListKind::BattleMain, current: cursor, max: 3 }) } + BattleMenu::Moves { cursor: Some(cursor), count } => Some(Listing { + kind: ListKind::BattleMoves, + current: cursor, + max: count.saturating_sub(1), + }), BattleMenu::Moves { cursor: None, .. } | BattleMenu::None => None, - BattleMenu::Bag { cursor, count } => { - (count > 0).then(|| Listing { current: cursor, max: count.saturating_sub(1) }) - } + BattleMenu::Bag { cursor, count } => (count > 0).then(|| Listing { + kind: ListKind::BattleBag, + current: cursor, + max: count.saturating_sub(1), + }), BattleMenu::Party { cursor } => { let max = u8::try_from(state.party().mons.len().saturating_sub(1)).unwrap_or(0); - Some(Listing { current: cursor, max }) + Some(Listing { kind: ListKind::BattleParty, current: cursor, max }) } }; } if let Some(menu) = state.start_menu() { - return Some(Listing { current: menu.cursor.current, max: menu.cursor.max }); + return Some(Listing { + kind: ListKind::StartMenu, + current: menu.cursor.current, + max: menu.cursor.max, + }); } if let Some(shop) = state.shop() { - return Some(Listing { current: shop.cursor.current, max: shop.cursor.max }); + return Some(Listing { + kind: ListKind::Shop, + current: shop.cursor.current, + max: shop.cursor.max, + }); } if let Some(pc) = state.pc() { - return Some(Listing { current: pc.cursor.current, max: pc.cursor.max }); + return Some(Listing { kind: ListKind::Pc, current: pc.cursor.current, max: pc.cursor.max }); } None } 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 33363db..12b3e1e 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 @@ -149,6 +149,15 @@ struct World { start_to_open: u8, /// What the next A presses open. opens: VecDeque, + /// Frames the cartridge spends *drawing* a list an A press opened, before it accepts input. + /// + /// Zero everywhere but the one test this is for. On the cartridge it is not zero and it is not + /// bounded by the twenty frames a script's `settle` waits: measured 2026-09-22, `THROW BALL` + /// pressed ITEM, settled, and then read the battle *menu*'s cursor because the bag had not + /// drawn yet -- 63 starts, 63 `blocked` (section 12.11). + opens_draw_in: u32, + /// The list an A press opened and the frame it starts accepting input on. + pending: Option<(u32, Opens)>, /// A scene the world switches to at this frame, for the abort rule. switch: Option<(u32, Scene)>, /// A frame at which the cartridge heals the party, which is what a Pokémon Center does while @@ -223,6 +232,8 @@ impl World { b_to_close: 1, start_to_open: 1, opens: VecDeque::new(), + opens_draw_in: 0, + pending: None, switch: None, heal_at: None, scripted: false, @@ -342,6 +353,15 @@ impl World { fn frame(&mut self, mask: u8) { self.frames += 1; + if let Some((at, next)) = self.pending + && self.frames >= at + { + self.list = next.list; + self.cursor = next.cursor; + self.cursor_max = next.max; + self.grid = next.grid; + self.pending = None; + } if let Some(at) = self.scripted_at && self.frames >= at { @@ -411,10 +431,14 @@ impl World { if mask == buttons::A && let Some(next) = self.opens.pop_front() { - self.list = next.list; - self.cursor = next.cursor; - self.cursor_max = next.max; - self.grid = next.grid; + if self.opens_draw_in > 0 { + self.pending = Some((self.frames + self.opens_draw_in, next)); + } else { + self.list = next.list; + self.cursor = next.cursor; + self.cursor_max = next.max; + self.grid = next.grid; + } } } @@ -1042,8 +1066,12 @@ fn a_move_button_confirms_its_own_slot_over_an_open_list() { assert_eq!(world.cursor, 2, "the third slot, by watching where the cursor went"); assert_eq!(world.pulses.last(), Some(&buttons::A)); - // And from the menu above, FIGHT first and then the slot. + // And from the menu above, FIGHT first and then the slot. The list has to actually open: + // since section 12.11 the step that walks it *waits* for the move list rather than reading + // whatever cursor is up, so a fixture where FIGHT opens nothing waits out `CURSOR_WAIT` -- + // which is the right answer, and is what the cartridge was doing to `THROW BALL` in reverse. let mut world = World::battle(); + world.opens.push_back(Opens { list: List::Moves(3), cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Move2).unwrap(), MacroAbort::Done); assert!( world.pulses.contains(&buttons::A), @@ -2142,6 +2170,10 @@ fn item_reaches_the_potion_by_reading_the_bags_cursor() { let mut world = World::battle(); world.bag = vec![(item::POKE_BALL, 3), (item::POTION, 2)]; world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 1, grid: false }); + // And confirming the potion opens the party list, which is where the script says which + // Pokémon to heal. Section 12.11: that step waits for the *party* list, so the fixture has to + // open it -- the cartridge does. + world.opens.push_back(Opens { list: List::BattleParty, cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Item).unwrap(), MacroAbort::Done); assert!( world.pulses.iter().filter(|mask| **mask == buttons::A).count() >= 2, @@ -4346,6 +4378,52 @@ fn the_move_list_deals_back_only_where_the_moves_can_be_read() { assert_eq!(world.pulses.last(), Some(&buttons::A)); } +/// Section 12.11: a cursor step waits for the list it was built for. +/// +/// **Measured on the cartridge, v0.4.3** (`infra/docs/macros-traps.md`): `THROW BALL` was **63 +/// starts and 63 `blocked`**, mean sixty-nine frames -- which is the cursor to ITEM, the A that +/// confirms it, the twenty settle frames, and then a refusal on the very next frame. The bag had +/// not drawn yet, so `listing` still answered for the battle *menu*: four entries, `max` 3. The +/// ball's own bag index was above that, so the step read "off the end of the list" and gave up at +/// once -- and where the index was inside it, the step pressed UP and LEFT at the battle menu +/// instead, which is the blind pressing section 4 forbids. +#[test] +fn throw_ball_waits_for_the_bag_rather_than_reading_the_menu_it_came_from() { + let mut world = World::battle(); + world.mons.truncate(1); + // Five items with the ball last, so its bag index is 4 -- above the battle menu's `max` of 3, + // which is the number the old step compared it against. + world.bag = vec![ + (item::POTION, 1), + (item::ANTIDOTE, 1), + (item::REPEL, 1), + (item::POTION, 1), + (item::POKE_BALL, 5), + ]; + assert_eq!(throw_slot(&mut world), Some(4)); + // The bag takes longer to draw than the script's twenty settle frames, which is the cartridge's + // own timing and the whole of the trap. + world.opens_draw_in = 40; + world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 4, grid: false }); + assert_eq!(run(&mut world, MacroKind::ThrowBall).unwrap(), MacroAbort::Done); + assert_eq!(world.cursor, 4, "the ball's own bag index, by reading the bag's cursor"); + assert_eq!(world.pulses.last(), Some(&buttons::A)); + // Nothing was pressed at the menu it came from while the bag was drawing: the presses are the + // one that chose ITEM and then the bag's own. + assert_eq!( + world.pulses.iter().filter(|mask| **mask == buttons::UP).count(), + 0, + "no blind press at the list it had already answered: {:?}", + world.pulses + ); + + // And a list that never opens is `blocked` rather than pressed at blind. + let mut never = World::battle(); + never.mons.truncate(1); + never.bag = vec![(item::POKE_BALL, 5)]; + assert_eq!(run(&mut never, MacroKind::ThrowBall).unwrap(), MacroAbort::Blocked); +} + /// Row 37 of `infra/docs/macros-traps.md`: a tile the cartridge pushes the fly off is not a tile /// to stand on, and the ground beside a villager is not the villager's fault. ///