//! Which macro each readout channel means in the current scene. //! //! `docs/design/macros.md` section 3 is the table this file implements, row for row. Slot binding //! is fixed per scene so that the meaning of a channel is stable inside a scene, and a slot whose //! precondition fails is *unbound* rather than bound-and-refusing: the stage shows it dim, the //! fly's channel does nothing, and no button is pressed. //! //! Slot order is the readout's, from section 1: UP is slot 0, DOWN 1, LEFT 2, RIGHT 3, A 4, //! B 5. use super::cartridge::{ CHEAPEST_PURCHASE, FACINGS, opposite, ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget, TargetKey, Tile, item, outdoors, }; use crate::adapter::PlaceKind; use super::geography::{self, Amenity}; use super::path::{self, Exit, Way}; use super::state::{BattleMenu, Facing, Mon, Move, Scene, ShopScreen, Status}; /// Slots in one palette: **one per macro type**, so a cell never moves. /// /// Six until section 14 (the operator, 2026-09-17: "make it 2 columns"). Six was the readout's D-pad and /// A/B, from section 1, and it survived section 12's "macros are buttons" as an arbitrary cap on /// how many buttons a scene could deal at once -- which is how `MENU` ended up on no pad at all /// (row 18 of `infra/docs/macros-traps.md`), how `GO FRONTIER` had to be rescued from truncation /// (row 12), and why the battle's own turn could not hold section 14's four move buttons beside /// `SWITCH`, `ITEM`, `THROW BALL`, `RUN` and `NEXT`. /// /// A slot is now a *type index* ([`MacroKind::slot`]): the screen draws every cell in a fixed /// order and lights the ones this scene binds, the wire carries the bound ones with their type /// index as `slot`, and nothing truncates anything. The decoder never cared -- it competes among /// the bound *channels* and a slot is only a name for one of them. pub const SLOTS: usize = MacroKind::BY_CHANNEL.len(); /// A slot within the current scene's palette (`docs/design/macros.md` section 4). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct MacroId(pub u8); /// What a macro does, independent of which slot or scene it is bound to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MacroKind { // Overworld GoObjective, GoOut, GoWarp, GoRoute, GoNpc, GoItem, GoFrontier, Talk, Menu, // Dialog Next, Yes, No, // Menu Close, Confirm, Back, // Battle. Section 14 replaces `ATTACK` with one button per move slot: the fly picks the move, // and no "best move" knowledge remains anywhere in this crate. Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run, // Errands (`docs/design/macros.md` section 13) GoShop, GoHeal, // Shop and PC BuyPotion, BuyBall, BuyAntidote, BuyRepel, // Pokémon Center Heal, Leave, } impl MacroKind { /// Every macro, for the tests and the docs. pub const ALL: [Self; 31] = [ Self::GoObjective, Self::GoOut, Self::GoWarp, Self::GoRoute, Self::GoNpc, Self::GoItem, Self::GoFrontier, Self::GoShop, Self::GoHeal, Self::Talk, Self::Menu, Self::Next, Self::Yes, Self::No, Self::Close, Self::Confirm, Self::Back, Self::Move1, Self::Move2, Self::Move3, Self::Move4, Self::Switch, Self::Item, Self::ThrowBall, Self::Run, Self::BuyPotion, Self::BuyBall, Self::BuyAntidote, Self::BuyRepel, Self::Heal, Self::Leave, ]; /// The twenty-two types in the order `docs/design/macros.md` section 11 lists them. /// /// Not the same order as [`MacroKind::ALL`], which is the order this enum happens to declare /// them in, and the difference matters three times over: it is the order /// `tools/build_flywire.py` cut the populations in (so it decides which neurons are whose), /// the decoder's macro-channel order (so it decides which of two equal scores wins a tie), and /// the screen's cell order. One list, quoted from the contract, rather than three that could /// drift. pub const BY_CHANNEL: [Self; 31] = [ Self::GoObjective, Self::GoOut, Self::GoWarp, Self::GoRoute, Self::GoItem, Self::GoNpc, Self::GoFrontier, // Section 13's two errands, beside the other walks: a cell's neighbours on the screen are // the macros it is most often dealt with. Self::GoShop, Self::GoHeal, Self::Talk, Self::Menu, Self::Next, Self::Yes, Self::No, Self::Close, Self::Confirm, Self::Back, // Section 14: one button per move slot, where `ATTACK` was. Self::Move1, Self::Move2, Self::Move3, Self::Move4, Self::Switch, Self::Item, Self::ThrowBall, Self::Run, Self::BuyPotion, Self::BuyBall, Self::BuyAntidote, Self::BuyRepel, Self::Heal, Self::Leave, ]; /// This type's slot, which is its position in [`MacroKind::BY_CHANNEL`]. /// /// Section 14: a slot is a type index, so a cell never moves and nothing is ever truncated /// off a pad. The screen draws every cell in this order and lights the bound ones. pub const fn slot(self) -> u8 { let mut index = 0; while index < Self::BY_CHANNEL.len() { if Self::BY_CHANNEL[index] as u8 == self as u8 { return index as u8; } index += 1; } // Unreachable: `the_channel_order_is_every_type` pins that the list is complete. 0 } /// This type's neuron population, which is also its decoder channel /// (`docs/design/macros.md` sections 11 and 12). /// /// `macro_` plus the macro's name lowercased with spaces as underscores, which is the rule /// `tools/build_flywire.py` writes the roles by and the rule the stage reads a bound macro's /// rate by; `the_channel_is_the_name_lowercased` pins the three ends together. The population /// is the button, so the role name and the channel name are deliberately the same string. pub const fn channel(self) -> &'static str { match self { Self::GoObjective => "macro_go_objective", Self::GoOut => "macro_go_out", Self::GoWarp => "macro_go_warp", Self::GoRoute => "macro_go_route", Self::GoNpc => "macro_go_npc", Self::GoItem => "macro_go_item", Self::GoFrontier => "macro_go_frontier", Self::Talk => "macro_talk", Self::Menu => "macro_menu", Self::Next => "macro_next", Self::Yes => "macro_yes", Self::No => "macro_no", Self::Close => "macro_close", Self::Confirm => "macro_confirm", Self::Back => "macro_back", Self::Move1 => "macro_move_1", Self::Move2 => "macro_move_2", Self::Move3 => "macro_move_3", Self::Move4 => "macro_move_4", Self::ThrowBall => "macro_throw_ball", Self::Switch => "macro_switch", Self::Item => "macro_item", Self::Run => "macro_run", Self::GoShop => "macro_go_shop", Self::GoHeal => "macro_go_heal", Self::BuyPotion => "macro_buy_potion", Self::BuyBall => "macro_buy_ball", Self::BuyAntidote => "macro_buy_antidote", Self::BuyRepel => "macro_buy_repel", Self::Heal => "macro_heal", Self::Leave => "macro_leave", } } /// The channel's tag, which is the glyph on the palette cell and the label on the SENSES /// tab's MACROS row (`docs/design/macros.md` section 12). /// /// Six characters at most, because the cell's glyph column is the width it was when it held a /// D-pad arrow. `MB` for the mushroom body, whose output neurons most of the population is: /// the tag says on air which part of the brain pressed the button. pub const fn channel_tag(self) -> &'static str { match self { Self::GoObjective => "MB·GOAL", Self::GoOut => "MB·OUT", Self::GoWarp => "MB·WARP", Self::GoRoute => "MB·ROUTE", Self::GoNpc => "MB·NPC", Self::GoItem => "MB·ITEM", Self::GoFrontier => "MB·FRONT", Self::Talk => "MB·TALK", Self::Menu => "MB·MENU", Self::Next => "MB·NEXT", Self::Yes => "MB·YES", Self::No => "MB·NO", Self::Close => "MB·CLOSE", Self::Confirm => "MB·CONF", Self::Back => "MB·BACK", Self::Move1 => "MB·MV1", Self::Move2 => "MB·MV2", Self::Move3 => "MB·MV3", Self::Move4 => "MB·MV4", // `MB·BALL` is the ball the fly *throws*; the mart's is `MB·PBALL`. Two channels // cannot share a tag on a screen that identifies a cell by it. Self::ThrowBall => "MB·BALL", Self::Switch => "MB·SWAP", Self::Item => "MB·BAG", Self::Run => "MB·RUN", Self::GoShop => "MB·SHOP", Self::GoHeal => "MB·HEAL", Self::BuyPotion => "MB·POTN", Self::BuyBall => "MB·PBALL", Self::BuyAntidote => "MB·ANTI", Self::BuyRepel => "MB·REPEL", // Not `MB·HEAL`, which is the walk to the door: this is the conversation at the // counter, and two channels cannot share a tag on a screen that identifies a cell by // it. Self::Heal => "MB·NURSE", Self::Leave => "MB·LEAVE", } } /// The name on screen. At most fourteen characters, as section 3 requires. pub const fn name(self) -> &'static str { match self { Self::GoObjective => "GO OBJECTIVE", Self::GoOut => "GO OUT", Self::GoWarp => "GO WARP", Self::GoRoute => "GO ROUTE", Self::GoNpc => "GO NPC", Self::GoItem => "GO ITEM", Self::GoFrontier => "GO FRONTIER", Self::Talk => "TALK", Self::Menu => "MENU", Self::Next => "NEXT", Self::Yes => "YES", Self::No => "NO", Self::Close => "CLOSE", Self::Confirm => "CONFIRM", Self::Back => "BACK", Self::Move1 => "MOVE 1", Self::Move2 => "MOVE 2", Self::Move3 => "MOVE 3", Self::Move4 => "MOVE 4", Self::ThrowBall => "THROW BALL", Self::Switch => "SWITCH", Self::Item => "ITEM", Self::Run => "RUN", Self::GoShop => "GO SHOP", Self::GoHeal => "GO HEAL", Self::BuyPotion => "BUY POTION", Self::BuyBall => "BUY BALL", Self::BuyAntidote => "BUY ANTIDOTE", Self::BuyRepel => "BUY REPEL", Self::Heal => "HEAL", Self::Leave => "LEAVE", } } /// The item id and price this macro buys, for a `BUY …` and `None` for everything else. /// /// Indices into [`PURCHASES`], so the four buttons, their four preconditions and /// [`super::cartridge::CHEAPEST_PURCHASE`] -- which is what the mart errand's money test /// measures against -- are one list rather than three that could drift. pub const fn purchase(self) -> Option<(u8, u32)> { match self { Self::BuyPotion => Some(PURCHASES[0]), Self::BuyBall => Some(PURCHASES[1]), Self::BuyAntidote => Some(PURCHASES[2]), Self::BuyRepel => Some(PURCHASES[3]), _ => None, } } /// Two or three words of what this macro *does*, for the palette cell under the screen. /// /// `docs/design/macros.md` section 6: "each with the macro's name and a two or three word /// gloss of what it does in this scene (`GO EXIT · nearest door`)". One gloss per macro rather /// than one per scene-and-slot, because a macro means the same thing wherever it is bound — /// `NEXT` advances text in a dialog and in an `Unknown` frame alike — and a second table /// keyed by scene could only drift from the first. /// /// Terse copy, as the screen rules require, and it goes over the feed rather than living on /// the page: `game.palette[].gloss` (`docs/feed-protocol.md`). pub const fn gloss(self) -> &'static str { match self { // Section 9.1 splits the old `GO EXIT` three ways, and the gloss is where the // distinction has to land for a viewer: out of the building, between its floors, or // on to the next area. The operator's own words for the three. Self::GoObjective => "next rung", Self::GoOut => "leave building", Self::GoWarp => "stairs or door", Self::GoRoute => "next area", Self::GoNpc => "nearest person", Self::GoItem => "nearest object", Self::GoFrontier => "new ground", // One press of A at whatever is ahead, which is a person, a ball, a sign or nothing. // `LOOK` used to sit in slot 2 with its own name for the second half of that, and it // was the same press at the same tile: section 3, 2026-09-16. Self::Talk => "talk / look", Self::Menu => "open start menu", Self::Next => "advance text", Self::Yes => "answer yes", Self::No => "answer no", Self::Close => "back to overworld", Self::Confirm => "take the choice", Self::Back => "one step back", Self::Move1 => "first move", Self::Move2 => "second move", Self::Move3 => "third move", Self::Move4 => "fourth move", Self::ThrowBall => "throw a ball", Self::Switch => "healthiest reserve", Self::Item => "use a potion", Self::Run => "flee the battle", Self::GoShop => "to the mart", Self::GoHeal => "to the centre", Self::BuyPotion => "buy one potion", Self::BuyBall => "buy one ball", Self::BuyAntidote => "buy one antidote", Self::BuyRepel => "buy one repel", Self::Heal => "rest the party", Self::Leave => "close the menu", } } } /// One bound slot. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MacroSpec { pub name: &'static str, pub kind: MacroKind, } impl MacroSpec { pub const fn of(kind: MacroKind) -> Self { Self { name: kind.name(), kind } } } /// A tile a walking macro aims at, and how the session's ledgers name what is there. /// /// `tile` is somewhere to stand, `press` the one press that arriving asks for (a doormat's step /// off, `None` for a warp that fires by itself), and `key` how [`MacroState::blocked`] and /// [`MacroState::reached`] name the target -- `None` for a tile that is not a target in its own /// right. The key travels with the goal rather than being re-derived at the end because by the /// time a walk aborts the fly may be standing somewhere else entirely, and "which thing did this /// macro fail to reach" is only answerable where the thing was chosen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Aim { pub tile: Tile, pub press: Option, /// The direction to turn once standing on `tile`, for an aim that is "beside it, facing it". /// /// A rung earned by a conversation is a person to stand next to and look at, which is exactly /// `GO NPC`'s arrival; a rung earned by standing somewhere is a tile or a door, which is /// `press`. Both in one type because `GO OBJECTIVE` is one macro over all of them. pub face: Option, pub key: Option, } /// The six slots of one scene. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Palette { pub scene: Scene, pub slots: [Option; SLOTS], } impl Palette { /// The palette for `scene`, with every precondition in section 3 evaluated against `state`. /// /// The contract writes this as taking a `&dyn GameState`; agent A's seam reads WRAM behind /// `&mut self` accessors — reads are memoized per frame behind it — so it is `&mut dyn` here. /// Nothing else about the signature moves, and nothing here presses a button. /// /// The three preconditions the table states in words are: /// /// - `ITEM` — "potion if own HP < 50% and one is held, else no action"; /// - `RUN` — "(wild only; trainer: no action)"; /// - `BUY POTION` / `BUY BALL` — "if money allows". /// /// The rest follow from the same principle, because a macro with nothing to act on is not an /// action: `GO EXIT` needs an exit, `GO NPC` needs a visible person, `GO ITEM` needs an /// object or a sign, `SWITCH` needs another Pokémon that can fight, and `ATTACK` needs a move /// with PP left. /// /// `GO EXIT`'s binding test is deliberately the cheap one — does this map have an exit at all /// — because `for_scene` runs every frame and a route search does not. /// [`super::executor::MacroMachine::start`] settles whether a route exists, and refuses the /// slot rather than pressing anything. pub fn for_scene(scene: Scene, state: &mut dyn MacroState) -> Self { let mut slots: [Option; SLOTS] = [None; SLOTS]; for kind in scene_set(scene, state) { if precondition(kind, state) { slots[usize::from(kind.slot())] = Some(MacroSpec::of(kind)); } } Self { scene, slots } } /// The spec in `slot`, or `None` when the slot is unbound or out of range. pub fn slot(&self, slot: MacroId) -> Option { self.slots.get(usize::from(slot.0)).copied().flatten() } /// How many slots this palette binds, for the log line and the tests. pub fn bound(&self) -> usize { self.slots.iter().flatten().count() } } /// Which macro types this scene deals *at all*, before any precondition is asked. /// /// The one table, and `docs/design/macros.md` sections 12, 12.6, 13, 13.1 and 14 are what it says. /// Section 12 gave the fixed six-channel table of section 3 and the plan's ordering as two /// dealers, which could disagree about which buttons a scene had -- and did, for two releases: /// `GO OBJECTIVE` was on the plan's overworld and on no fixed row. Section 14 removes the last /// reason they differed, because with a slot per type there is nothing to order and nothing to /// truncate. [`super::plan::plan_for`] is this function. /// /// A set, not a ranking. Nothing here decides which of them the fly presses. pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec { use MacroKind::*; match scene { // Section 2: "no palette: boot variant of the readout applies". Scene::Title => Vec::new(), // Section 2 treats `Unknown` like a dialog -- advance only -- and `BACK` is the other half // of advancing, because `Unknown` is also where the Pokédex, the trainer card and OPTION // land (`docs/design/macros-wram.md`) and B is what leaves all three. Scene::Unknown => vec![Next, Back], // `NEXT`, `YES`, `NO` for a plain box -- A and B both advance one, and what the three // buy is the fly being *able* to answer no. On the one box that **is** a choice, the pad // is the choice's own answers: section 12.12. Scene::Dialog => dialog_set(state), Scene::Menu => vec![Close, Confirm, Back], // Section 9.1's split, plus section 13's errands and centre. Indoors the ways out of a // room are the building's door and its passages; outdoors there is no building to leave. // // **`MENU` is on no pad** (section 12.11). It was here as the unconditional button that // made an empty overworld pad impossible, and that is exactly what made it a trap: opening // the start menu changes nothing in the world, so the macro completes where the fly stands // -- section 12.2's rule -- and the scene it opens deals `CLOSE` and `BACK`, which close it // again. Live on rung 10, thirty minutes inside the Pewter museum's upper floor: `MENU` 82 // starts, `BACK` 82, `GO FRONTIER` 8, the log alternating `MENU start/done, BACK // start/done`. Nothing in the macro vocabulary uses the start menu for anything -- there is // no SAVE macro and no POKéDEX macro -- so there is nothing behind the button worth // pressing it for. What keeps this pad from being empty instead is the way out, which // [`ways`] offers regardless of the ledgers when nothing else on the map is worth walking // to. Scene::Overworld => { let mut set = vec![GoObjective]; if outdoors_now(state) { set.push(GoRoute); } else { set.push(GoOut); set.push(GoWarp); } if inside_center(state) { set.push(Heal); } set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk]); set } // Section 12: "Forced switch: SWITCH" -- plus the press that advances a battle, because // `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 // 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) { // The move list. `BACK` is a button here because there is a list to leave (12.9) -- // but only while the moves can be *read*: a battler the seam cannot place leaves all // four `MOVE n` unbound, and a pad of `BACK` alone closes the list that `MOVE 1` on // the menu underneath had just opened. That is 12.10's pair again, with `MOVE 1` in // `NEXT`'s place. With nothing readable the pad is `MOVE 1` alone and its script // confirms wherever the cursor stands, which is the press that ends the turn // (section 12.11). BattleMenu::Moves { .. } => { if state.battle().and_then(|battle| battle.own).is_some() { vec![Move1, Move2, Move3, Move4, Back] } else { vec![Move1] } } BattleMenu::Party { .. } => vec![Switch, 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] } }, // 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a // press exactly like a dialog. // // **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 // answer rather than only close. Nothing in the macro vocabulary deposits or withdraws // (row 17, contract). Scene::Pc => vec![Confirm, Leave], } } /// Section 3's preconditions, one arm each. pub fn precondition(kind: MacroKind, state: &mut dyn MacroState) -> bool { match kind { // A place for the next rung, and a way to aim at it from here; the route search is // `start`'s job as it is for every walk. MacroKind::GoObjective => !objective_goals(state).is_empty(), // Any way out of this kind at all; the route search is `start`'s job. MacroKind::GoOut => !ways(state, Way::Exit).is_empty(), MacroKind::GoWarp => !ways(state, Way::Passage).is_empty(), MacroKind::GoRoute => !ways(state, Way::Route).is_empty(), // A tile that borders ground this run has never stood on. Unlike the three above this is // the *whole* question rather than a cheap proxy for it, because the frontier search is // the same walk of the map the walkable predicate would do anyway. MacroKind::GoFrontier => !frontier_aims(state).is_empty(), // A *person*, not any sprite. The sixteen sprite slots hold the map's whole // `object_event` list and an item ball is one of those, so "is there a sprite" would have // bound `GO NPC` on a map with nothing but furniture on it — and walked the fly to a ball // under a macro whose gloss says "nearest person". The two macros partition the list. MacroKind::GoNpc => !untalked_people(state).is_empty(), // The other half of it: the objects, plus the map's sign tiles. This is the slot that // reaches the starter Pokéballs on Oak's table, which are `object_event`s with // `SPRITE_POKE_BALL` and are not people (section 3, 2026-09-16). MacroKind::GoItem => !untalked_objects(state).is_empty(), // A press of A at the tile ahead, and only where the map says there is something there // that this run has not already talked to (`docs/design/macros.md` section 12: "TALK // (only when facing something untalked)"). A tile ahead with nothing on it is not a // reason to press A, and a shelf that has been read is not a reason to read it again. // ...and only where there is something to say. The nurse of a Pokemon Center is an // object with a purpose, not a person to chat with: with the party already full her whole // conversation is forty-six text frames that end where they began, which is section // 12.2's trap at conversation scale (section 12.12). MacroKind::Talk => facing_untalked(state) && !rested_nurse(state), // The start menu opens from anywhere, and that is exactly why `MENU` is on no pad: // "the precondition is satisfied wherever the fly stands" is section 12.2's trap, and a // macro whose whole effect is a screen its own scene's `BACK` closes again is 12.10's // pair one scene wider (section 12.11). The refusal belongs at the **dealer** and not // here, because this arm is a true fact about the macro and [`scene_set`] is where the // reason lives: nothing in the vocabulary uses the start menu, so there is nothing behind // the button to press it for -- and the day a SAVE macro exists, one row changes. // `MENU` stays a type, a population, a tag and a script, so the roles, the channel order // and `--print-compatibility` are untouched. MacroKind::Menu => true, // Advancing text, answering a prompt and backing out never need anything. MacroKind::Next | MacroKind::Yes | MacroKind::No | MacroKind::Close | MacroKind::Confirm | MacroKind::Back | MacroKind::Leave => true, // Section 14: one button per move slot, bound only when that slot holds a move with PP -- // and `MOVE 1` also when *nothing* does, because Struggle is what the cartridge does then // and the only way to it is to choose a move anyway (row 30a, row 34). The fly picks the // move; nothing here scores one. MacroKind::Move1 | MacroKind::Move2 | MacroKind::Move3 | MacroKind::Move4 => { move_slot_bound(state, kind) } // Section 14's addition (the operator: "throw pokeball should be a macro"). A wild battle, a ball // in the bag, and room in the party. No catch-rate and no HP knowledge: when to throw is // the fly's. MacroKind::ThrowBall => throw_slot(state).is_some(), MacroKind::Switch => healthiest_other(state).is_some(), MacroKind::Item => hurt(state) && potion_slot(state).is_some(), // A wild battle the fly is *losing* (`docs/design/macros.md` section 13.1, the operator: "we run // away a lot"). Knowledge inside the macro, as a precondition: nothing ranks `RUN` below // `ATTACK` -- the button simply is not there while the fight is still worth having. MacroKind::Run => { state.battle().is_some_and(|battle| battle.kind == super::state::BattleKind::Wild) && losing(state) } MacroKind::BuyPotion | MacroKind::BuyBall | MacroKind::BuyAntidote | MacroKind::BuyRepel => match kind.purchase() { Some((id, cost)) => affordable(state, id, cost), // Unreachable while [`MacroKind::purchase`] covers the four arms above, and a `false` // rather than a panic if it ever stops: an unpriced purchase is a button off the pad. None => false, }, // Section 13's two errands. The goal list *is* the whole question here, unlike the three // ways out: `amenity_goals` is either a person on this map or this map's exits toward one, // so a precondition that held with nothing to walk to could only ever refuse. MacroKind::GoShop => !amenity_goals(state, Amenity::Mart).is_empty(), MacroKind::GoHeal => !amenity_goals(state, Amenity::Center).is_empty(), // On the pad only inside a Pokémon Center, and only while the party needs it // (section 13: "at least one party member is not at full HP or has a status"). MacroKind::Heal => party_needs_rest(state) && !heal_goals(state).is_empty(), } } /// The dialog pad: the answers to a box that is a choice, the three presses for one that is not. /// /// **Section 12.12, rung 10, the Pewter Pokemon Center.** Since the 09:39 restart the macro starts /// were `YES` **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, the log ending `YES start/done` /// for ever, on one tile of map `0x3a`. Surveyed on the cartridge from the live checkpoint: the /// nurse's conversation is a **ring of forty-six A presses** -- welcome, "We heal your POKeMON /// back to perfect health!", the YES/NO box on **one** frame of the forty-six, "OK. We'll need /// your POKeMON.", the machine, "Your POKeMON are fighting fit!", "We hope to see you again!", /// the box closes for a single frame, and the next A press at a nurse two tiles away over the /// counter opens the whole thing again. The party was **70/70 and healthy** throughout, so every /// press of it changed nothing. /// /// Two readings the survey settles, because the brief allowed three: /// /// - the box open at the checkpoint is **not** the prompt, it is the closing line, and `YES` there /// is an A press on plain text. Forty-five of the forty-six frames are like that, and on every /// one of them `NEXT` and `YES` are the identical press with two names; /// - `HEAL` is **not** in the loop at all: its precondition already reads the live party and /// `party_needs_rest` answers `false`, so the button was off the pad the whole time. What was on /// the pad was the *dialog*, unconditionally, and `TALK` to get back into it. /// /// So: on a box that is a readable choice the pad is that choice's answers and `NEXT` is off it, /// which is 12.10's rule about two buttons that are one press; at the nurse's own prompt the /// answer that changes something is the only one bound, which is 12.2's rule about a macro whose /// precondition is already satisfied; and an answer that brings the same prompt straight back is /// excluded for the blocked window, which is 12.1's ledger doing what it does for a walk. fn dialog_set(state: &mut dyn MacroState) -> Vec { use MacroKind::*; if !state.yes_no_prompt() { return vec![Next, Yes, No]; } // A choice is open. An A press here confirms whichever option the cursor is on, which is what // `YES` is, so `NEXT` is off this pad for exactly 12.10's reason: two buttons that are one // press cannot both be answers to the box. let answers = if nurse_prompt(state) { // The nurse's box is an offer about the party, and the party is a byte. Hurt or statused, // the answer worth making is `YES`; full and healthy, the offer is for nothing and the // only answer that changes anything is `NO`. Knowledge inside the macro as a // precondition, section 13's rule for `HEAL` applied to the box `HEAL` opens. if party_needs_rest(state) { vec![Yes] } else { vec![No] } } else { vec![Yes, No] }; let kept: Vec = answers.iter().copied().filter(|kind| !answer_excluded(state, *kind)).collect(); // A box must stay answerable: the exclusion narrows a pad, it never empties one. With both // answers excluded the fly is offered both again, because a dialog with nothing on its pad is // a screen nothing can leave. if kept.is_empty() { answers } else { kept } } /// Whether this answer to the box at this tile is inside its reopened-prompt exclusion window. fn answer_excluded(state: &mut dyn MacroState, kind: MacroKind) -> bool { let yes = match kind { MacroKind::Yes => true, MacroKind::No => false, _ => return false, }; match answer_key(state, yes) { Some(key) => state.blocked(key), None => false, } } /// The blocked-ledger key for answering the box at the tile the fly is standing on. pub fn answer_key(state: &mut dyn MacroState, yes: bool) -> Option { let player = state.player()?; Some(TargetKey::Answer { at: Tile::new(player.x, player.y), yes }) } /// Whether the box on screen is the Pokemon Center nurse's own YES/NO prompt. /// /// Three readings, each a byte: the two-option box is drawn ([`MacroState::yes_no_prompt`]), the /// map is a centre ([`inside_center`]), and the thing the fly is facing is the nurse. The map id /// is in there because Red draws a two-option box for a dozen scripts and only this one is an /// offer about the party. pub fn nurse_prompt(state: &mut dyn MacroState) -> bool { state.yes_no_prompt() && inside_center(state) && facing_nurse(state) } /// Whether the thing the fly is facing -- over a counter, which is how a nurse is ever faced -- is /// a Pokemon Center's nurse. pub fn facing_nurse(state: &mut dyn MacroState) -> bool { let Some(TalkTarget::Sprite(slot)) = facing_target(state) else { return false }; state.npcs().iter().any(|npc| npc.slot == slot && npc.picture == poke_sprite::NURSE) } /// Whether the fly is facing a nurse with nothing to ask her for: what takes `TALK` off the pad. /// /// The nurse is the one person in Red whose conversation has a *precondition*, because her /// conversation is a service and the cartridge publishes whether the service is needed. `HEAL` has /// read that byte since section 13; this is the same byte read for the press that opens the same /// box. Section 12.2's rule, at conversation scale: a `TALK` whose whole effect is a ring of text /// that ends where it began is a trap, so it is not on the pad. pub fn rested_nurse(state: &mut dyn MacroState) -> bool { inside_center(state) && !party_needs_rest(state) && facing_nurse(state) } /// Whether at least one party member is below full HP or carries a status: `HEAL`'s precondition. /// /// The party rather than the battler, because this is asked in the overworld where the battle /// engine's copy is stale. A member with species 0 is skipped: `wPartyCount` leads the structs it /// counts (`docs/design/macros-wram.md`), so a fresh gift can read as a Pokémon with no HP at all /// and resting for it would be resting for nothing. pub fn party_needs_rest(state: &mut dyn MacroState) -> bool { state.party().mons.iter().any(|mon| { mon.species != 0 && mon.max_hp > 0 && (mon.hp < mon.max_hp || mon.status != Status::Healthy) }) } /// Whether the party is at full HP with no statuses: what the end of a `HEAL` looks like. /// /// An empty party answers `false`, so a `HEAL` can never read "already done" off a party that is /// not there yet. pub fn party_rested(state: &mut dyn MacroState) -> bool { let party = state.party(); !party.mons.is_empty() && party .mons .iter() .all(|mon| mon.species == 0 || (mon.hp == mon.max_hp && mon.status == Status::Healthy)) } /// Whether a wild battle is one the fly is losing: `RUN`'s precondition (section 13.1). /// /// Two readings, both of them "there is nothing left to do here but leave", and both taken from /// the cartridge's own numbers rather than from a judgement about the fight: /// /// - **the Pokémon that is out is under a third of its HP and no healthier one can come in.** /// "No healthier one" is `Party::healthiest_reserve` compared on HP fraction, which is the same /// measure `SWITCH` chooses by, so the two buttons cannot disagree about whether there is a /// reserve worth sending in. /// - **every move it has is out of PP and there is nothing to switch to.** Struggle is what the /// cartridge does then and it costs recoil; with a reserve, `SWITCH` is the move, and without /// one the battle is a countdown. /// /// Anything else and `RUN` is *off the pad*: a Pokémon with health and PP is in a fight worth /// having, and a wild win is a reward the ladder pays for. That is the whole of "we run away a /// lot" -- `RUN` was bound for every wild battle, so the fly could flee one it was winning, and a /// fled battle pays nothing and teaches nothing. pub fn losing(state: &mut dyn MacroState) -> bool { let Some(battle) = state.battle() else { return false }; let Some(own) = battle.own.or_else(|| state.party().active_mon().copied()) else { return false; }; if own.max_hp == 0 { return false; } let party = state.party(); let healthier = party .healthiest_reserve() .is_some_and(|reserve| reserve.hp_fraction() > own.hp_fraction()); if u32::from(own.hp) * 3 < u32::from(own.max_hp) && !healthier { return true; } let has_moves = own.moves.iter().flatten().any(|entry| entry.id != 0); let no_pp = has_moves && own.moves.iter().flatten().all(|entry| entry.pp == 0); no_pp && party.healthiest_reserve().is_none() } /// The area the fly is standing in, which is the town an errand is counted once per. pub fn area_here(state: &mut dyn MacroState) -> Option { state.player().and_then(|player| geography::area_of(player.map)) } /// The map of this area's outstanding errand of `kind`, or `None` when there is none. /// /// `docs/design/macros.md` section 13, and the three ways it can be `None`: /// /// - the area has no building of this kind, or no row in [`geography`]'s table -- a route, Pallet /// Town, a town the table has not grown a row for yet. Nothing is guessed; /// - this run has already been inside it ([`MacroState::area_visited`]), which is what makes the /// errand paid once and never a loop; /// - for a mart, the money on hand is below [`CHEAPEST_PURCHASE`], so there would be nothing to /// do when the fly got there. pub fn errand(state: &mut dyn MacroState, kind: Amenity) -> Option { let area = area_here(state)?; if state.area_visited(kind, area) { return None; } if kind == Amenity::Mart && state.money() < CHEAPEST_PURCHASE { return None; } geography::amenity_of(area, kind) } /// The errand `GO OBJECTIVE` puts *ahead* of the rung's place, when this area has one. /// /// Section 13: "on a map whose area has a mart or a Pokémon Center this run has not yet entered, /// the objective target is that building's door first, then the rung's place." /// /// Which of the two comes first when both are outstanding is the one piece of ordering in here, /// and it is knowledge inside the macro rather than a ranking of buttons: **the centre first when /// the party is hurt or statused, the mart first otherwise.** A hurt party is what ends runs, and /// a full-price Potion is the expensive way to fix what the nurse fixes for nothing. pub fn errand_place(state: &mut dyn MacroState) -> Option { let order = if party_needs_rest(state) { [Amenity::Center, Amenity::Mart] } else { [Amenity::Mart, Amenity::Center] }; let map = order.into_iter().find_map(|kind| errand(state, kind))?; Some(Objective { map, tile: None, warp: None, edge: None, target: None }) } /// Where `GO OBJECTIVE` is going: the errand if this area has one, else the ladder's next rung. /// /// One accessor rather than `state.objective()` in five places, so the errand cannot be ahead of /// the rung for the walk and behind it for the ranking of exits. pub fn objective_place(state: &mut dyn MacroState) -> Option { errand_place(state).or_else(|| state.objective()) } /// Whether the open mart stocks `item` and the money on hand covers it. /// /// Both halves, and the stock half is the one that surprises: Viridian's counter sells POKE BALL, /// ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no Potion at all** (`data/items/marts.asm` at the /// pinned commit), so `BUY POTION` is correctly off the pad in the first mart the fly ever walks /// into. That is the precondition working, not a gap. fn affordable(state: &mut dyn MacroState, item: u8, cost: u32) -> bool { // Sell cursors index the bag, not the counter's stock state .shop() .is_some_and(|shop| matches!(shop.screen, ShopScreen::BuySellQuit | ShopScreen::Buying)) && state.money() >= cost && state.shop_stock().contains(&item) } /// What `GO SHOP` or `GO HEAL` walks to, or empty when there is nothing to walk to. /// /// Two cases, which is section 13's "walk to its door and in; inside, walk to the counter / nurse /// and face them": /// /// - **standing in the building already**: the goals are the tiles to face its counter person /// from ([`counter_aims`]). The errand ledger is written on arrival, so this case is *not* /// gated on the ledger -- the errand is discharged and the button's remaining job is the walk /// to the counter, which is what puts the fly where `BUY …` and `HEAL` can be pressed. /// - **anywhere else in the area, with the errand outstanding**: the goals are this map's exits /// toward the building, over the same breadth-first hop `GO OBJECTIVE` crosses maps with. pub fn amenity_goals(state: &mut dyn MacroState, kind: Amenity) -> Vec { let Some(here) = state.player().map(|player| player.map) else { return Vec::new() }; if geography::amenity_at(here) == Some(kind) { return counter_aims(state, counter_sprite(kind)); } match errand(state, kind) { Some(map) => goals_toward(state, map), None => Vec::new(), } } /// `HEAL`'s own walk: the tiles the nurse of this Pokémon Center can be talked to from. /// /// Empty anywhere but inside a centre, which is half of `HEAL`'s precondition. pub fn heal_goals(state: &mut dyn MacroState) -> Vec { let Some(here) = state.player().map(|player| player.map) else { return Vec::new() }; if geography::amenity_at(here) != Some(Amenity::Center) { return Vec::new(); } counter_aims(state, poke_sprite::NURSE) } /// The picture id of the person who stands behind `kind`'s counter. const fn counter_sprite(kind: Amenity) -> u8 { match kind { Amenity::Mart => poke_sprite::CLERK, Amenity::Center => poke_sprite::NURSE, } } /// `constants/sprite_constants.asm` picture ids, which is what the sprite table's byte 0 holds. /// /// The same numbering `FIRST_STILL_SPRITE` is compared against ([`super::state::Npc::person`]), so /// "which of these people is the clerk" is a byte the cartridge already publishes rather than a /// guess from where somebody is standing. pub mod poke_sprite { /// `SPRITE_CLERK`, `$26`. pub const CLERK: u8 = 0x26; /// `SPRITE_NURSE`, `$29`. pub const NURSE: u8 = 0x29; } /// Somewhere to stand and face a person with picture id `picture`, counters included. /// /// The ordinary four tiles around them, **plus the tile two away in each direction when the tile /// between is a counter** ([`MacroState::counter_tile`]). The second half is not an optimisation: /// a mart clerk and a Pokémon Center nurse stand behind a desk, so all four tiles around either /// of them are walls or floor behind the desk, and a walk that only knew how to stand beside /// somebody could never reach one at all -- `GO SHOP` would refuse `no route` once per hold for /// ever. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch is what makes the /// longer reach real, and it reaches exactly two tiles. /// /// Nearest first by Manhattan distance, ties by tile, which is `approach`'s own rule. Whether any /// of the tiles is *reachable* is the route search's answer, not this one's. pub fn counter_aims(state: &mut dyn MacroState, picture: u8) -> Vec { let Some(player) = state.player() else { return Vec::new() }; let here = Tile::new(player.x, player.y); let mut people: Vec<(u32, Tile, u8)> = state .npcs() .iter() .filter(|npc| npc.picture == picture) .map(|npc| (Tile::new(npc.x, npc.y).distance(here), Tile::new(npc.x, npc.y), npc.slot)) .collect(); people.sort_unstable(); let Some((_, tile, slot)) = people.first().copied() else { return Vec::new() }; let in_amenity = geography::amenity_at(player.map).is_some(); let target = TalkTarget::Sprite(slot); // The same two exclusions `untalked` applies, and for the same reason: a counter the fly has // already been stood in front of, or already talked to, is a job done. Without them // `amenity_goals` never empties, `GO SHOP` never leaves the pad inside the building, and the // suppression in [`ways`] would keep the fly in there for ever. if state.talked(target) || state.reached(TargetKey::Thing(target)) || state.blocked(TargetKey::Thing(target)) { return Vec::new(); } let key = Some(TargetKey::Thing(target)); let mut aims = Vec::with_capacity(8); for facing in FACINGS { let Some(beside) = tile.step(facing) else { continue }; // **Only a tile the collision table does not call a wall.** Every other approach in this // file leaves reachability to the route search, and for an ordinary villager that is // right; for somebody behind a desk it is not. The floor behind a mart's counter reads // walkable and is walled off, so the multi-goal search happily routes *toward* it, spends // three failed steps at (0, 7) and reports `Blocked` — which excludes the clerk for ten // brain minutes and releases the suppression that was keeping the fly in the shop. // Measured from the release container's checkpoint, 2026-09-17: the fly walked the mart's // bottom row to the corner and then out of the building. `Unknown` is kept, because a // tile off the walkable window is not a wall. if state.walkable(beside.x, beside.y) != super::state::Walkable::No && !state.pushed_tile(beside.x, beside.y) { aims.push(Aim { tile: beside, press: None, face: Some(opposite(facing)), key }); } // And over the counter, when that is what the tile beside them is -- or when the fly is // inside a mart or a centre at all, for the reason [`facing_target`] gives: the tile read // is unreliable on a map smaller than the screen and the map id is not. if !in_amenity && !state.counter_tile(beside.x, beside.y) { continue; } let Some(over) = beside.step(facing) else { continue }; if state.walkable(over.x, over.y) == super::state::Walkable::No || state.pushed_tile(over.x, over.y) { continue; } aims.push(Aim { tile: over, press: None, face: Some(opposite(facing)), key }); } // The tile the fly is already standing on facing the right way is not somewhere to walk to: // that is `TALK`'s state, and the same exclusion `untalked` makes (row 4). let ahead = Tile::new(player.x, player.y).step(player.facing); aims.retain(|aim| aim.tile != here || Some(tile) != ahead); aims } /// Whether the fly is standing on an outdoor map, which is what picks the overworld row. pub fn outdoors_now(state: &mut dyn MacroState) -> bool { state.player().is_some_and(|player| outdoors(player.map)) } /// Whether the fly is standing inside a Pokémon Center, which is what deals `HEAL`. /// /// Section 13's "centre scene" is a sub-state of the overworld rather than a [`Scene`] of its own, /// and that is deliberate: pokered has no "a Pokémon Center is open" byte -- the nurse's own /// dialogue is an ordinary text box -- so the only honest observable is the map id, which /// [`geography`]'s table already names. A new `Scene` would also be a new `game.scene` on the /// wire, and section 13 changes no schema. pub fn inside_center(state: &mut dyn MacroState) -> bool { state .player() .is_some_and(|player| geography::amenity_at(player.map) == Some(Amenity::Center)) } /// Whether the fly is standing in a mart or a centre whose counter it has not faced yet. /// /// The one thing that keeps a building shut (2026-09-17, measured from the release container's own /// checkpoint): the errand is paid on *entering* and never offered again, so a macro that walks /// the fly straight back out spends the one visit this area gets and nothing can ever bring it /// back. It reached the Viridian mart in 1.7 brain minutes and could not buy in it — the fly /// arrives on the building's doormat, `exit_goals` keeps a doormat underfoot because "its press is /// the point" (row 13 of `infra/docs/macros-traps.md`), and *any* walk that presses DOWN there /// warps out. The measured culprit was `GO OBJECTIVE`, not `GO OUT`: the rung's place is on /// another map, so the objective's own goal was the doormat the fly was standing on. /// /// So while this is true, neither the ways out ([`ways`]) nor the objective ([`objective_goals`]) /// offer anything, and the pad's walks are `GO SHOP` / `GO HEAL` to the counter. It is the same /// rule row 29 gave the rung's own target — "the room the rung is in is not left while the thing /// that earns it is standing in it" — and it has the same escape hatches: facing the counter /// retires it, a `TALK` retires it, and a walk that cannot reach it is excluded for the window. pub fn counter_pending(state: &mut dyn MacroState) -> bool { let Some(here) = state.player().map(|player| player.map) else { return false }; let Some(kind) = geography::amenity_at(here) else { return false }; !counter_aims(state, counter_sprite(kind)).is_empty() } /// Whether the fly is standing inside a mart, which is where `BUY …` can be reached at all. pub fn inside_mart(state: &mut dyn MacroState) -> bool { state .player() .is_some_and(|player| geography::amenity_at(player.map) == Some(Amenity::Mart)) } /// The ways out of the current map of one kind, the ones that lead somewhere new preferred. /// /// **"Unvisited" is a question about the map on the other side.** Section 9.2, after the town /// loop: a connection or a door counts as visited only once the run has stood on the map it leads /// to ([`MacroState::map_visited`]), never because the fly once stood on the boundary tile. The /// old test was the adapter's `boundary` ledger, which pays for standing on *or beside* an exit, /// so walking along the top row of Pallet Town marked the way to Route 1 "visited" while the /// door of an already-explored house still read unvisited -- and with every route exhausted the /// fallback to *all* of them let "nearest" pick a front door, once per hold, for ever /// (`infra/docs/macros-bench.md`, 2026-09-16). [`MacroState::exit_visited`] is still the adapter's /// answer about the boundary and is still what [`visited_exits`] reports for the log line; it is /// no longer what "somewhere new" means. /// /// Three tiers, in order: /// /// 1. **exits into a map this run has not stood on**, and among those, a *connection* before a /// door: the next area is a bigger unknown than a room off this one, which is the whole of /// what "rank connections to unvisited maps above doors to unvisited interiors" buys. /// 2. **exhausted** -- everything of this kind leads somewhere the run has been -- falls back to /// the ones that lead *toward the objective* ([`toward_objective`]), because a map the fly has /// already seen is still the way to the next rung. /// 3. **failing that, all of them**, and the route search picks the nearest -- but only where a map /// would otherwise be impossible to leave. A room whose doors are all in the ledger still has to /// be left, so `GO OUT` keeps its last-resort fallback, and so does `GO WARP` on a map with no /// way out but a passage (Red's bedroom, the upper floor of a house). `GO ROUTE` does not, and /// neither does `GO WARP` on a floor that has its own front door: outdoors there is no room to /// be stuck in, a staircase already been up is exploration already done, and "the nearest door, /// once per hold, for ever" is the loop itself -- measured twice, once as `GO ROUTE` into a house /// and once as `GO WARP` up its stairs (`infra/docs/macros-traps.md`). Viridian City, 2026-09-17: with every route visited and the one connection /// toward the objective excluded, tier 3 handed `nearest` the house door the fly was standing /// at, and the fly walked in, walked out and walked in again for two hours seventeen /// (`infra/docs/macros-traps.md`). Section 9.2 fixed *which* exits count as visited and left /// the fallback standing; this is the other half. With nothing fresh and nothing on the way, /// `GO ROUTE` leaves the pad, which is section 12's "a precondition failure means the button is /// not on the pad". /// /// An exit whose destination nobody can name counts as unvisited, which keeps it a candidate -- /// **except a way out of a building**. The only way onto an interior map is through its own front /// door, so the map outside it is one this run has stood on whether or not the geography table has /// a row to name it; treating an unnameable front door as "somewhere new" made `GO OUT` /// permanently tier-one fresh for every house in the game, which is the other half of the same /// bounce. The fly still chooses whether to press it; nothing here decides to leave. pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec { let tiers = exit_tiers(state, way); if !tiers.is_empty() { return tiers; } let all = unexcluded_exits(state, way); // Tier 3, and only where a map would otherwise be impossible to leave: see the doc comment. match way { // A room still has to be leavable, and since section 12.11 that holds even while the // ledgers are resting its one door: `unexcluded_exits` is emptied by the blocked window, // by the rung's own target being on this map (row 29) and by an unfaced counter, and with // `MENU` off the pad an emptied way out is an overworld with nothing on it at all. Way::Exit => { if all.is_empty() { last_resort(state, way) } else { all } } // A staircase the run has already been up is exploration already done, and the same bounce // `GO ROUTE` had: up, straight back down, up again, once per hold. The exception is the map // whose only way anywhere *is* a passage -- Red's bedroom, the upper floor of any house -- // where dropping the fallback would strand the fly. Way::Passage => { if path::exits(state).iter().any(|exit| exit.way == Way::Exit) { Vec::new() } else if all.is_empty() { last_resort(state, way) } else { all } } // Section 13.1's empty-pad rule, and *only* as a last resort (the operator, 2026-09-17: // "sometimes the macro buttons disappear and everything just hangs there"). // // Row 2 of `infra/docs/macros-traps.md` removed `GO ROUTE`'s unconditional tier 3 because // "all of them, nearest" walked the fly in and out of the same house door for two hours // seventeen. That reasoning still holds and this does not undo it: the fallback is reached // only when **nothing else on this map is worth walking to at all** ([`stranded`]) -- no // objective, no errand, no person, no object, no frontier, no other kind of exit. In that // state the choice is not "a visited door or something better", it is "a visited door or a // pad that cannot move", and a door the run has been through is the honest answer. // // It also ignores the blocked window, which nothing else does: a target the ledger is // resting is still the only place to go. Way::Route => last_resort(state, way), } } /// Every way out of this kind, ignoring the ledgers, when the map offers nothing else at all. /// /// Section 13.1's never-empty rule, and since section 12.11 it is what the rule *rests* on: the /// overworld has no unconditional button any more, so the pad of a map with every ledger against it /// is this list or nothing. Guarded by [`stranded`], which is built out of [`exit_tiers`] rather /// than [`ways`] so that asking "is the fly stranded" cannot recurse into the answer it decides. /// /// It ignores the blocked window, which nothing else does: a target the ledger is resting is still /// the only place to go. And it is a last resort rather than a tier -- with anything else on the /// pad it stays off, because "all of them, nearest" once per hold is row 2's own loop, measured as /// two hours seventeen in and out of one house door. fn last_resort(state: &mut dyn MacroState, way: Way) -> Vec { if !stranded(state) { return Vec::new(); } let every: Vec = path::exits(state).into_iter().filter(|exit| exit.way == way).collect(); let toward = toward_objective(state, &every); if toward.is_empty() { every } else { toward } } /// The exits of the current map of one kind that no ledger excludes. fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec { if !objective_targets(state).is_empty() { return Vec::new(); } // **A mart or a centre is not left while the thing the errand came for is still unfaced** // (2026-09-17, measured from the release container's own checkpoint). The same rule row 29 // gave the rung's own target, for the same failure: the fly arrives on the building's doormat, // `exit_goals` keeps a doormat underfoot because "its press is the point" (row 13), `GO OUT` // presses DOWN and the map changes ten frames later. The errand is paid on entering and is // never offered again, so that bounce spent the *one* visit this area gets and no macro could // ever walk the fly back: it reached the mart in 1.7 brain minutes and could not buy in it. // // The escape hatches are the ones every other suppression has: facing the counter retires it // ([`counter_aims`]), a `TALK` retires it, and a walk that cannot reach it is excluded for the // window -- after which `amenity_goals` empties and the way out is a candidate again. if counter_pending(state) { return Vec::new(); } path::exits(state) .into_iter() .filter(|exit| exit.way == way && !state.blocked(TargetKey::Exit(exit.id))) .collect() } /// Whether this map offers *nothing* worth walking to: the trigger for `GO ROUTE`'s last resort. /// /// Section 13.1's pad-empty audit. Deliberately built out of [`exit_tiers`] rather than [`ways`], /// so that asking "is the fly stranded" cannot recurse into the fallback the answer decides. /// /// What this measures is the state the operator saw on stream: a map where nothing the pad offers /// can move the fly anywhere. It used to be the weaker claim -- `MENU` was unconditional, so the /// pad was never *literally* empty, only useless -- and since section 12.11 took `MENU` off the /// overworld it is the literal one, which is why [`last_resort`] is what answers it. pub fn stranded(state: &mut dyn MacroState) -> bool { objective_goals(state).is_empty() && amenity_goals(state, Amenity::Mart).is_empty() && amenity_goals(state, Amenity::Center).is_empty() && !facing_untalked(state) && untalked_people(state).is_empty() && untalked_objects(state).is_empty() && frontier_aims(state).is_empty() && exit_tiers(state, Way::Exit).is_empty() && exit_tiers(state, Way::Passage).is_empty() && exit_tiers(state, Way::Route).is_empty() } /// Tiers one and two of [`ways`]: somewhere new, then somewhere on the way to the objective. fn exit_tiers(state: &mut dyn MacroState, way: Way) -> Vec { let Some(here) = state.player().map(|player| player.map) else { return Vec::new() }; // The room the rung is in is not left while the thing that earns it is standing in it. // // Knowledge inside the macro, the way `GO EXIT` knows which exits the run has taken: a way out // is not a candidate while the ladder's own target is on this map, untalked and unexcluded. // Nothing here ranks or chooses — the button simply has nothing to aim at, exactly as it has // nothing to aim at on a map with no doors. // // This is row 29 of `infra/docs/macros-traps.md`, and the second half of its fix: `GO OBJECTIVE` // used to arrive on the objective map's *doormat* and `GO OUT` walked straight back out, every // three brain seconds at Oak's lab door. Suppressing the way out on its own stalled the fly in // the room (measured: 600,000 frames in the lab, nothing delivered) because nothing led to Oak; // with the objective naming him, something does. And the escape hatch is the blocked ledger: a // target no walk can reach is excluded for the window, `objective_targets` empties, and the // way out is a candidate again. if !objective_targets(state).is_empty() { return Vec::new(); } // The same for section 13's errand: the building it came for is not left while its counter is // unfaced ([`counter_pending`]). let all = unexcluded_exits(state, way); let mut fresh = Vec::with_capacity(all.len()); for exit in &all { let known_visited = match exit.destination(here) { Some(map) => state.map_visited(map), // A front door nobody can name opens on the map the fly came in from, which is a map // this run has stood on by construction. Every other unnameable destination stays a // candidate. None => exit.way == Way::Exit, }; if !known_visited { fresh.push(*exit); } } if !fresh.is_empty() { let connections: Vec = fresh.iter().copied().filter(|exit| matches!(exit.id, ExitId::Edge(_))).collect(); return if connections.is_empty() { fresh } else { connections }; } toward_objective(state, &all) } /// The exits of `candidates` that take the first hop of the route to the objective's map. /// /// [`geography::next_hop`] is breadth-first over the static map graph, so this is "which of these /// doors is on the way", answered without a route table and without any map data beyond the one /// loaded. The region rather than the map id, because `ROUTE_2` is one map whose two halves are /// not connected to each other and "which way is Pewter" has two answers on it /// (`docs/design/macros.md` section 12.7). /// /// When the graph knows no route at all there is still one exit that is plainly toward the /// objective: one whose destination *is* the objective's map. That is the tier-2 answer for a map /// the table has no row for, and without it a way out whose whole destination list is visited had /// no candidate of any tier and left the button off the pad — which is the state the gate house /// was in. Empty when there is no objective and when it is on this map. pub fn toward_objective(state: &mut dyn MacroState, candidates: &[Exit]) -> Vec { let Some(objective) = objective_place(state) else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; let here = player.map; if here == objective.map { return Vec::new(); } let hop = geography::next_hop(geography::region_at(here, player.y), objective.map); let aim = hop.unwrap_or(objective.map); candidates.iter().copied().filter(|exit| exit.destination(here) == Some(aim)).collect() } /// The people on this map still worth walking to, each with the key the ledgers name it by. pub fn untalked_people(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> { if counter_pending(state) { return Vec::new(); } let targets = path::person_targets(state); untalked(state, targets) } /// The objects and signs on this map still worth walking to, with their keys. pub fn untalked_objects(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> { if counter_pending(state) { return Vec::new(); } let targets = path::interactable_targets(state); untalked(state, targets) } /// Whichever of `targets` the talked ledger has no entry for. /// /// No fallback to all of them: a map whose people have all been talked to leaves `GO NPC` /// *unbound*, which is section 12's "a precondition failure means the button is not on the pad". /// That is the point of the ledger -- the loop it replaced was a fallback that could never /// empty, so the button was always there and always aimed at the same villager. fn untalked( state: &mut dyn MacroState, targets: Vec<(Tile, TalkTarget)>, ) -> Vec<(Tile, TalkTarget)> { // A fourth exclusion, and the one that stops a macro completing without going anywhere: the // thing the fly is *already facing*. `GO NPC` and `GO ITEM` promise "arrive and face it", and // standing in front of it is that promise already kept -- so the walk is a press of a button // that finishes in twenty frames having moved nothing, once per hold (Viridian City, // 2026-09-17: `GO NPC start, GO NPC done (150 ms)`). The press that belongs on the pad there // is `TALK`, whose precondition is exactly this state, and the moment the fly turns away the // target is a candidate again. let ahead = facing_target(state); targets .into_iter() .filter(|(_, target)| Some(*target) != ahead) .filter(|(_, target)| { // Three exclusions, one candidate list (`docs/design/macros.md` section 12): // // - **talked** -- a `TALK` finished facing it, which is the ledger section 9.2 added; // - **reached** -- a `GO ITEM` or `GO NPC` arrived and faced it, which is all either // macro promises, and the half that was missing while the fly walked to the same // person in Viridian City for forty-five minutes because it never pressed A; // - **blocked** -- a walk to it aborted within the exclusion window, which is the // half that was missing while `GO ITEM` re-chose the same unreachable object. // // Still no fallback to the excluded ones: a map whose things are all accounted for // leaves the button off the pad, which is what makes the loop impossible rather than // merely unlikely. !state.talked(*target) && !state.reached(TargetKey::Thing(*target)) && !state.blocked(TargetKey::Thing(*target)) }) .collect() } /// The frontier tiles `GO FRONTIER` may aim at: [`path::frontier`] minus the excluded ones. /// /// The reached half needs nothing here, and that is worth stating because it looks like an /// omission: a frontier tile the fly *stood on* stops being a frontier by itself, since /// [`path::frontier`] asks [`MacroState::tile_visited`] about the new ground and the adapter's /// exploration ledger records the tile three stable samples after the fly arrives. The blocked /// half is real: a tile the route search cannot reach stays a frontier for ever otherwise. pub fn frontier_aims(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { // The measured culprit, second time round: a mart's unstood ground includes its own doormats, // and `Arrival::Step` presses DOWN onto one, which warps out. While the errand's counter is // unfaced the only walks on the pad are the ones that go to it ([`counter_pending`]). if counter_pending(state) { return Vec::new(); } let mut local: Vec<(Tile, Facing)> = Vec::new(); for (tile, facing) in path::frontier(state) { // A tile the blocked ledger is resting, or one a script pushes the fly off (row 37). if state.blocked(TargetKey::Tile(tile)) || state.pushed_tile(tile.x, tile.y) { continue; } local.push((tile, facing)); } if !local.is_empty() { return local; } // Section 13.1's pad-empty rule, second half: **the nearest unstood tile on the whole map**. // // [`path::frontier`] answers about the ten-by-nine walkable window, so a fly standing in a // corner of a route with the near ground covered has an empty frontier while most of the map // is unstood -- and with every exit visited that is the pad the operator saw hanging. This fallback // asks the stood ledger about every tile of the map instead and walks toward the nearest one // the run has not been on, treating an off-window tile as plausible ground exactly as the // route search does ([`path::UNKNOWN_STEP`]). // // It is a fallback and not the rule, because the windowed answer is the *correct* one whenever // it has anything in it: those tiles are known-walkable and known-adjacent. far_frontier(state) } /// The nearest tile of the whole map this run has not stood on, as a one-goal frontier. /// /// Excludes the tile underfoot, the tiles a sprite is standing in, tiles the collision table calls /// walls, and anything the blocked ledger is resting. An off-window tile reads /// [`super::state::Walkable::Unknown`] and is kept: the walk finds out on the way, which is the /// same bargain every cross-map walk makes. fn far_frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { let Some(size) = state.map_size() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; let here = Tile::new(player.x, player.y); let held: Vec = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect(); let mut best: Option<(u32, Tile)> = None; for y in 0..size.height { for x in 0..size.width { let tile = Tile::new(x, y); if tile == here || held.contains(&tile) { continue; } if state.walkable(x, y) == super::state::Walkable::No { continue; } if state.tile_visited(x, y) || state.blocked(TargetKey::Tile(tile)) || state.pushed_tile(x, y) { continue; } let distance = tile.distance(here); if best.is_none_or(|(known, _)| distance < known) { best = Some((distance, tile)); } } } best.map(|(_, tile)| vec![(tile, player.facing)]).unwrap_or_default() } /// Whether the tile ahead holds something this run has not talked to: `TALK`'s precondition. /// /// Both halves in one place now (`docs/design/macros.md` section 12). "Facing something" is the /// map's own object data: the tile ahead is one of its sprites or one of its `bg_event` signs. /// "Untalked" is the ledger, which is written when a `TALK` finishes facing the thing -- the /// question the cartridge could not answer before the ledger existed, and which the two readings /// tried on it (the ground beside the thing, and the thing's other sides) each got wrong in a /// different direction. pub fn facing_untalked(state: &mut dyn MacroState) -> bool { match facing_target(state) { Some(target) => !state.talked(target), None => false, } } /// What the fly is facing, whether or not it has been talked to: the tile-ahead half of /// [`facing_untalked`], which [`untalked`] needs on its own. pub fn facing_target(state: &mut dyn MacroState) -> Option { let player = state.player()?; let here = Tile::new(player.x, player.y); let ahead = here.step(player.facing)?; let size = state.map_size()?; if ahead.x >= size.width || ahead.y >= size.height { return None; } if let Some(target) = path::target_at(state, ahead) { return Some(target); } // **Over a counter**, which is the symmetric half of [`counter_aims`] and was missing from it // (2026-09-17, measured on the cartridge in the Viridian mart). The game doubles its own // talking range over the tileset's counter tiles, so a fly standing at a counter facing the // clerk *is* facing something -- but `target_at` looks one tile ahead, found the counter tile, // and answered nothing. `GO SHOP` walked the fly to the counter and `TALK` was not on the pad // there, so the counter could never be opened: the two macros between them could reach a mart // and not buy in it. // `counter_tile` is a screen-buffer read and it is **not reliable on a map smaller than the // screen**: measured in the Viridian mart on 2026-09-17, the same tile reads `Yes`/counter // from one of the fly's tiles and `No`/not-counter from another two tiles away, because an // 8x8 map cannot centre under a ten-by-nine view and the player is no longer at the buffer's // fixed point. So the reach is taken from the *map* instead, which is a byte the adapter // already has: inside a mart or a Pokémon Center, a counter is the only thing the game puts // between the fly and a person, and those two building kinds are the only ones this reach is // for. The tile read is still consulted, because it is right everywhere it can be trusted. let in_amenity = geography::amenity_at(player.map).is_some(); if !in_amenity && !state.counter_tile(ahead.x, ahead.y) { return None; } let over = ahead.step(player.facing)?; if over.x >= size.width || over.y >= size.height { return None; } path::target_at(state, over) } /// The tiles `GO OBJECTIVE` aims at, or empty when it cannot aim at anything from here. /// /// Section 9: "`GO OBJECTIVE` paths toward the next unreached ladder rung's place where the /// catalog knows one ... using the map, warp, connection and object data the adapter already /// reads; if the rung has no known place, fall through." Three cases, in order: /// /// - **the objective is on this map** and the catalog names a tile or a warp on it: walk there. /// With neither — which is every rung the Pokémon catalog knows today — there is nothing finer /// than "be here" to aim at and the fly is already here, so this answers empty and the plan /// falls through to `GO ITEM` and `GO NPC`, which is exactly right in Oak's lab. /// - **a warp on this map names the objective's map**: walk to that warp. This is the /// cross-map step, and it is honest map knowledge rather than a route table — the warp's /// destination is a byte the adapter already reads for every warp of the loaded map. /// - **the objective is outdoors and the fly is indoors**: a `LAST_MAP` warp leads outside, so it /// counts. Without this the front door of a house would never be the objective's door, because /// pokered writes it as "back where you came from" rather than as the town's id. pub fn objective_goals(state: &mut dyn MacroState) -> Vec { // Section 13 puts the area's errands *ahead* of the rung's place, and this is the one line // that does it: everything below is unchanged, aimed at whichever place is in front. // The building the errand came for is not left while its counter is unfaced, and this is the // macro that was measured leaving it ([`counter_pending`]). if counter_pending(state) { return Vec::new(); } let Some(objective) = objective_place(state) else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; let here = player.map; // Every way out this macro could aim at, minus the ones a walk has just failed at: the same // blocked-target ledger `ways` applies, and the reason `GO OBJECTIVE` can fall through to // nothing and leave its button off the pad rather than sit on the pad unchosen. let exits: Vec = path::exits(state) .into_iter() .filter(|exit| !state.blocked(TargetKey::Exit(exit.id))) .collect(); let of = |exits: Vec| -> Vec { exits .into_iter() .map(|exit| Aim { tile: exit.tile, press: exit.press, face: None, key: Some(TargetKey::Exit(exit.id)), }) .collect() }; if objective.map == here { // A rung earned by talking to something: the aim is the four tiles around it with the // press that turns to face it, which is `GO NPC`'s arrival. `approach` in the executor // builds the same shape for the same reason. // // The tile ahead is left out, exactly as `untalked` leaves it out (row 4): a fly already // standing in front of the thing has nothing left for a *walk* to do, and that is the state // `TALK`'s own precondition is. So `GO OBJECTIVE` completes, leaves the pad, and `TALK` // takes its place on the very next hold. if objective.target.is_some() { let ahead = Tile::new(player.x, player.y).step(player.facing); let here_tile = Tile::new(player.x, player.y); let mut ranked: Vec<(u32, Tile, TalkTarget)> = objective_targets(state) .into_iter() .filter(|(tile, _)| Some(*tile) != ahead) .map(|(tile, target)| (tile.distance(here_tile), tile, target)) .collect(); ranked.sort_unstable(); let Some((_, tile, target)) = ranked.first().copied() else { return Vec::new() }; let mut aims = Vec::with_capacity(4); for facing in FACINGS { let Some(stand) = tile.step(facing) else { continue }; // Not a tile a script pushes the fly off (row 37); `approach` excludes the same. if state.pushed_tile(stand.x, stand.y) { continue; } aims.push(Aim { tile: stand, press: None, face: Some(opposite(facing)), key: Some(TargetKey::Thing(target)), }); } return aims; } if let Some(tile) = objective.tile { if state.blocked(TargetKey::Tile(tile)) || state.pushed_tile(tile.x, tile.y) { return Vec::new(); } return vec![Aim { tile, press: None, face: None, key: Some(TargetKey::Tile(tile)) }]; } if let Some(index) = objective.warp { return of(exits.into_iter().filter(|exit| exit.id == ExitId::Warp(index)).collect()); } if let Some(edge) = objective.edge { return of(exits.into_iter().filter(|exit| exit.id == ExitId::Edge(edge)).collect()); } return Vec::new(); } goals_toward(state, objective.map) } /// This map's exits that take the first step of the way to `target`. /// /// The cross-map half of [`objective_goals`], lifted out because section 13's two errands need /// exactly the same walk to a different map: the first hop of a breadth-first route over the map /// graph, and whichever of this map's exits takes it, asked again on arrival -- which is the same /// re-plan-every-tile shape the walk itself has. /// /// When the graph knows no route at all there is still one exit that is plainly toward the target: /// one whose destination *is* that map. And for a target outdoors reached from indoors, the way /// out of the building counts, because pokered writes a front door as "back where you came from" /// rather than as the town's id. Empty when `target` is this map, and when nothing leads there. pub fn goals_toward(state: &mut dyn MacroState, target: u8) -> Vec { let Some(player) = state.player() else { return Vec::new() }; let here = player.map; if here == target { return Vec::new(); } let exits: Vec = path::exits(state) .into_iter() .filter(|exit| !state.blocked(TargetKey::Exit(exit.id))) .collect(); let of = |exits: Vec| -> Vec { exits .into_iter() .map(|exit| Aim { tile: exit.tile, press: exit.press, face: None, key: Some(TargetKey::Exit(exit.id)), }) .collect() }; if let Some(hop) = geography::next_hop(geography::region_at(here, player.y), target) { let toward: Vec = exits.iter().copied().filter(|exit| exit.destination(here) == Some(hop)).collect(); if !toward.is_empty() { return of(toward); } } let outward = !outdoors(here) && outdoors(target); of(exits .into_iter() .filter(|exit| match exit.into { Some(map) => map == target, None => outward && exit.way == Way::Exit, }) .collect()) } /// The people or objects on this map that the objective's place names, untalked and unexcluded. /// /// Empty unless the ladder's next rung is earned *here* and by talking to something /// ([`crate::adapter::PlaceKind`]). Two ledgers filter it and a third deliberately does not: /// /// - **talked** — a conversation already had is not the one the rung is waiting for; /// - **blocked** — a target a walk has just failed to reach is excluded for the window, which is /// what lets the leaving macros back onto the pad when the objective's own thing is unreachable; /// - **reached** is *not* applied. That ledger is `GO NPC`'s politeness — "you have stood in front /// of this villager once" — and the ladder is not being polite: the rung is the conversation, so /// the thing stays worth walking back to until it has been had. pub fn objective_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> { let Some(objective) = objective_place(state) else { return Vec::new() }; let Some(kind) = objective.target else { return Vec::new() }; let Some(here) = state.player().map(|player| player.map) else { return Vec::new() }; if objective.map != here { return Vec::new(); } let targets = match kind { PlaceKind::Person => path::person_targets(state), PlaceKind::Object => path::interactable_targets(state), }; targets .into_iter() .filter(|(_, target)| { !state.talked(*target) && !state.blocked(TargetKey::Thing(*target)) }) .collect() } /// The exits the ledger has already recorded, for the log line. pub fn visited_exits(state: &mut dyn MacroState) -> Vec { path::exits(state) .iter() .map(|exit| exit.id) .filter(|id| state.exit_visited(*id)) .collect() } /// Whether the Pokémon that is out is below half health: `ITEM`'s first half. /// /// The battle's own copy of the active Pokémon is the one the engine damages, so that is the HP /// this measures; the party entry is stale during a battle. pub fn hurt(state: &mut dyn MacroState) -> bool { let Some(battle) = state.battle() else { return false }; let Some(mon) = battle.own.or_else(|| state.party().active_mon().copied()) else { return false; }; mon.max_hp > 0 && u32::from(mon.hp) * 2 < u32::from(mon.max_hp) } /// The bag index of the first potion, which is also its cursor index in the bag menu. pub fn potion_slot(state: &mut dyn MacroState) -> Option { state .bag() .iter() .position(|stack| stack.id == item::POTION && stack.count > 0) .and_then(|index| u8::try_from(index).ok()) } /// The party slot of the healthiest Pokémon that is neither fainted nor the one already out. /// /// Section 3: "highest HP fraction, not fainted, not the active one", ties by slot — which is /// what agent A's `Party::healthiest_reserve` already answers, so this is a thin wrapper over it /// rather than a second opinion. pub fn healthiest_other(state: &mut dyn MacroState) -> Option { state.party().healthiest_reserve().map(|mon: &Mon| mon.slot) } /// Which battle menu is accepting input, or `None` outside a battle and between turns. /// /// The sub-state the pad depends on (`docs/design/macros.md` section 12.6): the top-level menu, the /// move list, or the party list. `state::battle` decides which; this is the accessor the palette /// and the executor both read so they cannot disagree about it. pub fn battle_menu(state: &mut dyn MacroState) -> BattleMenu { state.battle().map_or(BattleMenu::None, |battle| battle.menu) } /// The move list, when it is the menu that is up: `(cursor, count)`. pub fn move_list(state: &mut dyn MacroState) -> Option<(Option, u8)> { match battle_menu(state) { BattleMenu::Moves { cursor, count } => Some((cursor, count)), _ => None, } } /// The 0-based move slot a `MOVE n` button names. pub const fn move_index(kind: MacroKind) -> Option { match kind { MacroKind::Move1 => Some(0), MacroKind::Move2 => Some(1), MacroKind::Move3 => Some(2), MacroKind::Move4 => Some(3), _ => None, } } /// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14). /// /// Three things it is *not*, each of them a bug this palette has had: /// /// - it is not "the best move", because there is no such knowledge in this crate any more. Section /// 14 replaced `ATTACK` with four buttons precisely so that which move is used is the fly's /// choice and the mushroom body's to learn, rather than a base-power table's. /// - it is not "is there a move list to open at all" (row 34 of `infra/docs/macros-traps.md`). That /// rule existed because one button had to stand for every move; with a button per slot the /// question is per slot again, and the Struggle case is `MOVE 1`'s exception below. /// - it is not gated on which menu is up: the top-level menu and the open move list deal the same /// four buttons, and it is the *script* that differs (FIGHT first, or straight to the slot). /// /// **`MOVE 1` with nothing anywhere.** A Pokémon whose every move is out of PP uses Struggle, and /// the way to get there is to choose a move anyway. Taking all four buttons off the pad there is /// how an hour and forty-one minutes of `NEXT` began in Viridian Forest (row 34), so `MOVE 1` /// stays bound and its script confirms whatever the cursor is on. A Pokémon with no move in slot /// 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(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; } // And the same backstop over an **open move list** whose battler the seam cannot read // (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the // list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in // `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so // it reads no move either, and confirming a move is what ends a turn. let Some(own) = battle.own else { return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }); }; let holds = |slot: usize| -> Option<&Move> { own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) }; let Some(entry) = holds(usize::from(index)) else { return false }; if entry.pp > 0 { return true; } // Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the // fly would be offered a spent move beside a usable one. index == 0 && own.moves.iter().flatten().all(|entry| entry.id == 0 || entry.pp == 0) } /// The bag index of the first Poké Ball of any kind: `THROW BALL`'s target, and its precondition. /// /// `None` when there is no ball, when the battle is not a wild one -- the cartridge refuses a ball /// against a trainer and prints "The TRAINER blocked the BALL!" -- or when the party is full. /// /// **Party room only, and this is the limit worth stating.** A caught Pokémon goes to the party if /// there is space and to the current PC box otherwise, and `docs/design/macros-wram.md` has no /// reviewed symbol for the box count, so "a box has room" is not a question this crate can ask. /// Six in the party therefore takes the button off the pad even where a box would have taken the /// catch -- a narrowing, like every other thing the seam cannot answer, and not a guess. /// /// No catch rate, no enemy HP, no status: section 14 leaves *when* to throw to the fly. pub fn throw_slot(state: &mut dyn MacroState) -> Option { let battle = state.battle()?; if battle.kind != super::state::BattleKind::Wild { return None; } if state.party().mons.len() >= PARTY_CAPACITY { return None; } // **Not a species this run already has** (section 12.9). Live on rung 9, 69 hours in Viridian // Forest: `THROW BALL` was 28 of 183 macro starts, spending balls on the Caterpie and Weedle // already in the party -- and a catch opens the nickname screen, which reads `Unknown` and // needs the START the pad has no button for (row 14 of `infra/docs/macros-traps.md`), so the // throw costs a ball and then a stall. // // The *party* is the caught set here, and it is the honest one: it is the cartridge's own // lifetime record, it survives a restart the session ledgers do not, and it is in the same // numbering the enemy is read in -- the internal species index // (`docs/design/macros-wram.md`). `wPokedexOwned` is not usable for this: that bitset is by // Pokédex *number*, the table that converts an internal index to one is in a ROM bank this // crate cannot read, and `docs/design/ladder.md`'s rule is that an unverified number does not // go in. It costs nothing measurable: the button is already off the pad while the party is // full, nothing in the macro vocabulary deposits into a box (row 17), so every species this // run has caught is in the party this reads. // // An enemy species the seam could not place leaves the button where it was -- a precondition // this crate cannot observe is not a precondition, it is a guess (section 13.1). if let Some(species) = battle.enemy.map(|enemy| enemy.species).filter(|species| *species != 0) && state.party().mons.iter().any(|mon| mon.species == species) { return None; } let index = state .bag() .iter() .position(|stack| item::BALLS.contains(&stack.id) && stack.count > 0)?; u8::try_from(index).ok() } /// The cursor the current scene's macros navigate, derived from whichever of agent A's menus is /// up. /// /// `None` means no list is accepting input right now: an animation, a message, or a screen the /// seam does not report a cursor for. A script that needs one *waits* for it rather than pressing /// blind, which is section 4's "never by counting presses" taken seriously. pub fn listing(state: &mut dyn MacroState) -> Option { if let Some(battle) = state.battle() { return match battle.menu { 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 { 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 { kind: ListKind::BattleParty, current: cursor, max }) } }; } if let Some(menu) = state.start_menu() { return Some(Listing { kind: ListKind::StartMenu, current: menu.cursor.current, max: menu.cursor.max, }); } if let Some(shop) = state.shop() { return Some(Listing { kind: ListKind::Shop, current: shop.cursor.current, max: shop.cursor.max, }); } if let Some(pc) = state.pc() { return Some(Listing { kind: ListKind::Pc, current: pc.cursor.current, max: pc.cursor.max }); } None } /// Which screen of the mart is up, for the purchase script. pub fn shop_screen(state: &mut dyn MacroState) -> Option { state.shop().map(|shop| shop.screen) }