move_data reads a row of Moves from the cartridge image ($0E:$4000, each row checked against its own id); move_without_effect answers the refusals the effect routines make on bytes already in WRAM: a stat stage at its limit or a stat at 1/999, Mist or a substitute against a stat-lowering move, a status move against a statused, Poison-type or (Electric) Ground-type target. MacroState::move_without_effect defaults to false.
1134 lines
52 KiB
Rust
1134 lines
52 KiB
Rust
//! What the executor needs that agent A's seam does not carry, and the small shapes both halves
|
|
//! talk in.
|
|
//!
|
|
//! [`state`](super::state) is agent A's file and agent A's to change: it is copied into this
|
|
//! branch verbatim. What `docs/design/macros.md` names and it does not carry are cartridge
|
|
//! *tables* and session *ledgers* rather than WRAM:
|
|
//!
|
|
//! - the mart's stock list, for section 13's four purchases;
|
|
//! - the per-map visit set, for "nearest **unvisited** warp or map-edge exit";
|
|
//! - the talked, blocked, reached, stood and errand ledgers of sections 12 and 13.
|
|
//!
|
|
//! **The move table and the type chart are gone from here.** They existed for `ATTACK`'s "highest
|
|
//! base power move with PP, type effectiveness applied from the ROM's type chart", and section 14
|
|
//! replaced `ATTACK` with one button per move slot: which move is used is the fly's choice now,
|
|
//! so the knowledge that used to choose it is not narrowed, defaulted or degraded -- it is
|
|
//! removed.
|
|
//!
|
|
//! [`MacroState`] is where they go: one extension of A's trait, every method defaulted so that
|
|
//! `impl MacroState for PokeState {}` is enough to compile, and every default chosen so that a
|
|
//! missing table *narrows* what the palette offers instead of guessing. A slot the seam cannot
|
|
//! answer for is unbound, which is exactly section 3's own mechanism, and no macro ever presses a
|
|
//! button on a value it had to invent.
|
|
|
|
use crate::adapter::PlaceKind;
|
|
|
|
use super::geography::Amenity;
|
|
use super::state::{Facing, GameState, MapGrid};
|
|
|
|
/// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party.
|
|
///
|
|
/// `THROW BALL`'s only precondition beyond a ball and a wild battle: a full party sends a catch to
|
|
/// the current PC box, and this crate has no reviewed symbol for the box count, so a full party
|
|
/// takes the button off the pad. See [`super::palette::throw_slot`].
|
|
pub const PARTY_CAPACITY: usize = 6;
|
|
|
|
/// Every direction, in the readout's channel order.
|
|
pub const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right];
|
|
|
|
/// Item ids the palette's preconditions name, from `constants/item_constants.asm` at the pinned
|
|
/// pokered commit. Only the palette's preconditions depend on them, so a wrong id leaves a slot
|
|
/// unbound rather than pressing anything.
|
|
pub mod item {
|
|
pub const MASTER_BALL: u8 = 1;
|
|
pub const ULTRA_BALL: u8 = 2;
|
|
pub const GREAT_BALL: u8 = 3;
|
|
pub const POKE_BALL: u8 = 4;
|
|
|
|
/// Every ball a wild battle can be caught with, cheapest last.
|
|
///
|
|
/// `SAFARI_BALL` (`$08`) is deliberately absent: a Safari battle has its own menus and reads
|
|
/// as `Scene::Unknown` (`docs/design/macros-wram.md`), so `THROW BALL` is never dealt there
|
|
/// and a ball that only exists inside it is not one this palette can throw.
|
|
pub const BALLS: [u8; 4] = [MASTER_BALL, ULTRA_BALL, GREAT_BALL, POKE_BALL];
|
|
/// `$0b`. Section 13's third purchase: the cheapest thing any mart in Kanto sells.
|
|
pub const ANTIDOTE: u8 = 11;
|
|
pub const POTION: u8 = 20;
|
|
/// `$1e`.
|
|
pub const REPEL: u8 = 30;
|
|
}
|
|
|
|
/// Mart prices at the pinned pokered commit, for section 3's "if money allows".
|
|
///
|
|
/// `data/items/prices.asm`, which is a `bcd3` table indexed by item id: `POKE_BALL` 200,
|
|
/// `ANTIDOTE` 100, `POTION` 300, `REPEL` 350. The table lives in a ROM bank this crate may not
|
|
/// read ([`super::super::state`]'s own note), so the four numbers are quoted here with their
|
|
/// source, and a wrong one can only ever leave a purchase *unbound* -- the cartridge charges what
|
|
/// it charges, and the macro never types a quantity other than one.
|
|
pub mod price {
|
|
pub const POKE_BALL: u32 = 200;
|
|
pub const ANTIDOTE: u32 = 100;
|
|
pub const POTION: u32 = 300;
|
|
pub const REPEL: u32 = 350;
|
|
}
|
|
|
|
/// The four purchases section 13 names, as `(item id, price)`, in the order the palette deals them.
|
|
///
|
|
/// One list, three uses: the shop scene's buttons, each purchase's own "the mart stocks it and the
|
|
/// money covers it" precondition, and [`CHEAPEST_PURCHASE`], which is what "money below the
|
|
/// cheapest purchase skips the mart errand" measures against.
|
|
pub const PURCHASES: [(u8, u32); 4] = [
|
|
(item::POTION, price::POTION),
|
|
(item::POKE_BALL, price::POKE_BALL),
|
|
(item::ANTIDOTE, price::ANTIDOTE),
|
|
(item::REPEL, price::REPEL),
|
|
];
|
|
|
|
/// The least money any of [`PURCHASES`] can be made with: 100, the Antidote.
|
|
///
|
|
/// Section 13's "money below the cheapest purchase skips the mart errand". Deliberately the
|
|
/// cheapest of the *four buttons* rather than of the mart's stock: what a given counter stocks is
|
|
/// only readable once the counter is open, and an errand the fly cannot pay for anywhere is one
|
|
/// not worth walking to. A mart that turns out not to stock the Antidote simply leaves that button
|
|
/// off the pad when the fly gets there, which is the ordinary narrowing.
|
|
pub const CHEAPEST_PURCHASE: u32 = {
|
|
let mut least = u32::MAX;
|
|
let mut index = 0;
|
|
while index < PURCHASES.len() {
|
|
if PURCHASES[index].1 < least {
|
|
least = PURCHASES[index].1;
|
|
}
|
|
index += 1;
|
|
}
|
|
least
|
|
};
|
|
|
|
/// Rows of a mart's priced buy list the shared cursor can sit on.
|
|
///
|
|
/// Three, measured rather than derived (`infra/docs/macros-traps.md` row 55): on the live list in
|
|
/// the Pewter mart the cursor walked `0, 1, 2` under DOWN and then stopped moving while the
|
|
/// *window scrolled* under it, so the fourth drawn row is a look-ahead the cursor never occupies.
|
|
/// The absolute position of the item the cursor is on is that index plus `wListScrollOffset`,
|
|
/// which is not in the reviewed address list and cannot be pinned without the disassembly
|
|
/// `gen_symbols.py` reads -- so an item past the third of a counter's stock has no index this seam
|
|
/// can name, and a purchase aimed at one is a button whose script gives up before it presses
|
|
/// anything. [`super::palette::stock_index`] is where the rule is applied, once, for both the pad
|
|
/// and the plan.
|
|
pub const MART_CURSOR_ROWS: usize = 3;
|
|
|
|
/// Cursor indices of the battle menu, as `state::BattleMenu::Main` documents them: "0 FIGHT,
|
|
/// 1 PKMN, 2 ITEM, 3 RUN".
|
|
pub mod battle_entry {
|
|
pub const FIGHT: u8 = 0;
|
|
/// `$01`. **The left column's second row, not the right column's first.**
|
|
///
|
|
/// Red draws the battle menu as `FIGHT PKMN` over `ITEM RUN`, which reads as two rows -- and
|
|
/// the game's own index is two *columns*: `wCurrentMenuItem` is the row inside the column the
|
|
/// cursor is in and selection adds two for the right one. So the order is FIGHT, `ITEM`,
|
|
/// `PKMN`, RUN, and this pair was the other way round for as long as the four constants have
|
|
/// existed.
|
|
///
|
|
/// **Measured on the cartridge** (2026-09-22, `infra/docs/macros-traps.md`): a `THROW BALL`
|
|
/// aiming at 2 walked the cursor to `wTopMenuItemX` 15, `wCurrentMenuItem` 0, pressed A, and
|
|
/// the **party list** opened -- `wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02`.
|
|
/// The frame after the press the game wrote `wCurrentMenuItem` 2, which is the right column's
|
|
/// first row plus two, and the right column's first row is PKMN. So `THROW BALL` and `ITEM`
|
|
/// opened the party list and `SWITCH` opened the bag, every single time: `THROW BALL` was 63
|
|
/// starts and 63 `blocked` on v0.4.3, and `SWITCH` 15 of them.
|
|
pub const ITEM: u8 = 1;
|
|
/// `$02`. The right column's first row: see [`ITEM`].
|
|
pub const PKMN: u8 = 2;
|
|
pub const RUN: u8 = 3;
|
|
}
|
|
|
|
/// The button that walks, looks or moves a cursor this way.
|
|
pub const fn button(facing: Facing) -> u8 {
|
|
use crate::emulator::buttons;
|
|
match facing {
|
|
Facing::Down => buttons::DOWN,
|
|
Facing::Up => buttons::UP,
|
|
Facing::Left => buttons::LEFT,
|
|
Facing::Right => buttons::RIGHT,
|
|
}
|
|
}
|
|
|
|
/// The direction that points back the way `facing` came.
|
|
pub const fn opposite(facing: Facing) -> Facing {
|
|
match facing {
|
|
Facing::Down => Facing::Up,
|
|
Facing::Up => Facing::Down,
|
|
Facing::Left => Facing::Right,
|
|
Facing::Right => Facing::Left,
|
|
}
|
|
}
|
|
|
|
/// The last outdoor map id (`ROUTE_25`), which is how a warp is classified (section 9.1).
|
|
///
|
|
/// `constants/map_constants.asm` at the pinned pokered commit numbers the nine towns and cities,
|
|
/// Indigo Plateau, Saffron, one unused id and then `ROUTE_1` through `ROUTE_25` as `$00`..`$24`,
|
|
/// and `REDS_HOUSE_1F` at `$25` begins the interiors. So "is this map outdoors" is a comparison
|
|
/// rather than a table, and it is the decomp's own ordering rather than a tileset read -- which
|
|
/// matters because a map header lives in a ROM bank this crate cannot reach.
|
|
pub const LAST_OUTDOOR_MAP: u8 = 0x24;
|
|
|
|
/// A warp destination of `$ff`: "the map the player came from" (`LAST_MAP`).
|
|
///
|
|
/// How pokered writes a building's own front door, rather than naming the town outside. Counted
|
|
/// as outdoors by [`outdoors`], because on every interior map in the game that is what it is.
|
|
pub const LAST_MAP: u8 = 0xff;
|
|
|
|
/// Whether a map id is one of the outdoor maps.
|
|
pub const fn outdoors(map: u8) -> bool {
|
|
map <= LAST_OUTDOOR_MAP
|
|
}
|
|
|
|
/// Whether a warp's destination is outdoors, `LAST_MAP` included.
|
|
pub const fn destination_outdoors(destination: u8) -> bool {
|
|
destination == LAST_MAP || outdoors(destination)
|
|
}
|
|
|
|
/// A tile coordinate on the current map, in the same unit the player's coordinates are in.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub struct Tile {
|
|
pub x: u8,
|
|
pub y: u8,
|
|
}
|
|
|
|
impl Tile {
|
|
pub const fn new(x: u8, y: u8) -> Self {
|
|
Self { x, y }
|
|
}
|
|
|
|
/// Manhattan distance: the A* heuristic's unit, and the "nearest" in "nearest exit".
|
|
pub fn distance(self, other: Self) -> u32 {
|
|
u32::from(self.x.abs_diff(other.x)) + u32::from(self.y.abs_diff(other.y))
|
|
}
|
|
|
|
/// The neighbour one step in `facing`, or `None` where that leaves the coordinate space.
|
|
pub fn step(self, facing: Facing) -> Option<Self> {
|
|
let (dx, dy) = facing.delta();
|
|
let x = i32::from(self.x) + i32::from(dx);
|
|
let y = i32::from(self.y) + i32::from(dy);
|
|
Some(Self { x: u8::try_from(x).ok()?, y: u8::try_from(y).ok()? })
|
|
}
|
|
}
|
|
|
|
/// One way out of the current map, as the exploration ledger names it.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum ExitId {
|
|
/// The warp at this index of `GameState::warps`.
|
|
Warp(u8),
|
|
/// A step off this edge of the map.
|
|
Edge(Edge),
|
|
}
|
|
|
|
/// A map edge that leads to another map.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum Edge {
|
|
North,
|
|
South,
|
|
East,
|
|
West,
|
|
}
|
|
|
|
impl Edge {
|
|
/// Every edge with the direction a step off it travels.
|
|
pub const ALL: [(Self, Facing); 4] = [
|
|
(Self::North, Facing::Up),
|
|
(Self::South, Facing::Down),
|
|
(Self::West, Facing::Left),
|
|
(Self::East, Facing::Right),
|
|
];
|
|
}
|
|
|
|
/// Where the ladder's next unreached rung is, in the executor's own tile type.
|
|
///
|
|
/// [`crate::adapter::MapPlace`] crossing the seam: `state.rs` converts one into this so that
|
|
/// nothing under `macros/` has to name an adapter type. `tile` and `warp` are both `None` for
|
|
/// every rung the Pokémon catalog knows today -- it knows maps and nothing finer -- and the
|
|
/// script handles all three cases rather than assuming the sparse one away.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Objective {
|
|
/// The map the rung is earned on.
|
|
pub map: u8,
|
|
/// A tile on that map, when the catalog knows one.
|
|
pub tile: Option<Tile>,
|
|
/// An index into that map's warp table, when the catalog knows one.
|
|
pub warp: Option<u8>,
|
|
/// An edge of that map to step off, when the place is a connection.
|
|
pub edge: Option<Edge>,
|
|
/// What on that map earns the rung, when the rung is a conversation rather than a place:
|
|
/// [`crate::adapter::PlaceKind`] as the macro layer sees it.
|
|
pub target: Option<PlaceKind>,
|
|
}
|
|
|
|
/// One thing on the current map that prints something when it is faced.
|
|
///
|
|
/// The key of the *talked* ledger (`docs/design/macros.md` section 12): map plus object index,
|
|
/// where the index is the identity the cartridge itself gives the thing on this map. A sprite is
|
|
/// its sprite slot, which is stable while the map is loaded and is the same number the map's
|
|
/// `object_event` list is in; a sign is its text id, which is what a `bg_event` *is*.
|
|
///
|
|
/// Not a tile, deliberately: a person walks, and the tile it was talked to on says nothing about
|
|
/// whether it has been talked to.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum TalkTarget {
|
|
/// A sprite -- a person, or an object such as a Pokeball on a table -- by its sprite slot.
|
|
Sprite(u8),
|
|
/// A sign, by its text id.
|
|
Sign(u8),
|
|
}
|
|
|
|
/// One thing a macro can be *aimed at*, as the session's target ledgers name it.
|
|
///
|
|
/// `docs/design/macros.md` section 12 keeps knowledge inside macros, so the two ledgers of the
|
|
/// 2026-09-16 Viridian stall live behind [`MacroState`] and are read where a macro chooses its
|
|
/// target -- never where the fly chooses its macro. One key covers all five walking macros
|
|
/// because all five abort the same way and the exclusion is about the *target*, not the button:
|
|
///
|
|
/// - [`TargetKey::Exit`] for `GO OUT`, `GO WARP`, `GO ROUTE` and the cross-map step of
|
|
/// `GO OBJECTIVE`, by the same [`ExitId`] the exploration ledger names a way out with;
|
|
/// - [`TargetKey::Thing`] for `GO ITEM` and `GO NPC`, by the same [`TalkTarget`] the talked
|
|
/// ledger uses, so a thing that is walked to, reached and then talked to is one key throughout;
|
|
/// - [`TargetKey::Tile`] for `GO FRONTIER` and for an objective the catalog knows a tile for.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum TargetKey {
|
|
/// A way out of the map.
|
|
Exit(ExitId),
|
|
/// A thing on the map to stand beside and face.
|
|
Thing(TalkTarget),
|
|
/// A tile of the map to stand on.
|
|
Tile(Tile),
|
|
/// One answer to the YES/NO box at a tile: the key of the reopened-prompt exclusion
|
|
/// (`docs/design/macros.md` section 12.12).
|
|
///
|
|
/// Not a walk's target -- nothing is aimed at it -- but the same ledger and the same window,
|
|
/// because it is the same fact: an answer that changed nothing is an answer not worth making
|
|
/// again from this tile for a while. Keyed by the tile rather than by the person, because what
|
|
/// the box belongs to is whatever the fly is standing in front of, and the box is the only
|
|
/// thing on screen while it is open.
|
|
Answer { at: Tile, yes: bool },
|
|
}
|
|
|
|
/// 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 -- 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,
|
|
}
|
|
|
|
/// The state a macro reads: agent A's seam, plus the tables and the ledger above.
|
|
///
|
|
/// Every method is defaulted, and every default is a *narrowing*:
|
|
///
|
|
/// - no stock list means the four `BUY …` buttons stay unbound;
|
|
/// - an empty ledger means every exit is unvisited, which is a fresh run rather than an
|
|
/// exhausted one.
|
|
pub trait MacroState: GameState {
|
|
/// What the open mart sells, in menu order, so an item's position is its cursor index.
|
|
fn shop_stock(&mut self) -> Vec<u8> {
|
|
Vec::new()
|
|
}
|
|
|
|
/// Whether a tile of the loaded map is one the game lets the player talk *over*: a counter.
|
|
///
|
|
/// `docs/design/macros.md` section 13. A mart clerk and a Pokémon Center nurse both stand
|
|
/// behind a desk, so none of the four tiles around either is walkable and the approach every
|
|
/// other person takes cannot reach them. `IsSpriteOrSignInFrontOfPlayer` doubles the talking
|
|
/// range over exactly these tiles, so the second tile out is a place to stand and face from.
|
|
///
|
|
/// The default is `false` -- no counters anywhere -- which narrows to the ordinary approach.
|
|
fn counter_tile(&mut self, _x: u8, _y: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether the battle engine will answer the fly's move `id` with nothing on this frame: a
|
|
/// stat stage already at its limit, a status move against a target it cannot affect.
|
|
///
|
|
/// Row 60 (`docs/design/macros.md` 12.23), read from the cartridge's own move table and the
|
|
/// bytes its effect routines test ([`crate::pokemon_red::state::move_without_effect`]). The
|
|
/// default is `false`: a state that cannot read the table has proved nothing, so the `MOVE n`
|
|
/// buttons stay where they were.
|
|
fn move_without_effect(&mut self, _id: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
|
|
///
|
|
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state
|
|
/// that cannot answer has proved nothing, which is a fresh session.
|
|
fn frontier_exhausted(&mut self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether the cartridge has pushed the fly off this tile of the loaded map.
|
|
///
|
|
/// [`PushedLedger`] is the evidence and the measurement. The default is `false`: a state that
|
|
/// cannot answer has found no such tile, which is a fresh session.
|
|
fn pushed_tile(&mut self, _x: u8, _y: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// The whole loaded map's walkability, when the cartridge's tables can be decoded.
|
|
///
|
|
/// `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros
|
|
/// need to be map aware: A* over walkable tiles." [`GameState::walkable`] answers for the
|
|
/// ten-by-nine window of the screen buffer and [`super::state::Walkable::Unknown`] for
|
|
/// everything else, so before this every walk planned through guesses, re-planned at every
|
|
/// window edge, and `GO FRONTIER` aimed at whatever unstood ground happened to be on screen.
|
|
///
|
|
/// The default is `None`, which is this trait's usual narrowing and here it is also the
|
|
/// documented fallback: [`super::path::route`] and [`super::path::frontier`] use the window
|
|
/// predicate when the grid is absent, exactly as they did before, and
|
|
/// [`crate::pokemon_red::state::GridRefusal`] is what says why it is absent on a frame the
|
|
/// reader could not decode.
|
|
fn map_grid(&mut self) -> Option<std::sync::Arc<MapGrid>> {
|
|
None
|
|
}
|
|
|
|
/// Whether this run has already been into `area`'s mart or Pokémon Center.
|
|
///
|
|
/// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is
|
|
/// paid once and never loops. Written when the fly is observed standing on the building's own
|
|
/// map, which is the moment the errand is discharged -- a purchase or a heal marks nothing,
|
|
/// because what the errand asked for was the visit.
|
|
///
|
|
/// Session state beside [`MacroState::talked`] and [`MacroState::blocked`], owned by
|
|
/// [`super::driver::PokemonPalette`] and never checkpointed. A restored run offers every
|
|
/// errand once more, which is the honest answer for a ledger that did not survive.
|
|
///
|
|
/// The default is `false` -- nothing visited -- which is a fresh run.
|
|
fn area_visited(&mut self, _kind: Amenity, _area: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether `GO OUT`, `GO WARP` or `GO ROUTE` has already been through this exit.
|
|
///
|
|
/// Section 3: "visit sets for `GO EXIT` are the adapter's existing per-map exploration
|
|
/// ledger", and section 9.1 splits `GO EXIT` into three macros over the same ledger.
|
|
fn exit_visited(&mut self, _exit: ExitId) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether this run has stood on this tile of the current map: `GO FRONTIER`'s question
|
|
/// (section 9).
|
|
///
|
|
/// The default is `false`, i.e. no ground covered, which makes every walkable tile frontier.
|
|
/// That is the narrowing this trait's defaults all are: `GO FRONTIER` still only ever walks
|
|
/// to a tile it can see, and with no ledger it walks to the nearest one it is not standing on
|
|
/// rather than pretending to know where the run has been.
|
|
fn tile_visited(&mut self, _x: u8, _y: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether this run has ever been on `map`: `GO ROUTE`'s "a door into a building whose
|
|
/// interior is unvisited" (section 9.1).
|
|
fn map_visited(&mut self, _map: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// The tile the fly is stepping onto, or `None` while it is standing still.
|
|
///
|
|
/// **Row 54 of `infra/docs/macros-traps.md`, measured on the cartridge.** `wXCoord` and
|
|
/// `wYCoord` change at the *end* of a step, so for fifteen frames of every sixteen the fly's
|
|
/// coordinates are the tile it has already left. The ground under it is unrecorded for all of
|
|
/// them, `path::frontier` keeps offering it, and `GO FRONTIER` is dealt aiming one tile away
|
|
/// -- a walk that reports `done` the instant the step it did not make lands.
|
|
///
|
|
/// The default is `None`, i.e. never mid-step, which is the narrowing every other default in
|
|
/// this trait is: the stood ledger keeps the coordinates alone, which is what it did before.
|
|
fn stepping_onto(&mut self) -> Option<Tile> {
|
|
None
|
|
}
|
|
|
|
/// Whether this run has already talked to `target` on the map that is loaded.
|
|
///
|
|
/// `docs/design/macros.md` section 12's talked ledger, and the observable that ends the
|
|
/// town loop: "nearest untalked" used to mean "nearest thing with a tile beside it this run
|
|
/// has never stood on", which never closes for a villager standing in the open, so `GO NPC`
|
|
/// walked the fly back to the same person for ever (`infra/docs/macros-bench.md`, 2026-09-16).
|
|
/// A ledger entry is written when a `TALK` *finishes* facing the thing, which is the only
|
|
/// moment the press is known to have happened.
|
|
///
|
|
/// Session state rather than run state: it lives in the executor layer
|
|
/// ([`super::driver::PokemonPalette`]) and never reaches the checkpoint, because the
|
|
/// checkpoint's format is a compatibility string and this question is not worth moving it.
|
|
/// A restored run offers every person once more, which is the honest answer for a ledger that
|
|
/// did not survive.
|
|
///
|
|
/// The default is `false` -- nothing talked to -- which is a fresh session.
|
|
/// Whether the cartridge is driving the player instead of the fly.
|
|
///
|
|
/// `pokemon_red::state::controllable` inverted: a non-zero `wJoyIgnore` or
|
|
/// `wSimulatedJoypadStatesIndex`, `wStatusFlags5`'s scripted-movement or joypad-disabled bits,
|
|
/// a warp in flight, a ledge hop. It is **false during an ordinary text box** — measured at the
|
|
/// Viridian gate, where a plain conversation reads `joy=0 sim=0 flags5=0x00` and the gate's own
|
|
/// push-back reads `sim=1 flags5=0x80` (`infra/docs/macros-traps.md`, 2026-09-17) — which is
|
|
/// what makes it the test for "the conversation ended with the game moving the fly" rather than
|
|
/// a test for "a conversation happened".
|
|
///
|
|
/// The default is `false`: a state that cannot answer has nothing driving the player.
|
|
fn scripted(&mut self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn talked(&mut self, _target: TalkTarget) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether a text box is open at all: `wFontLoaded`'s bit, and nothing drawn.
|
|
///
|
|
/// The one thing that tells a screen with words on it from a frame of the overworld the
|
|
/// cartridge happens to be driving, and [`super::palette::scene_set`] deals
|
|
/// [`Scene::Unknown`]'s pad on it (**section 12.13**). `Unknown` is two different states
|
|
/// wearing one name: a screen this crate cannot name -- the Pokedex, the trainer card,
|
|
/// OPTION -- where `NEXT` and `BACK` are the A and B that leave it; and a *scripted* overworld
|
|
/// frame, where `scene::detect` falls through to `Unknown` because the buttons are not
|
|
/// reaching the player, and where an A or a B press is a press into somebody else's script.
|
|
///
|
|
/// The default is `false`, which narrows: with no reading, `Unknown` deals nothing and the
|
|
/// fly waits, which is what the doctrine says a scene with nothing to press does.
|
|
fn text_open(&mut self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box.
|
|
///
|
|
/// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the
|
|
/// cursor it parks inside it, surveyed on the cartridge (`docs/design/macros.md` section
|
|
/// 12.12). It is what tells the *one* frame of the nurse's conversation that is a choice from
|
|
/// the forty-five that are text, and the pad is dealt differently for it -- on a choice, an A
|
|
/// press *is* `YES`, so `NEXT` is the same press under another name (12.10).
|
|
///
|
|
/// The default is `false`: a state that cannot answer has no choice open, which leaves the
|
|
/// dialog pad exactly what it has always been.
|
|
fn yes_no_prompt(&mut self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether `target` is inside its blocked-target exclusion window on the map that is loaded.
|
|
///
|
|
/// Written when `GO ITEM`, `GO NPC`, `GO OBJECTIVE`, `GO OUT`, `GO WARP`, `GO ROUTE` or
|
|
/// `GO FRONTIER` aborts `Blocked` or `Timeout`, and true for [`BLOCKED_MINUTES_DEFAULT`]
|
|
/// brain minutes after that. The observable it closes (Viridian City, 2026-09-16): the
|
|
/// nearest object was unreachable from where the fly stood, so `GO ITEM` started and blocked,
|
|
/// started and blocked, once per hold for forty-five minutes -- because "nearest untalked"
|
|
/// re-chose the same unreachable thing every time and nothing recorded that the walk to it
|
|
/// had already failed.
|
|
///
|
|
/// A *window* rather than a permanent entry, because "unreachable" is a fact about where the
|
|
/// fly was standing and not about the target: a ledge, a closed door or a person in a doorway
|
|
/// all stop being in the way once the fly has moved, and a target excluded for ever would
|
|
/// lose the map its item. Ten brain minutes is long enough that the macro has to do something
|
|
/// else first and short enough that the thing is not forgotten.
|
|
///
|
|
/// Session state, like [`MacroState::talked`]: it lives in the executor layer
|
|
/// ([`super::driver::PokemonPalette`]) and never reaches the checkpoint.
|
|
fn blocked(&mut self, _target: TargetKey) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether the macro in `slot` was refused, for want of a route or of its precondition, on
|
|
/// the tile the fly is standing on now, inside the blocked window (`infra/docs/macros-traps.md`
|
|
/// row 57).
|
|
///
|
|
/// The dealer asks the cheap question and `start` asks the real one, so a button can be dealt
|
|
/// that its own route search refuses. The blocked ledger usually closes that gap -- the refusal
|
|
/// writes what it could not reach and the dealer stops offering it -- but not for a list that
|
|
/// deliberately ignores that ledger: a last resort. Live on rung 10, Pewter City, two hours:
|
|
/// the pad was `GO ROUTE` and nothing else, refused `no route` every 800 brain ms, because
|
|
/// `ways`'s last resort ignores the window its own refusal writes. A refusal from *here* is a
|
|
/// fact about here; the fly standing anywhere else, or the window closing, is what can change
|
|
/// it. Session state, never checkpointed.
|
|
fn refused_here(&mut self, _slot: u8) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether `GO ITEM` or `GO NPC` has already arrived at `target` and faced it this session.
|
|
///
|
|
/// The other half of the same stall: `GO NPC` walked to the same villager again and again,
|
|
/// reporting `done` every time, because only a *completed* `TALK` marks a person talked and
|
|
/// the fly is free never to press it. Arriving and facing is what `GO NPC` promises, so
|
|
/// arriving and facing is what retires the target -- for the rest of the session, since
|
|
/// nothing about a person the fly has already stood in front of changes by standing there
|
|
/// again. A later `TALK` writes the talked ledger instead, which excludes it anyway.
|
|
///
|
|
/// No window on this one: a reached target is not a failure to retry, it is a job done.
|
|
fn reached(&mut self, _target: TargetKey) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Where the ladder's next unreached rung is, when the adapter's rung catalog knows.
|
|
///
|
|
/// `None` leaves `GO OBJECTIVE` unbound, which is section 9's own fall-through: the plan's
|
|
/// next entry takes rank 0 and nothing is invented.
|
|
fn objective(&mut self) -> Option<Objective> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// What this session has already talked to, as the macros ask it.
|
|
///
|
|
/// Separate from [`crate::macros::RunLedger`], which is the adapter's lifetime exploration state,
|
|
/// because this one is neither the adapter's nor lifetime: it is written by the executor when a
|
|
/// `TALK` finishes and it dies with the process. `docs/design/macros.md` section 12 puts it here
|
|
/// deliberately -- the checkpoint's format is pinned to a compatibility string and a per-session
|
|
/// convenience is not worth moving it.
|
|
pub trait TalkLedger {
|
|
/// Whether a `TALK` has finished facing `target` on `map` in this session.
|
|
fn talked(&self, map: u8, target: TalkTarget) -> bool;
|
|
}
|
|
|
|
/// A ledger that has recorded nothing: every person and every sign untalked.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct NoTalk;
|
|
|
|
impl TalkLedger for NoTalk {
|
|
fn talked(&self, _map: u8, _target: TalkTarget) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// The session's talked ledger: every (map, thing) a `TALK` has finished facing.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Talked(std::collections::BTreeSet<(u8, TalkTarget)>);
|
|
|
|
impl Talked {
|
|
/// Record a finished `TALK`. Idempotent.
|
|
pub fn record(&mut self, map: u8, target: TalkTarget) {
|
|
self.0.insert((map, target));
|
|
}
|
|
|
|
/// How many things this session has talked to, for a log line and the tests.
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
}
|
|
|
|
impl TalkLedger for Talked {
|
|
fn talked(&self, map: u8, target: TalkTarget) -> bool {
|
|
self.0.contains(&(map, target))
|
|
}
|
|
}
|
|
|
|
/// The ground this session has watched the fly stand on, as `GO FRONTIER` asks it.
|
|
///
|
|
/// The second half of [`MacroState::tile_visited`], and the fix for the 2026-09-17 gate-house
|
|
/// stall (`infra/docs/macros-traps.md` row 32). The adapter's `exploration` ledger is the first
|
|
/// half and it is a *reward* ledger: `sample` inserts a coordinate only on a frame its payout gate
|
|
/// accepts, and that gate rejects `wMovementFlags & 0xc7` — bits 0, 1 and 2 are
|
|
/// `BIT_STANDING_ON_DOOR`, `BIT_EXITING_DOOR` and `BIT_STANDING_ON_WARP`. So **a doormat is a tile
|
|
/// the reward ledger can never record**, and a doormat is walkable, standable ground: every warp
|
|
/// tile in the game stayed a frontier for ever, and the pad in Viridian Forest's south gate was
|
|
/// `GO FRONTIER` and nothing else for four hours, one tile per macro, once per hold.
|
|
///
|
|
/// The gate is the reward rule's own and it stays exactly as it is — the catalog, the payouts and
|
|
/// the checkpoint are not this file's business. What changes is that the macro layer keeps its own
|
|
/// answer to its own question: session state in the executor layer beside [`Talked`] and
|
|
/// [`Targets`], written once per frame from the tile the fly is standing on, never checkpointed.
|
|
/// A restored run starts with the adapter's lifetime ground and re-learns the doormats it stands
|
|
/// on, which is the honest answer for a ledger that did not survive.
|
|
///
|
|
/// It can only ever make the frontier *smaller*: `tile_visited` is the two ledgers OR'd, so no
|
|
/// tile either one has recorded is offered as new ground.
|
|
pub trait StoodLedger {
|
|
/// Whether the fly has been seen standing on `tile` of `map` this session.
|
|
fn stood(&self, map: u8, tile: Tile) -> bool;
|
|
}
|
|
|
|
/// A ledger that has watched nothing: no ground covered.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct NoStood;
|
|
|
|
impl StoodLedger for NoStood {
|
|
fn stood(&self, _map: u8, _tile: Tile) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// The session's own record of where the fly has stood.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Stood(std::collections::BTreeSet<(u8, Tile)>);
|
|
|
|
impl Stood {
|
|
/// Record the tile the fly is standing on, and say whether it is ground this run had not
|
|
/// stood on before. Idempotent.
|
|
///
|
|
/// The answer is what clears a map's frontier mark ([`Frontiers`], section 12.14): new
|
|
/// ground under the fly is the one thing that can have changed which tiles it can reach.
|
|
pub fn record(&mut self, map: u8, tile: Tile) -> bool {
|
|
self.0.insert((map, tile))
|
|
}
|
|
|
|
/// How much ground this session has watched, for a log line and the tests.
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
}
|
|
|
|
impl StoodLedger for Stood {
|
|
fn stood(&self, map: u8, tile: Tile) -> bool {
|
|
self.0.contains(&(map, tile))
|
|
}
|
|
}
|
|
|
|
/// Maps whose frontier the run has proved it cannot reach any more.
|
|
///
|
|
/// **The rung-10 museum and Pewter City, 2026-09-22.** `GO FRONTIER` ran 1,235 times in 47
|
|
/// minutes over two museum floors and a town whose walkable ground the fly had already covered.
|
|
/// The ground it was aiming at was real -- the museum's exhibit hall behind the admission desk,
|
|
/// the far side of a fence -- and unreachable: 98 walkable tiles on map `0x34`, 62 of them
|
|
/// reachable from the door, 39 never stood on and all but a handful of those behind the desk.
|
|
/// A walk that can reach none of its goals refuses `no route` and writes every one of them to
|
|
/// the blocked ledger ([`Targets`]), which is a **window**: ten brain minutes later all forty
|
|
/// tiles were candidates again, the button was back on the pad, and the refusal happened again.
|
|
/// A window is right for a target a person is standing in front of and wrong for ground the map
|
|
/// has fenced off.
|
|
///
|
|
/// So the refusal is remembered per map instead. It is written when a `GO FRONTIER` refuses for
|
|
/// want of a route -- the measured fact "from here, no unstood tile of this map can be walked
|
|
/// to" -- and it is cleared the moment the fly **stands somewhere on that map it has not stood
|
|
/// before**, because that is the only thing that can have changed the answer: a door opened, a
|
|
/// script carried the fly through, somebody moved out of a doorway. Not on re-entering the map,
|
|
/// which is the loop the window made.
|
|
///
|
|
/// Session state beside [`Talked`], [`Stood`], [`Areas`] and [`Pushed`], never checkpointed: a
|
|
/// restored run tries the frontier once more, which is the honest answer for a ledger that did
|
|
/// not survive.
|
|
pub trait FrontierLedger {
|
|
/// Whether a `GO FRONTIER` has proved this map's remaining frontier unreachable.
|
|
fn frontier_exhausted(&self, map: u8) -> bool;
|
|
}
|
|
|
|
/// A ledger that has proved nothing: every map's frontier is still worth a walk.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct NoFrontiers;
|
|
|
|
impl FrontierLedger for NoFrontiers {
|
|
fn frontier_exhausted(&self, _map: u8) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// The session's own record of which maps have nothing left to walk to.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Frontiers(std::collections::BTreeSet<u8>);
|
|
|
|
impl Frontiers {
|
|
/// Record that `map`'s frontier could not be reached. Idempotent.
|
|
pub fn record(&mut self, map: u8) {
|
|
self.0.insert(map);
|
|
}
|
|
|
|
/// Forget `map`'s mark, because the run has just stood somewhere on it that it had not.
|
|
pub fn clear(&mut self, map: u8) {
|
|
self.0.remove(&map);
|
|
}
|
|
|
|
/// How many maps are marked, for a log line and the tests.
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
}
|
|
|
|
impl FrontierLedger for Frontiers {
|
|
fn frontier_exhausted(&self, map: u8) -> bool {
|
|
self.0.contains(&map)
|
|
}
|
|
}
|
|
|
|
/// Tiles the cartridge has pushed the fly off, as the macros ask it.
|
|
///
|
|
/// **The Viridian private-property tile** (2026-09-17, `infra/docs/macros-traps.md` row 37).
|
|
/// `ViridianCityCheckGotPokedexScript` fires on *every frame* the fly stands on (19, 9) of
|
|
/// Viridian City without the Pokédex: it prints "This is private property!" and walks the player
|
|
/// down a tile. The sleeping old man is at (18, 9) and `approach` offers the four tiles around
|
|
/// him, one of which is that tile — so `GO NPC` walked onto it, got a box, the fly advanced the
|
|
/// box, and the walk went back. Measured from the release container's own checkpoint: **53,266 of
|
|
/// 54,377 text-box frames in twenty brain minutes on that one tile**, 991 of them answered `YES`.
|
|
///
|
|
/// The blocked ledger could not close it. It is keyed on the *target* -- the old man -- so it
|
|
/// excluded a villager for ten brain minutes and said nothing about the ground, and the window
|
|
/// reopened. A scripted push-back is a fact about the **tile**, which is why this ledger is
|
|
/// separate and, unlike [`Targets`], has **no window**: the map does not stop being like that.
|
|
///
|
|
/// Session state beside [`Talked`] and [`Stood`], never checkpointed; a restored run walks onto
|
|
/// the tile once more and learns it again, which is the honest answer for a ledger that did not
|
|
/// survive.
|
|
pub trait PushedLedger {
|
|
/// Whether the cartridge has pushed the fly off `tile` of `map` this session.
|
|
fn pushed(&self, map: u8, tile: Tile) -> bool;
|
|
}
|
|
|
|
/// A ledger that has recorded nothing: no tile has pushed the fly anywhere.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct NoPushed;
|
|
|
|
impl PushedLedger for NoPushed {
|
|
fn pushed(&self, _map: u8, _tile: Tile) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// The session's record of the tiles the cartridge drives the fly off.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Pushed(std::collections::BTreeSet<(u8, Tile)>);
|
|
|
|
impl Pushed {
|
|
/// Record a scripted push-back. Idempotent.
|
|
pub fn record(&mut self, map: u8, tile: Tile) {
|
|
self.0.insert((map, tile));
|
|
}
|
|
|
|
/// How many such tiles this session has found, for a log line and the tests.
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
}
|
|
|
|
impl PushedLedger for Pushed {
|
|
fn pushed(&self, map: u8, tile: Tile) -> bool {
|
|
self.0.contains(&(map, tile))
|
|
}
|
|
}
|
|
|
|
/// Which of an area's errands this run has discharged, as the macros ask it.
|
|
///
|
|
/// `docs/design/macros.md` section 13. Session state beside [`Talked`] and [`Stood`], for exactly
|
|
/// the same reason: it is a fact about this run's walking rather than about the save, and the
|
|
/// checkpoint's format is pinned to a compatibility string that a per-session convenience is not
|
|
/// worth moving.
|
|
pub trait AreaLedger {
|
|
/// Whether the fly has been seen inside `area`'s building of this kind this run.
|
|
fn visited(&self, kind: Amenity, area: u8) -> bool;
|
|
}
|
|
|
|
/// A ledger that has recorded nothing: every errand still outstanding.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct NoAreas;
|
|
|
|
impl AreaLedger for NoAreas {
|
|
fn visited(&self, _kind: Amenity, _area: u8) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// The session's errand ledger: every `(kind, area)` the fly has been inside.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Areas(std::collections::BTreeSet<(Amenity, u8)>);
|
|
|
|
impl Areas {
|
|
/// Record a visit. Idempotent, which is what "once per area per run" is made of.
|
|
pub fn record(&mut self, kind: Amenity, area: u8) {
|
|
self.0.insert((kind, area));
|
|
}
|
|
|
|
/// How many errands this run has discharged, for a log line and the tests.
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
}
|
|
|
|
impl AreaLedger for Areas {
|
|
fn visited(&self, kind: Amenity, area: u8) -> bool {
|
|
self.0.contains(&(kind, area))
|
|
}
|
|
}
|
|
|
|
/// Brain minutes a blocked or timed-out target is excluded from its macro's target choice.
|
|
///
|
|
/// `docs/design/macros.md` section 12's fix for the Viridian stall, and the operator's number. Tunable
|
|
/// with [`BLOCKED_MINUTES_ENV`] because it is the one figure in the pair that is a judgement: too
|
|
/// short and the macro walks back into the same wall, too long and a map's only item is forgotten
|
|
/// for an hour.
|
|
pub const BLOCKED_MINUTES_DEFAULT: f64 = 10.0;
|
|
|
|
/// The environment variable that overrides [`BLOCKED_MINUTES_DEFAULT`].
|
|
///
|
|
/// Read once, when the session's ledger is built. A value that is not a finite positive number is
|
|
/// ignored with a warning rather than clamped to something invented.
|
|
pub const BLOCKED_MINUTES_ENV: &str = "FLY_MACRO_BLOCKED_MINUTES";
|
|
|
|
const MINUTE_MS: f64 = 60_000.0;
|
|
|
|
/// Frame-cap timeouts one target may cost a *closing* walk before it is excluded anyway.
|
|
///
|
|
/// Section 4 gives a walk three failed steps before it is `Blocked`; this is the same number at the
|
|
/// macro's own scale, and it is what bounds a walk that keeps the cap busy without ever arriving.
|
|
/// A `Timeout` that ended no nearer than it began excludes its target at once -- ten seconds of
|
|
/// buttons and no ground gained is a fact about the target. A `Timeout` that got closer is a walk
|
|
/// that is simply longer than ten seconds, so it costs one strike and the next hold resumes it;
|
|
/// three of them is thirty brain seconds of walking, which is wider than any town on the cartridge,
|
|
/// so a target still not reached by then is one to leave alone for a while.
|
|
///
|
|
/// Both readings were measured. "Did the player move" alone turned the Viridian loop into a
|
|
/// different one: the A* re-plans every tile over a walkable predicate whose window is the screen,
|
|
/// so a walk can step between two tiles for the whole cap, move every time, and arrive nowhere --
|
|
/// twelve such macros every two brain minutes over two tiles (`infra/docs/macros-traps.md`).
|
|
pub const TIMEOUT_STRIKES: u32 = 3;
|
|
|
|
/// The two target ledgers of `docs/design/macros.md` section 12, as the macros ask them.
|
|
///
|
|
/// Both are session state and neither is checkpointed, for the same reason the talked ledger is
|
|
/// not: a restored run offers every target once more, which is the honest answer for a ledger
|
|
/// that did not survive.
|
|
pub trait TargetLedger {
|
|
/// Whether `target` on `map` is inside its blocked-target exclusion window.
|
|
fn blocked(&self, map: u8, target: TargetKey) -> bool;
|
|
|
|
|
|
/// Whether `target` on `map` has been arrived at and faced this session.
|
|
fn reached(&self, map: u8, target: TargetKey) -> bool;
|
|
|
|
/// Whether the macro in `slot` was refused standing on `tile` of `map`, inside the window
|
|
/// ([`MacroState::refused_here`]). Defaulted, so a ledger that never records one refuses
|
|
/// nothing.
|
|
fn refused(&self, _map: u8, _slot: u8, _tile: Tile) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Ledgers that have recorded nothing: every target still a candidate.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct NoTargets;
|
|
|
|
impl TargetLedger for NoTargets {
|
|
fn blocked(&self, _map: u8, _target: TargetKey) -> bool {
|
|
false
|
|
}
|
|
|
|
fn reached(&self, _map: u8, _target: TargetKey) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// The session's blocked- and reached-target ledgers, and the brain clock they are read against.
|
|
///
|
|
/// One type for both halves because they are written from the same place (the executor, as a
|
|
/// macro finishes), read from the same place (a macro's target choice) and owned by the same one
|
|
/// ([`super::driver::PokemonPalette`]). The clock is carried here rather than passed per question
|
|
/// so that a `blocked` asked twice in one frame cannot answer differently.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Targets {
|
|
/// (map, target) -> the brain millisecond the abort was recorded at.
|
|
blocked: std::collections::BTreeMap<(u8, TargetKey), f64>,
|
|
/// (map, target) -> frame-cap timeouts a closing walk has spent on it, up to
|
|
/// [`TIMEOUT_STRIKES`].
|
|
strikes: std::collections::BTreeMap<(u8, TargetKey), u32>,
|
|
/// (map, target) -> the brain millisecond a walk arrived at it and faced it.
|
|
///
|
|
/// A window rather than a retirement, for the same reason `blocked` is one
|
|
/// (`docs/design/macros.md` section 12.4): **arriving is not talking.** 12.1 retired a reached
|
|
/// target for the session, to stop the fly walking to one villager for forty-five minutes —
|
|
/// and that also retired every person the fly walked to and then wandered away from without
|
|
/// pressing A, with nothing able to bring it back. Oak is the case that found it: the fly
|
|
/// reached his lab in 1.4 brain minutes, stood in front of him, chose something else, and
|
|
/// could never be offered him again — so the parcel stayed undelivered and the road north
|
|
/// stayed shut. The window keeps the loop bounded at one walk per target per window while
|
|
/// leaving the conversation available.
|
|
reached: std::collections::BTreeMap<(u8, TargetKey), f64>,
|
|
/// (map, slot, tile) -> the brain millisecond a macro was refused standing there
|
|
/// ([`MacroState::refused_here`], row 57). The same window as `blocked`.
|
|
refused: std::collections::BTreeMap<(u8, u8, Tile), f64>,
|
|
/// The brain clock of the frame being decided, from [`super::driver::PokemonPalette`].
|
|
now_ms: f64,
|
|
/// How long an entry excludes its target, in brain milliseconds.
|
|
window_ms: f64,
|
|
}
|
|
|
|
impl Default for Targets {
|
|
fn default() -> Self {
|
|
Self::with_minutes(BLOCKED_MINUTES_DEFAULT)
|
|
}
|
|
}
|
|
|
|
impl Targets {
|
|
/// The session's ledgers, with the exclusion window [`BLOCKED_MINUTES_ENV`] asks for.
|
|
pub fn new() -> Self {
|
|
let minutes = match std::env::var(BLOCKED_MINUTES_ENV) {
|
|
Err(_) => BLOCKED_MINUTES_DEFAULT,
|
|
Ok(raw) => match raw.trim().parse::<f64>() {
|
|
Ok(value) if value.is_finite() && value > 0.0 => value,
|
|
// Not clamped to something invented: an unreadable knob is the default, said out
|
|
// loud, because a silently halved exclusion window is a stall nobody can explain.
|
|
_ => {
|
|
// This crate has no logger of its own -- `flysim` owns tracing -- so the
|
|
// complaint goes to stderr, where the unit's journal picks it up.
|
|
eprintln!(
|
|
"{BLOCKED_MINUTES_ENV}={raw:?} is not a positive number of brain minutes; \
|
|
using {BLOCKED_MINUTES_DEFAULT}"
|
|
);
|
|
BLOCKED_MINUTES_DEFAULT
|
|
}
|
|
},
|
|
};
|
|
Self::with_minutes(minutes)
|
|
}
|
|
|
|
/// Ledgers with an explicit window, for the tests and for [`Targets::new`].
|
|
pub fn with_minutes(minutes: f64) -> Self {
|
|
Self {
|
|
blocked: std::collections::BTreeMap::new(),
|
|
strikes: std::collections::BTreeMap::new(),
|
|
reached: std::collections::BTreeMap::new(),
|
|
refused: std::collections::BTreeMap::new(),
|
|
now_ms: 0.0,
|
|
window_ms: minutes * MINUTE_MS,
|
|
}
|
|
}
|
|
|
|
/// The brain clock this frame is being decided at.
|
|
pub fn clock(&mut self, now_ms: f64) {
|
|
if now_ms.is_finite() {
|
|
self.now_ms = now_ms;
|
|
}
|
|
}
|
|
|
|
/// The exclusion window in brain minutes, for a log line and the tests.
|
|
pub fn minutes(&self) -> f64 {
|
|
self.window_ms / MINUTE_MS
|
|
}
|
|
|
|
/// Record that the macro in `slot` was refused -- no route, or no precondition -- with the fly
|
|
/// standing on `tile` of `map` (row 57). Re-recording restarts the window, as `blocked` does.
|
|
pub fn record_refused(&mut self, map: u8, slot: u8, tile: Tile) {
|
|
let now = self.now_ms;
|
|
let window = self.window_ms;
|
|
self.refused.retain(|_, at| now - *at < window);
|
|
self.refused.insert((map, slot, tile), now);
|
|
}
|
|
|
|
/// Record a `Blocked` or `Timeout` abort against the target it was aimed at.
|
|
///
|
|
/// Re-recording restarts the window, which is the honest reading: the walk failed *again*.
|
|
/// Entries whose window has closed are dropped as they are passed, so the map stays the size
|
|
/// of what is currently excluded rather than of everything a session ever failed at.
|
|
pub fn record_blocked(&mut self, map: u8, target: TargetKey) {
|
|
let now = self.now_ms;
|
|
let window = self.window_ms;
|
|
self.blocked.retain(|_, at| now - *at < window);
|
|
self.blocked.insert((map, target), now);
|
|
// The exclusion supersedes the strikes: when the window closes the target comes back with
|
|
// a clean slate rather than one strike from being excluded again.
|
|
self.strikes.remove(&(map, target));
|
|
}
|
|
|
|
/// Record that a walk to `target` spent the frame cap, and exclude the target when that is what
|
|
/// the cap means.
|
|
///
|
|
/// `closer` is whether the walk ended nearer a goal than it began. A walk that did not gets its
|
|
/// target excluded at once; one that did costs a strike, and [`TIMEOUT_STRIKES`] of them
|
|
/// exclude it. Either way the fly keeps choosing: this is the *target* choice inside the macro,
|
|
/// never the choice of macro (`docs/design/macros.md` section 12).
|
|
pub fn record_timeout(&mut self, map: u8, target: TargetKey, closer: bool) {
|
|
if !closer {
|
|
self.record_blocked(map, target);
|
|
return;
|
|
}
|
|
let strikes = self.strikes.entry((map, target)).or_insert(0);
|
|
*strikes += 1;
|
|
if *strikes >= TIMEOUT_STRIKES {
|
|
self.record_blocked(map, target);
|
|
}
|
|
}
|
|
|
|
/// Record that `GO ITEM` or `GO NPC` arrived at `target` and faced it.
|
|
///
|
|
/// Re-recording restarts the window, exactly as [`Targets::record_blocked`] does: the fly
|
|
/// arrived *again*.
|
|
pub fn record_reached(&mut self, map: u8, target: TargetKey) {
|
|
let now = self.now_ms;
|
|
let window = self.window_ms;
|
|
self.reached.retain(|_, at| now - *at < window);
|
|
self.reached.insert((map, target), now);
|
|
self.strikes.remove(&(map, target));
|
|
}
|
|
|
|
/// Frame-cap strikes standing against `target` on `map`, for a log line and the tests.
|
|
pub fn strikes(&self, map: u8, target: TargetKey) -> u32 {
|
|
self.strikes.get(&(map, target)).copied().unwrap_or(0)
|
|
}
|
|
|
|
/// How many targets are excluded right now, and how many have been reached, for a log line.
|
|
pub fn len(&self) -> (usize, usize) {
|
|
let standing = |entries: &std::collections::BTreeMap<(u8, TargetKey), f64>| {
|
|
entries.values().filter(|at| self.now_ms - **at < self.window_ms).count()
|
|
};
|
|
(standing(&self.blocked), standing(&self.reached))
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == (0, 0)
|
|
}
|
|
}
|
|
|
|
impl TargetLedger for Targets {
|
|
fn blocked(&self, map: u8, target: TargetKey) -> bool {
|
|
// A clock that has gone backwards -- which only a rollback can do, and the loop cancels
|
|
// the running macro then -- leaves the entry standing rather than expiring it early.
|
|
self.blocked.get(&(map, target)).is_some_and(|at| self.now_ms - *at < self.window_ms)
|
|
}
|
|
|
|
fn reached(&self, map: u8, target: TargetKey) -> bool {
|
|
self.reached.get(&(map, target)).is_some_and(|at| self.now_ms - *at < self.window_ms)
|
|
}
|
|
|
|
fn refused(&self, map: u8, slot: u8, tile: Tile) -> bool {
|
|
self.refused.get(&(map, slot, tile)).is_some_and(|at| self.now_ms - *at < self.window_ms)
|
|
}
|
|
}
|