Forty minutes after the Boulder Badge the overworld pad had shrunk to `GO ROUTE` alone and it was completing every 420 ms at a net of zero tiles. The trap hunt from that checkpoint on main is row 54 verbatim, one town further on: 14 distinct tiles in six brain minutes, 17 of 17 windows flagged, `GO FRONTIER` 122 / `GO HEAL` 129 / `GO ROUTE` 126, every one done at a mean net of 0.0. The run claims the fly leaves Pewter City for Route 3, that no chain of walks completes at a net of zero tiles more than three times in a row, and that neither errand is offered in a town the run has already shopped and healed in.
2550 lines
123 KiB
Rust
2550 lines
123 KiB
Rust
//! Macros mode on the real cartridge: the scene sets the buttons, the readout presses one.
|
|
//!
|
|
//! Gated on `FLY_ROM`, the same convention every ROM test in the workspace uses; the cartridge
|
|
//! never enters this repository:
|
|
//!
|
|
//! ```sh
|
|
//! FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
//! cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
//! ```
|
|
//!
|
|
//! ## What only the cartridge can answer
|
|
//!
|
|
//! Whether the scene's own buttons plus a readout that knows nothing about the game are enough to
|
|
//! get out of Red's house (`docs/design/macros.md` section 12). The unit tests pin which macros a
|
|
//! scene binds (`pokemon_red::macros`) and what the layer does with a winning channel
|
|
//! (`flysim::macros`) separately, and neither can tell you whether the two together reach Oak's
|
|
//! lab — that is a question about the warp table, about a doormat that fires on the step off it,
|
|
//! and about the rung catalog's places being the ones the ladder actually climbs.
|
|
//!
|
|
//! ## The stub readout
|
|
//!
|
|
//! A real [`PopulationDecoder`] with the Game Boy preset's macro group, fed synthetic rates: no
|
|
//! brain. One burst per hold leans on a single `macro_<type>` population — 1.6 times its own
|
|
//! calibration rate, every other population at rest — and the hot one rotates every
|
|
//! [`HOLDS_PER_SLOT`] bursts, which is the shape a settled network's own group has ("not a
|
|
//! tie-breaker; it is the rotation period", `docs/readout.md`). Nothing else about the decision is
|
|
//! stubbed: the mask of bound channels, the hold, the hysteresis and the fatigue are the shipping
|
|
//! decoder's, so what this test measures is the scene's bindings and the layer, with a readout
|
|
//! that never names a macro and cannot see the game.
|
|
//!
|
|
//! The rotation walks all twenty-two channels while a scene binds at most six, so most bursts lean
|
|
//! on a channel that is masked out of the decision; that is the honest case and deliberately the
|
|
//! weak one, because a masked burst leaves the bound channels tied and the group falls back on its
|
|
//! own tie rule. The burst is 100 ms, longer than the group's rise and shorter than its hold, so
|
|
//! exactly one decision comes out of each.
|
|
|
|
use flybrain_core::decoder::PopulationDecoder;
|
|
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
|
use flybrain_core::ordered::NumberMap;
|
|
use flybrain_gb::pokemon_red::PokemonRedReward;
|
|
use flybrain_gb::{
|
|
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons,
|
|
};
|
|
use flysim::config::Config;
|
|
use flysim::macros::{MacroLayer, macro_layer};
|
|
use flysim::snapshot::MacroMode;
|
|
|
|
/// `constants/map_constants.asm`.
|
|
const PALLET_TOWN: u32 = 0x00;
|
|
const REDS_HOUSE_1F: u32 = 0x25;
|
|
const REDS_HOUSE_2F: u32 = 0x26;
|
|
const OAKS_LAB: u32 = 0x28;
|
|
const VIRIDIAN_CITY: u32 = 0x01;
|
|
const ROUTE_2: u32 = 0x0d;
|
|
/// Kept for the record of what the fly used to reach first: before the objective pointed at the
|
|
/// errand, the mart's door was the only door out of Viridian the macros could find.
|
|
#[allow(dead_code)]
|
|
const VIRIDIAN_MART: u32 = 0x2a;
|
|
/// Route 2's southern forest gate and the forest north of it, which is rung 9's own road.
|
|
const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32;
|
|
const VIRIDIAN_FOREST: u32 = 0x33;
|
|
/// Pewter City's Pokemon Center, which is the room the rung-10 nurse loop was inside.
|
|
const PEWTER_POKECENTER: u32 = 0x3a;
|
|
/// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
|
|
const MUSEUM_2F: u32 = 0x35;
|
|
/// Pewter City and its two museum floors and gym: the rung-10 loop's whole world.
|
|
const PEWTER_CITY: u32 = 0x02;
|
|
const MUSEUM_1F: u32 = 0x34;
|
|
const PEWTER_GYM: u32 = 0x36;
|
|
const PEWTER_MART: u32 = 0x38;
|
|
const ROUTE_3: u32 = 0x0e;
|
|
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter
|
|
/// (`macros::geography`, and rung 10's own road).
|
|
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
|
|
|
|
/// `constants/event_constants.asm`, by bit index: the parcel picked up at the mart, and the parcel
|
|
/// delivered to Oak. The save that stalled has the first and not the second.
|
|
const EVENT_GOT_OAKS_PARCEL: u16 = 57;
|
|
const EVENT_OAK_GOT_PARCEL: u16 = 56;
|
|
/// `EVENT_GOT_POKEDEX`, rung 7, which comes out of the same script as the delivery.
|
|
///
|
|
/// Kept beside its sibling now that the delivery is measured rather than asserted: the two are one
|
|
/// script, so a test that ever asserts one will want the other.
|
|
#[allow(dead_code)]
|
|
const EVENT_GOT_POKEDEX: u16 = 37;
|
|
|
|
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
|
|
|
|
/// Frames of one channel per burst: long enough for one decision, short enough not to be a hold.
|
|
const BURST_MS: f64 = 100.0;
|
|
|
|
/// The seed the bench uses, so a failure here and a bench row are the same run.
|
|
const SEED: u32 = 20_260_916;
|
|
|
|
/// The hot population's rate, against [`REST`] for every other one.
|
|
///
|
|
/// A normalized score of 1.6, which is far enough above the rest for the group's 1.05 hysteresis
|
|
/// to be beatable in one decision and still a rate a real population reaches.
|
|
const HOT: f64 = 16.0;
|
|
|
|
/// Every other population's rate, which is also what the stub calibrates against, so a channel
|
|
/// that is not the hot one scores exactly 1.0.
|
|
const REST: f64 = 10.0;
|
|
|
|
/// Bursts the stub leans on one channel before it moves on.
|
|
///
|
|
/// The shape of the real readout rather than a number picked to pass: the exclusive group commits
|
|
/// to a winner, hysteresis keeps it while the field is within 5%, and `fatigue_gain` is what
|
|
/// eventually moves it on — "not a tie-breaker; it is the rotation period" (`docs/readout.md`).
|
|
/// Three holds is that rotation, coarsely.
|
|
const HOLDS_PER_SLOT: usize = 3;
|
|
|
|
/// The stub's rates for one burst: one macro population hot, the rest at their calibration rate.
|
|
///
|
|
/// The readout says "this population is firing" and nothing else — it never names a macro, never
|
|
/// reads the scene and does not know which channels are on the pad. Everything between that and a
|
|
/// macro starting is the shipping decoder and the shipping layer.
|
|
fn rates(hot: Option<&str>) -> NumberMap {
|
|
let mut rates = NumberMap::new();
|
|
for channel in flybrain_gb::macro_channels("pokemon-red") {
|
|
rates.set(channel, REST);
|
|
}
|
|
for bucket in 0..8 {
|
|
rates.set(&format!("command_{bucket}"), REST);
|
|
}
|
|
if let Some(channel) = hot {
|
|
rates.set(channel, HOT);
|
|
}
|
|
rates
|
|
}
|
|
|
|
fn rom() -> Option<Vec<u8>> {
|
|
let path = std::env::var_os("FLY_ROM")?;
|
|
match std::fs::read(&path) {
|
|
Ok(bytes) => Some(bytes),
|
|
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
|
|
}
|
|
}
|
|
|
|
fn emulator(rom: &[u8]) -> Emulator {
|
|
Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
|
.expect("binjgb should accept the cartridge")
|
|
}
|
|
|
|
/// The cartridge, the adapter beside it, and the layer under test.
|
|
struct Run {
|
|
gb: Emulator,
|
|
adapter: PokemonRedReward,
|
|
layer: MacroLayer,
|
|
/// The readout under test: the shipping decoder, fed by hand.
|
|
decoder: PopulationDecoder,
|
|
/// The macro channels, in the decoder's own order, for the rotation.
|
|
channels: Vec<&'static str>,
|
|
ms: f64,
|
|
/// Brain clock of the next burst.
|
|
next_burst: f64,
|
|
/// Which burst this is, i.e. which channel the stub readout is leaning on.
|
|
burst: usize,
|
|
hold_ms: f64,
|
|
/// Every map the run has been on, in order of arrival.
|
|
route: Vec<u32>,
|
|
/// How many times each macro has started, for the failure message and the record.
|
|
started: std::collections::BTreeMap<&'static str, u32>,
|
|
/// Runs of consecutive frames in which `TALK`'s channel was on the pad — a *facing window*,
|
|
/// the only chance the fly has to press A at what it is looking at.
|
|
talk_windows: u32,
|
|
talk_on_pad: bool,
|
|
/// A channel to lean on instead of the rotation, for a test that needs one macro pressed.
|
|
///
|
|
/// The rotation is the honest driver and every other test uses it; this is for the one question
|
|
/// that is about a *script* rather than about which button the fly picks.
|
|
force_hot: Option<&'static str>,
|
|
/// The smallest overworld pad each map ever dealt, by map id.
|
|
///
|
|
/// A pad of one button is a fly with one thing it can do: on 2026-09-17 the south gate of
|
|
/// Viridian Forest dealt `GO FRONTIER` and nothing else for four hours
|
|
/// (`infra/docs/macros-traps.md` row 32). Recorded per map rather than asserted here, because
|
|
/// which maps a rotation visits is not this harness's claim.
|
|
pads: std::collections::BTreeMap<u32, usize>,
|
|
/// Whether `ATTACK`'s channel was ever on the pad while the scene was a battle, and the
|
|
/// smallest battle pad the run ever dealt. Row 34's own two numbers
|
|
/// (`infra/docs/macros-traps.md`): a turn with no move left with PP took `ATTACK` off the pad
|
|
/// and the party list it fell into dealt one button.
|
|
move_button_on_battle_pad: bool,
|
|
battle_pad: Option<usize>,
|
|
/// Whether `BACK` was ever on the pad in a battle with **no list open** (section 12.9).
|
|
///
|
|
/// The rung-9 trap: between turns there is nothing to back out of, so the press completes
|
|
/// where the fly stands and the turn does not move. `false` is the assertion.
|
|
back_without_a_list: bool,
|
|
/// Where a `BACK` or a `NEXT` on a battle frame was dealt, as `scene/sub-state`.
|
|
///
|
|
/// The bool above says a rule broke; this says on which frame, which is the difference
|
|
/// between a pad rule to fix and a scene the detector cannot name.
|
|
battle_back_where: std::collections::BTreeSet<String>,
|
|
battle_next_where: std::collections::BTreeSet<String>,
|
|
/// Whether `THROW BALL` ever started at a species the party already held (section 12.9).
|
|
threw_at_a_held_species: bool,
|
|
/// Whether one battle pad ever held both `NEXT` and `BACK` (section 12.10).
|
|
///
|
|
/// The pair is the trap: `NEXT` is the A that advances text, `BACK` is the B that leaves a
|
|
/// list, and a pad with both has two buttons that undo each other with nothing else changing.
|
|
next_and_back_on_one_pad: bool,
|
|
/// Whether `NEXT` was ever on the pad while a battle menu was accepting input.
|
|
///
|
|
/// The live v0.4.2 shape: `NEXT` on the top-level menu was an A press on FIGHT, so it opened
|
|
/// the move list that `BACK` closed again -- 1264 starts against 1241 in 71 hours.
|
|
next_on_a_menu_accepting_input: bool,
|
|
/// The longest chain of battle macro starts that alternated `NEXT`, `BACK`, `NEXT`, `BACK`.
|
|
///
|
|
/// Two is an accident of the rotation; the live log did it for seventy-one hours. Only the
|
|
/// previous battle start has to be kept to measure the chain.
|
|
longest_next_back_alternation: u32,
|
|
alternation: u32,
|
|
last_battle_start: Option<&'static str>,
|
|
/// Macros started inside the battle that is running, and the worst any *finished* battle cost.
|
|
///
|
|
/// The claim the rung-9 loop breaks is that a battle **ends**, and that it ends on a bounded
|
|
/// number of macros rather than on however many holds the 2-cycle takes to fall out of.
|
|
macros_this_battle: u32,
|
|
worst_battle_macros: u32,
|
|
battles_entered: u32,
|
|
battles_ended: u32,
|
|
was_in_battle: bool,
|
|
/// Maps on whose *overworld* pad `GO OBJECTIVE` was ever bound.
|
|
///
|
|
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
|
|
/// north is whatever `GO FRONTIER` stumbles into.
|
|
objective_on_pad: std::collections::BTreeSet<u32>,
|
|
/// Maps on whose *overworld* pad `MENU` was ever bound (section 12.11).
|
|
///
|
|
/// The rung-10 trap: `MENU` opened the start menu and that scene's `BACK` closed it again, 82
|
|
/// starts each in thirty brain minutes inside one building. `MENU` is on no pad at all now, so
|
|
/// this set is empty -- and on v0.4.3 it holds every overworld map the run stood on.
|
|
menu_on_pad: std::collections::BTreeSet<u32>,
|
|
/// Overworld pads that bound nothing at all, by map: section 13.1's never-empty rule, which
|
|
/// since 12.11 rests on the way out rather than on an unconditional `MENU`.
|
|
empty_overworld_pads: std::collections::BTreeSet<u32>,
|
|
/// How many times each macro finished `blocked`, for the `THROW BALL` residual.
|
|
///
|
|
/// v0.4.3 on the cartridge: `THROW BALL` 63 starts, 63 `blocked` -- the bag had not drawn and
|
|
/// the step read the battle menu's cursor instead (section 12.11).
|
|
blocked: std::collections::BTreeMap<&'static str, u32>,
|
|
/// `NAME@scene/sub-state` for every macro that reported `blocked`: *where* it gave up.
|
|
blocked_where: std::collections::BTreeSet<String>,
|
|
/// Macros started while the fly was still on the map it resumed on.
|
|
macros_on_the_first_map: u32,
|
|
/// How many times each macro started while the fly was still on that map, by name.
|
|
///
|
|
/// The total is the wrong measure for a room the fly is meant to *leave*: a run that leaves it
|
|
/// and then fights a gym answers `YES` in the gym's own boxes, which is the fly playing the
|
|
/// game. What row 41 is about is the presses spent in the room.
|
|
started_on_the_first_map: std::collections::BTreeMap<&'static str, u32>,
|
|
/// The longest chain of macro starts that alternated `MENU`, `BACK`, `MENU`, `BACK`.
|
|
longest_menu_back_alternation: u32,
|
|
menu_alternation: u32,
|
|
last_start: Option<&'static str>,
|
|
/// Frames the run spent in a `Scene::Dialog`, and of those, frames a readable YES/NO prompt
|
|
/// was open (section 12.12).
|
|
///
|
|
/// The two numbers that name row 41: 62,804 of the hunt's 71,673 frames were one text box, and
|
|
/// the survey found the **prompt** on one frame of every forty-six. A `NEXT` and a `YES` that
|
|
/// are the same press live in the difference.
|
|
dialog_frames: u32,
|
|
prompt_frames: u32,
|
|
/// Whether `NEXT` was ever on the pad while a readable YES/NO prompt was open.
|
|
///
|
|
/// 12.10's rule in a dialog: an A press at a two-option box confirms the option the cursor is
|
|
/// on, which is what `YES` is, so the two are one press under two names. `false` is the claim.
|
|
next_on_a_prompt: bool,
|
|
/// Whether `TALK` was ever on the pad while the fly faced a nurse the party had no use for.
|
|
///
|
|
/// The door into the ring (section 12.12). `false` is the claim.
|
|
talk_at_a_rested_nurse: bool,
|
|
/// `BACK` starts on a frame the scene detector called `dialog` or `unknown` (section 12.13).
|
|
///
|
|
/// The same attribution `examples/trap_hunt.rs` makes, so the number is comparable with the
|
|
/// residual it comes from: **189** `BACK` in a text box on map `0x02` in twenty brain
|
|
/// minutes, and 678 starts in the 47 live minutes. `BACK` is on no overworld and no dialog
|
|
/// pad, so every one of them was dealt by `Unknown`.
|
|
back_in_a_box: u32,
|
|
/// Frames where the scene was `unknown`, nothing was drawn, and the pad was **not** empty.
|
|
///
|
|
/// Section 12.13's claim, and it is zero: a frame of the overworld the cartridge is driving
|
|
/// has nothing an A or a B press can do, so it deals nothing and the fly waits.
|
|
unknown_pads_with_no_box: u32,
|
|
/// Maps on whose overworld pad `TALK` was ever bound: where the fly stood facing somebody
|
|
/// this run has not talked to.
|
|
///
|
|
/// Rung 11 is BOULDER BADGE and its place is a *person* in map `0x36`, so "the fly reaches
|
|
/// the leader" is this map being in the set: the fly inside the gym, in front of one of its
|
|
/// people, with the press that talks to them on the pad. Whether it presses is the fly's
|
|
/// (section 12). Read off the shipping pad rather than recomputed, so it cannot disagree
|
|
/// with what the fly was actually offered.
|
|
talk_on_pad_by_map: std::collections::BTreeSet<u32>,
|
|
/// `TALK` starts by map, for the record beside it.
|
|
talk_starts_by_map: std::collections::BTreeMap<u32, u32>,
|
|
/// `GO FRONTIER` starts by map, for the museum (section 12.14).
|
|
frontier_by_map: std::collections::BTreeMap<u32, u32>,
|
|
/// Where the macro that is running started, for the net-tiles measure below.
|
|
started_at: Option<(u32, u8, u8)>,
|
|
/// The longest chain of macros that **completed at a net of zero tiles**, and the names in it.
|
|
///
|
|
/// Section 12.2's rule, measured (`infra/docs/macros-traps.md` row 54): "a macro that
|
|
/// completes without moving because its precondition is already satisfied where the fly stands
|
|
/// is a trap". One is ordinary -- a `TALK`, a `NEXT`, a walk that ends where it began because
|
|
/// it arrived by turning -- and a *chain* of them is the loop: the after arm of the v0.4.5
|
|
/// hunt spent brain minutes 1.0 to 8.5 cycling `GO FRONTIER`, `GO HEAL` and `GO ROUTE` over
|
|
/// five tiles, `GO HEAL` 204 starts at a mean net of 0.0 and a mean reach of 0.0.
|
|
///
|
|
/// Counted over walks only, because the presses are supposed to stand still: a `YES` that
|
|
/// answers a box and a `MOVE 2` that picks a move both finish on the tile they started on and
|
|
/// neither is going anywhere.
|
|
net_zero_streak: u32,
|
|
worst_net_zero_streak: u32,
|
|
net_zero_chain: Vec<&'static str>,
|
|
worst_net_zero_chain: Vec<&'static str>,
|
|
}
|
|
|
|
impl Run {
|
|
/// Boot the intro with scripted presses, then hand the buttons to macros mode.
|
|
///
|
|
/// The button pattern is `tests/rom.rs`'s and the bench's: Start and A on a slow alternation,
|
|
/// because a held button is ignored, then B alone until the adapter reports a stable
|
|
/// dialogue-free overworld sample.
|
|
fn boot(rom: &[u8], mode: MacroMode) -> Self {
|
|
let mut gb = emulator(rom);
|
|
let mut adapter = PokemonRedReward::new();
|
|
let mut ms = 0.0;
|
|
for frame in 0..6_000u32 {
|
|
let mask = match frame % 32 {
|
|
0..=7 => buttons::START,
|
|
16..=23 => buttons::A,
|
|
_ => buttons::NONE,
|
|
};
|
|
gb.set_buttons(mask);
|
|
gb.run_frame().expect("a frame should complete");
|
|
ms += MS_PER_FRAME;
|
|
adapter.sample(&mut gb, ms);
|
|
if adapter.mode() == "OVERWORLD" && frame > 3_000 {
|
|
break;
|
|
}
|
|
}
|
|
for frame in 0..12_000u32 {
|
|
gb.set_buttons(if frame % 24 < 8 { buttons::B } else { buttons::NONE });
|
|
gb.run_frame().expect("a frame should complete");
|
|
ms += MS_PER_FRAME;
|
|
adapter.sample(&mut gb, ms);
|
|
if adapter.safe_for_snapshot() {
|
|
break;
|
|
}
|
|
}
|
|
assert!(adapter.safe_for_snapshot(), "the intro never settled (mode {})", adapter.mode());
|
|
assert_eq!(adapter.map_id(), Some(REDS_HOUSE_2F), "a cold boot ends in bed");
|
|
|
|
let channels = flybrain_gb::macro_channels("pokemon-red");
|
|
let preset = gameboy_decoder_config_with_macros(&channels);
|
|
let hold_ms = preset.macros.as_ref().expect("the preset has a macro group").hold_ms;
|
|
let mut decoder = PopulationDecoder::new(preset).expect("the preset is well formed");
|
|
// Calibrated on the resting rates, so a channel at `REST` scores exactly 1.0 and the hot
|
|
// one 1.6.
|
|
decoder.calibrate(&rates(None));
|
|
let mut config = Config::default();
|
|
config.loop_.game = "pokemon-red".to_string();
|
|
config.macros.mode = mode;
|
|
config.validate().expect("pokemon-red has a palette in macros mode");
|
|
let mut layer = macro_layer(&config, hold_ms, SEED).expect("a layer in a dealt mode");
|
|
let _ = layer.observe(&mut gb, &AdapterLedger(&adapter), ms);
|
|
let route = vec![REDS_HOUSE_2F];
|
|
Self {
|
|
gb,
|
|
adapter,
|
|
layer,
|
|
decoder,
|
|
channels,
|
|
ms,
|
|
next_burst: ms,
|
|
burst: 0,
|
|
hold_ms,
|
|
route,
|
|
started: std::collections::BTreeMap::new(),
|
|
talk_windows: 0,
|
|
talk_on_pad: false,
|
|
force_hot: None,
|
|
pads: std::collections::BTreeMap::new(),
|
|
move_button_on_battle_pad: false,
|
|
battle_pad: None,
|
|
back_without_a_list: false,
|
|
battle_back_where: std::collections::BTreeSet::new(),
|
|
battle_next_where: std::collections::BTreeSet::new(),
|
|
threw_at_a_held_species: false,
|
|
next_and_back_on_one_pad: false,
|
|
next_on_a_menu_accepting_input: false,
|
|
longest_next_back_alternation: 0,
|
|
alternation: 0,
|
|
last_battle_start: None,
|
|
macros_this_battle: 0,
|
|
worst_battle_macros: 0,
|
|
battles_entered: 0,
|
|
battles_ended: 0,
|
|
was_in_battle: false,
|
|
objective_on_pad: std::collections::BTreeSet::new(),
|
|
menu_on_pad: std::collections::BTreeSet::new(),
|
|
empty_overworld_pads: std::collections::BTreeSet::new(),
|
|
blocked: std::collections::BTreeMap::new(),
|
|
blocked_where: std::collections::BTreeSet::new(),
|
|
macros_on_the_first_map: 0,
|
|
started_on_the_first_map: std::collections::BTreeMap::new(),
|
|
longest_menu_back_alternation: 0,
|
|
dialog_frames: 0,
|
|
prompt_frames: 0,
|
|
next_on_a_prompt: false,
|
|
talk_at_a_rested_nurse: false,
|
|
back_in_a_box: 0,
|
|
unknown_pads_with_no_box: 0,
|
|
talk_on_pad_by_map: std::collections::BTreeSet::new(),
|
|
talk_starts_by_map: std::collections::BTreeMap::new(),
|
|
frontier_by_map: std::collections::BTreeMap::new(),
|
|
started_at: None,
|
|
net_zero_streak: 0,
|
|
worst_net_zero_streak: 0,
|
|
net_zero_chain: Vec::new(),
|
|
worst_net_zero_chain: Vec::new(),
|
|
menu_alternation: 0,
|
|
last_start: None,
|
|
}
|
|
}
|
|
|
|
/// Pick up a run from a `FLYSIM01` checkpoint instead of booting the intro.
|
|
///
|
|
/// The cartridge state and the reward ledger come out of the envelope; the readout does not,
|
|
/// because the point of this harness is a stub that knows nothing about the game. So this is
|
|
/// the release box's own world with a game-blind driver in it, which is exactly what a stall
|
|
/// on the stream has to be reproduced against.
|
|
fn resume(rom: &[u8], mode: MacroMode, checkpoint: &flysim::store::Checkpoint) -> Self {
|
|
Self::resume_with(rom, mode, checkpoint, false)
|
|
}
|
|
|
|
/// [`Run::resume`], with `predates_macro_roles` restoring a decoder state that has no baseline
|
|
/// for any macro role — the shape the release box restored on 2026-09-17.
|
|
fn resume_with(
|
|
rom: &[u8],
|
|
mode: MacroMode,
|
|
checkpoint: &flysim::store::Checkpoint,
|
|
predates_macro_roles: bool,
|
|
) -> Self {
|
|
let mut gb = emulator(rom);
|
|
let mut adapter = PokemonRedReward::new();
|
|
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
|
|
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
|
|
let channels = flybrain_gb::macro_channels("pokemon-red");
|
|
let preset = gameboy_decoder_config_with_macros(&channels);
|
|
let hold_ms = preset.macros.as_ref().expect("the preset has a macro group").hold_ms;
|
|
let mut decoder = PopulationDecoder::new(preset).expect("the preset is well formed");
|
|
decoder.calibrate(&rates(None));
|
|
let mut config = Config::default();
|
|
config.loop_.game = "pokemon-red".to_string();
|
|
config.macros.mode = mode;
|
|
config.validate().expect("pokemon-red has a palette in macros mode");
|
|
let mut layer = macro_layer(&config, hold_ms, SEED).expect("a layer in a dealt mode");
|
|
// **The live restore, in this harness's own units.** The release box calibrates at warm-up
|
|
// and then imports a checkpoint's decoder state; a checkpoint written before the macro
|
|
// roles existed carries no baseline for them, and an absent baseline reads as zero
|
|
// (`docs/readout.md`, "Channels added after a checkpoint was written").
|
|
//
|
|
// The checkpoint's *own* decoder state cannot be used for this: its baselines are the live
|
|
// network's population rates and this readout is a stub feeding synthetic ones, so mixing
|
|
// them measures neither. What reproduces the defect faithfully is the same state shape in
|
|
// the stub's units — calibrated on the stub's rates, with every macro role's baseline
|
|
// removed — which is exactly what the live box restored.
|
|
if predates_macro_roles {
|
|
let mut predates = decoder.export_state();
|
|
for channel in &channels {
|
|
predates.baseline.remove(channel);
|
|
}
|
|
decoder.import_state(&predates).expect("a state from before the macro roles");
|
|
}
|
|
let ms = 0.0;
|
|
let _ = layer.observe(&mut gb, &AdapterLedger(&adapter), ms);
|
|
let route = vec![adapter.map_id().unwrap_or(u32::MAX)];
|
|
Self {
|
|
gb,
|
|
adapter,
|
|
layer,
|
|
decoder,
|
|
channels,
|
|
ms,
|
|
next_burst: ms,
|
|
burst: 0,
|
|
hold_ms,
|
|
route,
|
|
started: std::collections::BTreeMap::new(),
|
|
talk_windows: 0,
|
|
talk_on_pad: false,
|
|
force_hot: None,
|
|
pads: std::collections::BTreeMap::new(),
|
|
move_button_on_battle_pad: false,
|
|
battle_pad: None,
|
|
back_without_a_list: false,
|
|
battle_back_where: std::collections::BTreeSet::new(),
|
|
battle_next_where: std::collections::BTreeSet::new(),
|
|
threw_at_a_held_species: false,
|
|
next_and_back_on_one_pad: false,
|
|
next_on_a_menu_accepting_input: false,
|
|
longest_next_back_alternation: 0,
|
|
alternation: 0,
|
|
last_battle_start: None,
|
|
macros_this_battle: 0,
|
|
worst_battle_macros: 0,
|
|
battles_entered: 0,
|
|
battles_ended: 0,
|
|
was_in_battle: false,
|
|
objective_on_pad: std::collections::BTreeSet::new(),
|
|
menu_on_pad: std::collections::BTreeSet::new(),
|
|
empty_overworld_pads: std::collections::BTreeSet::new(),
|
|
blocked: std::collections::BTreeMap::new(),
|
|
blocked_where: std::collections::BTreeSet::new(),
|
|
macros_on_the_first_map: 0,
|
|
started_on_the_first_map: std::collections::BTreeMap::new(),
|
|
longest_menu_back_alternation: 0,
|
|
dialog_frames: 0,
|
|
prompt_frames: 0,
|
|
next_on_a_prompt: false,
|
|
talk_at_a_rested_nurse: false,
|
|
back_in_a_box: 0,
|
|
unknown_pads_with_no_box: 0,
|
|
talk_on_pad_by_map: std::collections::BTreeSet::new(),
|
|
talk_starts_by_map: std::collections::BTreeMap::new(),
|
|
frontier_by_map: std::collections::BTreeMap::new(),
|
|
started_at: None,
|
|
net_zero_streak: 0,
|
|
worst_net_zero_streak: 0,
|
|
net_zero_chain: Vec::new(),
|
|
worst_net_zero_chain: Vec::new(),
|
|
menu_alternation: 0,
|
|
last_start: None,
|
|
}
|
|
}
|
|
|
|
fn map(&self) -> u32 {
|
|
self.adapter.map_id().unwrap_or(u32::MAX)
|
|
}
|
|
|
|
/// One of pokered's event flags, by bit index (`pokemon_red::symbols::events`).
|
|
///
|
|
/// The ladder's rank cannot answer for a rung earned out of order — it is the *maximum* over
|
|
/// satisfied rungs, so delivering the parcel while Viridian City is already stood on leaves it
|
|
/// at 8 — so a test about the delivery asks the bit the delivery sets.
|
|
fn event(&mut self, bit: u16) -> bool {
|
|
let address = flybrain_gb::pokemon_red::symbols::ram::wEventFlags + (bit >> 3);
|
|
self.gb.read_wram(address) & (1 << (bit & 7)) != 0
|
|
}
|
|
|
|
/// Whether a battle menu is accepting input, i.e. the fly's own turn
|
|
/// (`docs/design/macros.md` section 12.6). Read through the same seam the macros read.
|
|
fn own_turn(&mut self) -> bool {
|
|
use flybrain_gb::pokemon_red::macros::state::GameState;
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let mut state =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
|
state.battle().is_some_and(|battle| battle.own_turn && !battle.forced_switch)
|
|
}
|
|
|
|
/// Whether a battle list is open and accepting input: the move list, the party list or the bag.
|
|
///
|
|
/// Section 12.9's question, through the same seam the macros read: `BACK` is a button where
|
|
/// there is a list to leave and nowhere else.
|
|
/// Whether the pad on this frame is a **battle** pad.
|
|
///
|
|
/// The scene the palette was dealt for, not `wIsInBattle`. The two differ on the `$ff` frame a
|
|
/// lost battle passes through and on a Safari or tutorial battle, where `state::battle` reads
|
|
/// nothing and `scene::detect` answers `Unknown` -- whose pad is `NEXT, BACK` by contract
|
|
/// (section 12.2, row 9: B is what leaves the Pokédex, the trainer card and OPTION). Asking
|
|
/// the cartridge byte instead accused that row of a battle rule it is not under.
|
|
fn battle_pad(&self) -> bool {
|
|
self.layer.scene_name() == "battle" || self.layer.scene_name() == "battle-switch"
|
|
}
|
|
|
|
/// The battle sub-state the seam reports, as a word, for the record and the failure message.
|
|
fn battle_sub_state(&mut self) -> &'static str {
|
|
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let mut state =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
|
match state.battle() {
|
|
None => "no-battle",
|
|
Some(battle) => match battle.menu {
|
|
BattleMenu::Main { .. } => "main",
|
|
BattleMenu::Moves { cursor: Some(_), .. } => "moves",
|
|
BattleMenu::Moves { cursor: None, .. } => "moves-unplaceable",
|
|
BattleMenu::Party { .. } if battle.forced_switch => "party-forced",
|
|
BattleMenu::Party { .. } => "party",
|
|
BattleMenu::Bag { .. } => "bag",
|
|
BattleMenu::None => "between-turns",
|
|
},
|
|
}
|
|
}
|
|
|
|
fn battle_list_open(&mut self) -> bool {
|
|
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let mut state =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
|
state.battle().is_some_and(|battle| {
|
|
matches!(
|
|
battle.menu,
|
|
BattleMenu::Moves { cursor: Some(_), .. }
|
|
| BattleMenu::Party { .. }
|
|
| BattleMenu::Bag { .. }
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Whether the Pokémon on the other side is a species the party already holds.
|
|
///
|
|
/// The internal species index on both sides, which is the one numbering they share
|
|
/// (`docs/design/macros-wram.md`).
|
|
fn enemy_species_in_party(&mut self) -> bool {
|
|
use flybrain_gb::pokemon_red::macros::state::GameState;
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let mut state =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
|
let Some(species) =
|
|
state.battle().and_then(|battle| battle.enemy).map(|enemy| enemy.species)
|
|
else {
|
|
return false;
|
|
};
|
|
species != 0 && state.party().mons.iter().any(|mon| mon.species == species)
|
|
}
|
|
|
|
/// The rung the macros are walking toward, as the objective reads it.
|
|
fn objective_map(&mut self) -> Option<u8> {
|
|
use flybrain_gb::pokemon_red::macros::MacroState;
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let mut state =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
|
state.objective().map(|objective| objective.map)
|
|
}
|
|
|
|
/// `wIsInBattle`: 0 out of battle, 1 wild, 2 a trainer.
|
|
fn in_battle(&mut self) -> u8 {
|
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle)
|
|
}
|
|
|
|
/// `wPartyCount`, which is what taking a starter changes.
|
|
fn party(&mut self) -> u8 {
|
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wPartyCount)
|
|
}
|
|
|
|
/// The wallet, in whole units, through the same accessor the macros read it with.
|
|
fn money(&mut self) -> u32 {
|
|
flybrain_gb::pokemon_red::state::money(&mut self.gb)
|
|
}
|
|
|
|
/// How many of `item` the bag holds, summed over its stacks.
|
|
///
|
|
/// Kept unused for now: the purchase the section-13 test would have counted with it is
|
|
/// unit-proven rather than ROM-proven (see `go_shop_enters_the_mart_once_and_walks_to_its_counter`
|
|
/// and rows 35 and 36 of `infra/docs/macros-traps.md`), and deleting the reader would mean
|
|
/// writing it again with the fix.
|
|
#[allow(dead_code)]
|
|
fn bag_count(&mut self, item: u8) -> u32 {
|
|
flybrain_gb::pokemon_red::state::bag(&mut self.gb)
|
|
.iter()
|
|
.filter(|stack| stack.id == item)
|
|
.map(|stack| u32::from(stack.count))
|
|
.sum()
|
|
}
|
|
|
|
/// What the open counter sells, through the same gated accessor the macros read it with.
|
|
///
|
|
/// Empty off a counter, and that gate is the point: `wItemList` is a scratch buffer nobody
|
|
/// clears, so a non-empty answer *is* "a real mart is open" -- which is a stronger test than
|
|
/// the scene name. Measured on the cartridge 2026-09-17: a text box in Viridian City with a
|
|
/// stale `wTextBoxID` reads as `Scene::Shop` and this still answers `[]`.
|
|
fn counter_stock(&mut self) -> Vec<u8> {
|
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut self.gb);
|
|
flybrain_gb::pokemon_red::macros::MacroState::shop_stock(&mut state)
|
|
}
|
|
|
|
/// Whether the two-option YES/NO box is drawn, through the accessor the palette reads
|
|
/// (section 12.12).
|
|
fn yes_no_prompt(&mut self) -> bool {
|
|
flybrain_gb::pokemon_red::state::yes_no_prompt(&mut self.gb)
|
|
}
|
|
|
|
/// Whether the fly faces a Pokemon Center nurse with a party that does not need her.
|
|
fn rested_nurse(&mut self) -> bool {
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let mut state =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
|
flybrain_gb::pokemon_red::macros::palette::rested_nurse(&mut state)
|
|
}
|
|
|
|
/// Whether every party member reads full HP with no status: the end of a `HEAL`.
|
|
fn party_rested(&mut self) -> bool {
|
|
let party = flybrain_gb::pokemon_red::state::party(&mut self.gb);
|
|
!party.mons.is_empty()
|
|
&& party.mons.iter().all(|mon| {
|
|
mon.species == 0
|
|
|| (mon.hp == mon.max_hp
|
|
&& mon.status == flybrain_gb::pokemon_red::macros::state::Status::Healthy)
|
|
})
|
|
}
|
|
|
|
/// One frame of the sim loop's order, with the stub rates in place of the brain.
|
|
fn frame(&mut self) {
|
|
// The stub readout: one burst per hold, one macro population hot in it, rotating.
|
|
let bursting = self.ms < self.next_burst + BURST_MS;
|
|
let hot = bursting.then(|| {
|
|
self.force_hot
|
|
.unwrap_or(self.channels[(self.burst / HOLDS_PER_SLOT) % self.channels.len()])
|
|
});
|
|
if self.ms >= self.next_burst + self.hold_ms {
|
|
self.next_burst = self.ms;
|
|
self.burst += 1;
|
|
}
|
|
// The loop's own two lines: the scene's bound channels are the mask, and the decoder
|
|
// picks among exactly those (`docs/design/macros.md` section 12).
|
|
let bound = self.layer.bound_channels();
|
|
// Only the fly's *own* turn. Battle text between turns is a pad of one `NEXT` by design
|
|
// (the v0.2.4 deadlock fix), so counting it would measure the wrong thing.
|
|
if self.own_turn() {
|
|
self.move_button_on_battle_pad |=
|
|
bound.iter().any(|channel| channel.starts_with("macro_move_"));
|
|
let dealt = bound.len();
|
|
self.battle_pad = Some(self.battle_pad.map_or(dealt, |seen| seen.min(dealt)));
|
|
}
|
|
// Section 12.9, on the cartridge: `BACK` belongs to a list. A battle frame with no list
|
|
// accepting input and `BACK` on the pad is the rung-9 trap itself.
|
|
if self.battle_pad() {
|
|
let back = bound.iter().any(|channel| channel.as_str() == "macro_back");
|
|
if back {
|
|
let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state());
|
|
self.battle_back_where.insert(where_);
|
|
}
|
|
if back && !self.battle_list_open() {
|
|
self.back_without_a_list = true;
|
|
}
|
|
}
|
|
// Section 12.10, on the cartridge: no battle pad holds a pair that undoes itself, and
|
|
// `NEXT` is on no frame with a cursor accepting input. The second is the stronger of the
|
|
// two -- the live pair was split across two sub-states, so no single pad held both.
|
|
if self.battle_pad() {
|
|
let next = bound.iter().any(|channel| channel.as_str() == "macro_next");
|
|
let back = bound.iter().any(|channel| channel.as_str() == "macro_back");
|
|
self.next_and_back_on_one_pad |= next && back;
|
|
if next {
|
|
let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state());
|
|
self.battle_next_where.insert(where_);
|
|
}
|
|
if next && self.own_turn() {
|
|
self.next_on_a_menu_accepting_input = true;
|
|
}
|
|
}
|
|
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
|
|
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
|
|
if talk_bound && !self.talk_on_pad {
|
|
self.talk_windows += 1;
|
|
}
|
|
self.talk_on_pad = talk_bound;
|
|
let active =
|
|
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
|
|
let (mask, started, blocked, done) = {
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
|
|
let started: Vec<&'static str> = decision
|
|
.events
|
|
.iter()
|
|
.filter(|event| event.outcome.is_none())
|
|
.map(|event| event.name)
|
|
.collect();
|
|
let blocked: Vec<&'static str> = decision
|
|
.events
|
|
.iter()
|
|
.filter(|event| {
|
|
event.outcome.is_some_and(|outcome| outcome.as_str() == "blocked")
|
|
})
|
|
.map(|event| event.name)
|
|
.collect();
|
|
let done: Vec<&'static str> = decision
|
|
.events
|
|
.iter()
|
|
.filter(|event| {
|
|
event.outcome.is_some_and(|outcome| outcome.as_str() == "done")
|
|
})
|
|
.map(|event| event.name)
|
|
.collect();
|
|
(decision.mask, started, blocked, done)
|
|
};
|
|
// Row 54's own measure, taken before the starts below so that a macro that finishes and
|
|
// another that starts on the same frame are not confused for one another.
|
|
for name in done {
|
|
let walk = name.starts_with("GO ");
|
|
let net = match self.started_at {
|
|
Some((map, x, y)) if map == self.map() => {
|
|
u32::from(x.abs_diff(self.tile().0)) + u32::from(y.abs_diff(self.tile().1))
|
|
}
|
|
// Another map is the biggest move a macro can make.
|
|
_ => u32::MAX,
|
|
};
|
|
if walk && net == 0 {
|
|
self.net_zero_streak += 1;
|
|
self.net_zero_chain.push(name);
|
|
if self.net_zero_streak > self.worst_net_zero_streak {
|
|
self.worst_net_zero_streak = self.net_zero_streak;
|
|
self.worst_net_zero_chain = self.net_zero_chain.clone();
|
|
}
|
|
} else if walk {
|
|
self.net_zero_streak = 0;
|
|
self.net_zero_chain.clear();
|
|
}
|
|
}
|
|
for name in blocked {
|
|
*self.blocked.entry(name).or_insert(0) += 1;
|
|
let sub = self.battle_sub_state();
|
|
let list = self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wListMenuID);
|
|
self.blocked_where.insert(format!(
|
|
"{name}@{}/{sub}/list={list:#04x}",
|
|
self.layer.scene_name()
|
|
));
|
|
}
|
|
let in_battle_now = self.in_battle() != 0;
|
|
let on_a_battle_pad = self.battle_pad();
|
|
for name in started {
|
|
// Section 12.9's other half: a ball is never thrown at a species the party holds.
|
|
if name == "THROW BALL" && self.enemy_species_in_party() {
|
|
self.threw_at_a_held_species = true;
|
|
}
|
|
// Section 12.10's own signature, as the live event log printed it: `NEXT start/done,
|
|
// BACK start/done`, every hold, for seventy-one hours. Measured as the longest chain
|
|
// of consecutive battle starts drawn from those two alone and strictly alternating.
|
|
if in_battle_now {
|
|
self.macros_this_battle += 1;
|
|
}
|
|
if on_a_battle_pad {
|
|
let two = name == "NEXT" || name == "BACK";
|
|
self.alternation = match self.last_battle_start {
|
|
Some(last) if two && (last == "NEXT" || last == "BACK") && last != name => {
|
|
self.alternation.max(1) + 1
|
|
}
|
|
_ if two => 1,
|
|
_ => 0,
|
|
};
|
|
self.longest_next_back_alternation =
|
|
self.longest_next_back_alternation.max(self.alternation);
|
|
self.last_battle_start = Some(name);
|
|
}
|
|
// Section 12.11's own signature, as the live event log printed it: `MENU
|
|
// start/done, BACK start/done`, every hold, for thirty brain minutes inside one
|
|
// building. The pair is split across two *scenes*, so this is measured over every
|
|
// start rather than over a battle's.
|
|
let two = name == "MENU" || name == "BACK";
|
|
self.menu_alternation = match self.last_start {
|
|
Some(last) if two && (last == "MENU" || last == "BACK") && last != name => {
|
|
self.menu_alternation.max(1) + 1
|
|
}
|
|
_ if two => 1,
|
|
_ => 0,
|
|
};
|
|
self.longest_menu_back_alternation =
|
|
self.longest_menu_back_alternation.max(self.menu_alternation);
|
|
self.last_start = Some(name);
|
|
if self.route.len() == 1 {
|
|
self.macros_on_the_first_map += 1;
|
|
*self.started_on_the_first_map.entry(name).or_insert(0) += 1;
|
|
}
|
|
*self.started.entry(name).or_insert(0) += 1;
|
|
// Section 12.13: which of the two `Unknown`s a `BACK` was dealt on, attributed
|
|
// exactly as the trap hunt attributes it so the numbers can be compared.
|
|
if name == "BACK" && matches!(self.layer.scene_name(), "dialog" | "unknown") {
|
|
self.back_in_a_box += 1;
|
|
}
|
|
if name == "GO FRONTIER" {
|
|
let map = self.map();
|
|
*self.frontier_by_map.entry(map).or_insert(0) += 1;
|
|
}
|
|
if name == "TALK" {
|
|
let map = self.map();
|
|
*self.talk_starts_by_map.entry(map).or_insert(0) += 1;
|
|
}
|
|
let (x, y) = self.tile();
|
|
self.started_at = Some((self.map(), x, y));
|
|
}
|
|
self.gb.set_buttons(mask as u8);
|
|
self.gb.run_frame().expect("a frame should complete");
|
|
self.ms += MS_PER_FRAME;
|
|
let ms = self.ms;
|
|
self.adapter.sample(&mut self.gb, ms);
|
|
{
|
|
let ledger = AdapterLedger(&self.adapter);
|
|
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
|
|
}
|
|
// Battle boundaries, after the frame: what a battle cost in macros, and whether it ended.
|
|
let now_in_battle = self.in_battle() != 0;
|
|
match (self.was_in_battle, now_in_battle) {
|
|
(false, true) => {
|
|
self.battles_entered += 1;
|
|
self.macros_this_battle = 0;
|
|
self.last_battle_start = None;
|
|
self.alternation = 0;
|
|
}
|
|
(true, false) => {
|
|
self.battles_ended += 1;
|
|
self.worst_battle_macros =
|
|
self.worst_battle_macros.max(self.macros_this_battle);
|
|
self.macros_this_battle = 0;
|
|
}
|
|
_ => {}
|
|
}
|
|
self.was_in_battle = now_in_battle;
|
|
let map = self.map();
|
|
if self.route.last() != Some(&map) && map != u32::MAX {
|
|
self.route.push(map);
|
|
}
|
|
// Section 12.12's two frame counters and its two pad rules, asked of the frame the pad
|
|
// was dealt for -- the dialog's, exactly as 12.10 asks the battle rules of the battle's.
|
|
if self.layer.scene_name() == "dialog" {
|
|
self.dialog_frames += 1;
|
|
let dealt = self.layer.bound_channels();
|
|
if self.yes_no_prompt() {
|
|
self.prompt_frames += 1;
|
|
if dealt.iter().any(|channel| channel.as_str() == "macro_next") {
|
|
self.next_on_a_prompt = true;
|
|
}
|
|
}
|
|
}
|
|
// Section 12.13's claim, per frame: an `Unknown` with nothing drawn deals nothing.
|
|
if self.layer.scene_name() == "unknown"
|
|
&& !flybrain_gb::pokemon_red::state::text_box(&mut self.gb).open
|
|
&& !self.layer.bound_channels().is_empty()
|
|
{
|
|
self.unknown_pads_with_no_box += 1;
|
|
}
|
|
if self.layer.scene_name() == "overworld"
|
|
&& self.rested_nurse()
|
|
&& self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk")
|
|
{
|
|
self.talk_at_a_rested_nurse = true;
|
|
}
|
|
// The pad the *next* frame will choose from, for the maps that have been accused of
|
|
// dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box
|
|
// is a pad of its own.
|
|
if map != u32::MAX && self.layer.scene_name() == "overworld" {
|
|
let dealt = self.layer.bound_channels();
|
|
if dealt.iter().any(|channel| channel.as_str() == "macro_go_objective") {
|
|
self.objective_on_pad.insert(map);
|
|
}
|
|
if dealt.iter().any(|channel| channel.as_str() == "macro_menu") {
|
|
self.menu_on_pad.insert(map);
|
|
}
|
|
// Standing in front of somebody this run has not talked to, which on the gym's own
|
|
// map is standing in front of one of the people rung 11's place names.
|
|
if dealt.iter().any(|channel| channel.as_str() == "macro_talk") {
|
|
self.talk_on_pad_by_map.insert(map);
|
|
}
|
|
if dealt.is_empty() {
|
|
self.empty_overworld_pads.insert(map);
|
|
}
|
|
let dealt = dealt.len();
|
|
let seen = self.pads.entry(map).or_insert(dealt);
|
|
*seen = (*seen).min(dealt);
|
|
}
|
|
}
|
|
|
|
/// The tile the fly is standing on, as the macro layer reads it.
|
|
fn tile(&mut self) -> (u8, u8) {
|
|
(
|
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wXCoord),
|
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wYCoord),
|
|
)
|
|
}
|
|
|
|
/// Drive until `done`, or panic with where it got to.
|
|
fn drive_until(&mut self, what: &str, budget: u32, mut done: impl FnMut(&mut Self) -> bool) {
|
|
for frame in 0..budget {
|
|
self.frame();
|
|
if done(self) {
|
|
eprintln!(
|
|
"{what}: reached after {frame} frames ({:.1} brain minutes), route {:?}, \
|
|
macros {:?}",
|
|
self.ms / 60_000.0,
|
|
self.route,
|
|
self.layer.counts()
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
panic!(
|
|
"{what}: not reached in {budget} frames (map {:#04x}, rank {}, tile {:?}, route {:?}, \
|
|
macros {:?}, by name {:?}, smallest pads {:?})",
|
|
self.map(),
|
|
self.adapter.progress().rank,
|
|
(
|
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wXCoord),
|
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wYCoord)
|
|
),
|
|
self.route,
|
|
self.layer.counts(),
|
|
self.started,
|
|
self.pads
|
|
);
|
|
}
|
|
}
|
|
|
|
macro_rules! skip_without_rom {
|
|
() => {
|
|
match rom() {
|
|
Some(rom) => rom,
|
|
None => {
|
|
eprintln!("skipped: FLY_ROM is not set");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/// From the bedroom, macros mode with a stub readout leaves the house and earns Oak's lab.
|
|
///
|
|
/// The three rungs the catalog knows places for at this end of the ladder, in order: the ground
|
|
/// floor (rung 2, a passage on this map), Pallet Town (rung 3, `LAST_MAP` doormats, which count
|
|
/// because the objective is outdoors) and Oak's lab (rung 4, a Pallet Town warp that names the
|
|
/// map). The readout knows nothing about any of it — it leans on one population at a time and
|
|
/// rotates — so what carries the run is which buttons each scene puts on the pad and what the
|
|
/// macros behind them do. If a place were missing from the catalog, or a scene bound the wrong
|
|
/// set, this would wander instead.
|
|
///
|
|
/// **The starter is not part of this test, and that is a finding rather than a trim**
|
|
/// (2026-09-16, recorded in `infra/docs/macros-bench.md`). A game-blind stub does not press `TALK`
|
|
/// in front of a Pokéball on purpose: three of them were tried on the cartridge under the drive
|
|
/// rules section 12 replaced and none took a starter — every slot equal pressed A 615 times at one
|
|
/// shelf and never walked away; one hot slot per burst wandered (367 `GO OUT`s, one `TALK`); a
|
|
/// committing rotation did the same over five times the frames (4,904 macros, `TALK` once). What
|
|
/// a stub cannot do is exactly what section 12 hands to the mushroom body, so the claim belongs to
|
|
/// a run with a brain in it — the bench's macros arm — and not here.
|
|
#[test]
|
|
fn macros_mode_leaves_the_house_and_reaches_oaks_lab() {
|
|
let rom = skip_without_rom!();
|
|
let mut run = Run::boot(&rom, MacroMode::Macros);
|
|
|
|
run.drive_until("the ground floor", 12_000, |run| run.map() == REDS_HOUSE_1F);
|
|
run.drive_until("pallet town", 24_000, |run| run.map() == PALLET_TOWN);
|
|
run.drive_until("oak's lab", 60_000, |run| run.map() == OAKS_LAB);
|
|
// The rung is not the arrival. A map rung needs three stable unscripted samples and the fly
|
|
// lands *on* the lab's own door, which the adapter's gate rejects by design
|
|
// (`docs/design/room-escape.md` section 2), so the rung is earned once another macro has
|
|
// walked it into the room.
|
|
run.drive_until("the lab's own rung", 24_000, |run| run.adapter.progress().rank >= 4);
|
|
|
|
assert!(run.route.contains(&REDS_HOUSE_1F), "through the ground floor: {:?}", run.route);
|
|
assert!(run.route.contains(&PALLET_TOWN), "and out into the town: {:?}", run.route);
|
|
assert_eq!(run.map(), OAKS_LAB);
|
|
assert!(run.adapter.progress().rank >= 4, "the ladder recorded it: {:?}", run.adapter.progress());
|
|
assert_eq!(run.party(), 0, "nothing here takes a starter; see this test's own note");
|
|
|
|
// Every macro that ran was one the scene had put on the pad and the readout had held up: the
|
|
// counts are the proof that the two halves met.
|
|
let counts = run.layer.counts();
|
|
assert!(counts.started > 0, "no macro ever started: {counts:?}");
|
|
assert_eq!(counts.started, counts.finished() + u64::from(run.layer.running().is_some()));
|
|
eprintln!(
|
|
"macros mode reached Oak's lab in {:.1} brain minutes on {} macros: {counts:?}",
|
|
run.ms / 60_000.0,
|
|
counts.started
|
|
);
|
|
}
|
|
|
|
/// The checkpoint a resumed test starts from, or `None` to skip.
|
|
///
|
|
/// `.local/` is not tracked and a checkpoint is not a fixture: it is the state the release box was
|
|
/// really in. So this reads the same variables `examples/trap_hunt.rs` does and skips without one.
|
|
fn checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
let path = std::env::var_os("FLY_TRAP_CHECKPOINT")
|
|
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))?;
|
|
Some(
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
|
)
|
|
}
|
|
|
|
/// From the Viridian stall's own checkpoint, macros mode's walks complete and reach a door.
|
|
///
|
|
/// **The stall** (2026-09-17, `infra/docs/macros-traps.md`): eight hours on rung 8, 164 walk starts
|
|
/// and 165 timeouts in the last 40 KB of the event log, and not one macro finishing. Every walk
|
|
/// spent its whole 600-frame cap oscillating over *two* tiles with a net displacement of zero,
|
|
/// because `path::route`'s closest-approach answer plus a re-plan after every tile traded the fly
|
|
/// between two tiles across the walkable window's edge; the cap then read as "closing" and cost a
|
|
/// strike instead of excluding anything, so the same walk came back ten brain minutes later for
|
|
/// ever.
|
|
///
|
|
/// **The criterion is that walks complete**, not that they arrive anywhere in particular. The fly
|
|
/// chooses the macro and this readout is a rotation that knows nothing about the game, so which
|
|
/// target it spends a hold on is not this test's business. What is: that a chosen walk can finish,
|
|
/// that finishing is the common case rather than the frame cap, and that the walks carry the fly
|
|
/// far enough across Viridian City to reach one of its doors — the mart
|
|
/// (`maps::VIRIDIAN_MART`), which is a warp on the far side of the city from the checkpoint's tile.
|
|
///
|
|
/// **Route 2 is not asserted, and that is a measurement rather than a trim.** `maps::ROUTE_2` is
|
|
/// the connection north and rung 9's first hop, so it is what `GO OBJECTIVE` aims at; with these
|
|
/// fixes the fly reaches the mart in 3.2 brain minutes and then **never leaves the city** in seven
|
|
/// brain hours (1,500,000 frames, 30,946 macros, 30,921 of them `done`). It ends every run in a
|
|
/// text box two tiles from where it got stuck, and the `Dialog` pad's `NEXT`, `YES` and `NO` — one
|
|
/// A press and one B press — do not advance it. That is the next trap, not this one, and it is
|
|
/// recorded as such in `infra/docs/macros-traps.md`; asserting Route 2 here would be a test that
|
|
/// fails on a trap it does not measure.
|
|
///
|
|
/// Gated on `FLY_ROM` *and* on a checkpoint, and skips cleanly without either, because `.local/` is
|
|
/// not tracked and a checkpoint is not a fixture — it is the state the release box was really in:
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
/// FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-timeouts.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn macros_mode_completes_its_walks_from_the_stalled_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
assert_eq!(run.map(), VIRIDIAN_CITY, "the checkpoint is the one the stream stalled on");
|
|
|
|
// Off the map the stall was on, by whichever door the fly's own choices find. Bounded at
|
|
// 120,000 frames, which is 33 brain minutes; the measured run needs 1,600 of them.
|
|
//
|
|
// **Not a particular door any more.** When this test was written the objective pointed north
|
|
// through a shut gate, so the mart was the reachable one; since the objective is the lowest
|
|
// *unsatisfied* rung it points at Oak's lab, and the fly leaves southward by Route 1. Which
|
|
// door is not this test's claim — that the walks carry the fly off the map at all is.
|
|
run.drive_until("off the map the stall was on", 120_000, |run| {
|
|
run.route.iter().any(|map| *map != VIRIDIAN_CITY)
|
|
});
|
|
assert!(run.route.len() > 1, "route {:?}", run.route);
|
|
|
|
// Then keep going, because the claim is about the *outcomes* rather than the arrival: the
|
|
// stall's signature was every walk spending its whole cap and nothing finishing, and one
|
|
// macro's worth of frames cannot show that either way.
|
|
for _ in 0..30_000 {
|
|
run.frame();
|
|
}
|
|
|
|
let counts = run.layer.counts();
|
|
assert!(counts.started > 0, "no macro ever started: {counts:?}");
|
|
// The stall's signature, and what this test exists to keep out: every walk timing out and
|
|
// nothing finishing at all.
|
|
assert!(counts.done > 0, "no macro completed: {counts:?}");
|
|
assert!(
|
|
counts.done > counts.timeout,
|
|
"finishing is the common case, not the frame cap: {counts:?}"
|
|
);
|
|
// On a bounded number of macros rather than by grinding: a budget that scales with the plan
|
|
// means one walk covers what took dozens of ten-second ones.
|
|
assert!(counts.started < 1_000, "macros spent leaving the map: {counts:?}");
|
|
eprintln!(
|
|
"off the stalled map in {:.1} brain minutes on {} macros ({counts:?}), route {:?}, \
|
|
by name {:?}",
|
|
run.ms / 60_000.0,
|
|
counts.started,
|
|
run.route,
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// The rung-9 forest checkpoint, or `None` to skip.
|
|
///
|
|
/// Its own variable rather than `FLY_TRAP_CHECKPOINT`, because the two checkpoint tests above
|
|
/// assert the map their checkpoint is on: one envelope cannot be both.
|
|
fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
let path = std::env::var_os("FLY_FOREST_CHECKPOINT")?;
|
|
Some(
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
|
)
|
|
}
|
|
|
|
/// From the rung-9 forest checkpoint: the turns advance, the battles end, and no two buttons on a
|
|
/// battle pad undo each other.
|
|
///
|
|
/// **What was live** (2026-09-22, `infra/docs/macros-traps.md`): rank 9, VIRIDIAN FOREST, 69 hours
|
|
/// on the rung, the ratchet's three attempts spent, and since the restart the macro starts were
|
|
/// `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 — the event log repeating `RUN blocked, BACK
|
|
/// start, BACK done`. `BACK` on a battle frame with no list open completes in a handful of frames
|
|
/// without changing anything, so the roll landed on it most holds and the turn did not move; and
|
|
/// `THROW BALL` spent balls on the species already in the party, each catch opening a nickname
|
|
/// screen the pad cannot leave.
|
|
///
|
|
/// **What was live again** (2026-09-22, thirty-five minutes after v0.4.2): the same rung, the same
|
|
/// forest, and the macro starts since the restart were `NEXT` 1264, `BACK` 1241, `THROW BALL` 5 and
|
|
/// `GO WARP` 3, the event log alternating `NEXT start/done, BACK start/done` every hold. The pair
|
|
/// was split across two sub-states of one turn, so 12.9's rule held and the loop survived it:
|
|
/// `NEXT` on the top-level menu was an A press on FIGHT, which **opened** the move list, and `BACK`
|
|
/// on the move list **closed** it again. Section 12.10 takes `NEXT` off every pad with a cursor
|
|
/// accepting input and gives the bag its own; `MOVE 1` is the backstop the top-level menu keeps.
|
|
///
|
|
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that it
|
|
/// ends on a bounded number of macros, that the fly's own presses are what ends it, and that none
|
|
/// of the three traps is on the pad any more. Which move it picks and whether it wins are the
|
|
/// fly's.
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
/// FLY_FOREST_CHECKPOINT=.local/checkpoints/release-forest-rung9.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = forest_checkpoint() else {
|
|
eprintln!("skipped: no FLY_FOREST_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
let from = run.map();
|
|
assert!(
|
|
from == VIRIDIAN_FOREST || from == ROUTE_2 || from == VIRIDIAN_FOREST_SOUTH_GATE,
|
|
"the checkpoint is the one the stream stalled on, got map {from:#04x}"
|
|
);
|
|
// Rung 9 is stood on, so the objective is rung 10 — Pewter City — and the road there is north
|
|
// through the forest (`macros::geography`, asserted as hops in that module's own tests).
|
|
assert_eq!(
|
|
run.objective_map(),
|
|
Some(0x02),
|
|
"the objective is Pewter City, whatever errand is in front of it"
|
|
);
|
|
|
|
// Every battle this run passes through, and how it left.
|
|
let (mut battles, mut ended, mut in_battle) = (0u32, 0u32, run.in_battle() != 0);
|
|
let mut own_turns = 0u32;
|
|
for _ in 0..240_000 {
|
|
run.frame();
|
|
let now = run.in_battle() != 0;
|
|
match (in_battle, now) {
|
|
(false, true) => battles += 1,
|
|
(true, false) => ended += 1,
|
|
_ => {}
|
|
}
|
|
in_battle = now;
|
|
if now && run.own_turn() {
|
|
own_turns += 1;
|
|
}
|
|
if run.route.contains(&VIRIDIAN_FOREST_NORTH_GATE) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let counts = run.layer.counts();
|
|
eprintln!(
|
|
"from map {from:#04x} in {:.1} brain minutes: route {:?}, battles {battles} ({ended} ended), own-turn frames {own_turns}, macros {counts:?}, by name {:?}, objective on the pad on {:?}",
|
|
run.ms / 60_000.0,
|
|
run.route,
|
|
run.started,
|
|
run.objective_on_pad
|
|
);
|
|
|
|
// The traps, as assertions on the cartridge.
|
|
eprintln!(
|
|
"`BACK` in a battle was dealt on {:?}; `NEXT` on {:?}",
|
|
run.battle_back_where, run.battle_next_where
|
|
);
|
|
assert!(
|
|
!run.back_without_a_list,
|
|
"`BACK` was on a battle pad with no list open: {:?}",
|
|
run.battle_back_where
|
|
);
|
|
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
|
|
eprintln!("blocked where: {:?}", run.blocked_where);
|
|
// Section 12.11, the other half of `THROW BALL`: it reaches the bag. On v0.4.3 every start of
|
|
// it was `blocked` -- 63 of 63, mean sixty-nine frames -- because the step that walks the bag
|
|
// list was allowed to read the battle menu's cursor while the bag was still drawing.
|
|
assert_eq!(
|
|
run.blocked.get("THROW BALL"),
|
|
None,
|
|
"`THROW BALL` reported blocked: {:?}",
|
|
run.blocked
|
|
);
|
|
// Section 12.10, the two halves of it.
|
|
assert!(
|
|
!run.next_and_back_on_one_pad,
|
|
"a battle pad held both `NEXT` and `BACK`: two buttons that undo each other"
|
|
);
|
|
assert!(
|
|
!run.next_on_a_menu_accepting_input,
|
|
"`NEXT` was on the pad while a battle menu was accepting input, where A opens rather \
|
|
than advances"
|
|
);
|
|
// And the shape the log had, rather than only the pads it came from. Two in a row is the
|
|
// rotation happening to deal the pair; the live run did it for seventy-one hours.
|
|
assert!(
|
|
run.longest_next_back_alternation < 4,
|
|
"`NEXT`/`BACK` alternated {} times in a row",
|
|
run.longest_next_back_alternation
|
|
);
|
|
|
|
// The turn moves: the fly's own battle presses happen, and a battle this run entered or
|
|
// resumed finishes.
|
|
assert!(own_turns > 0, "the fly never got a turn");
|
|
let battle_presses: u32 = run
|
|
.started
|
|
.iter()
|
|
.filter(|(name, _)| name.starts_with("MOVE ") || **name == "THROW BALL")
|
|
.map(|(_, n)| *n)
|
|
.sum();
|
|
assert!(battle_presses > 0, "no move and no ball: {:?}", run.started);
|
|
assert!(ended > 0, "no battle ever ended: {battles} entered");
|
|
// **Every battle that started, finished**, and each one on a bounded number of macros. That is
|
|
// the claim the 2-cycle breaks, and the way it breaks it is the opposite of a slow fight: the
|
|
// battle never leaves the fly's own turn at all, so the count grows with the *run*. On v0.4.2
|
|
// from this same checkpoint the trap hunt spent all twenty of its brain minutes -- 71,673
|
|
// frames, 1,489 macros, 73 of 73 windows flagged -- inside **one** battle that never ended,
|
|
// with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu.
|
|
//
|
|
// The bound is **seven hundred** and was four; the number it is generous against is **450**
|
|
// and was 275, re-measured after section 12.11 put `battle_entry`'s `ITEM` and `PKMN` the
|
|
// right way round. Before that `SWITCH` opened the bag and `ITEM` and `THROW BALL` opened the
|
|
// party list, so neither could ever finish: every turn was an attack or nothing. A fly that
|
|
// can switch and heal fights longer, which is a longer battle and not a stalled one -- what
|
|
// the assertion is for is the difference between a battle that ends and a cycle that does
|
|
// not, and 3 of 3 ended here.
|
|
assert!(
|
|
run.battles_ended + 1 >= run.battles_entered,
|
|
"{} battles entered and only {} left: a battle was entered and never got out",
|
|
run.battles_entered,
|
|
run.battles_ended
|
|
);
|
|
assert!(
|
|
run.worst_battle_macros > 0 && run.worst_battle_macros < 700,
|
|
"the worst battle cost {} macros over {} that ended",
|
|
run.worst_battle_macros,
|
|
run.battles_ended
|
|
);
|
|
eprintln!(
|
|
"battles: {} entered, {} ended, worst {} macros; longest NEXT/BACK alternation {}",
|
|
run.battles_entered,
|
|
run.battles_ended,
|
|
run.worst_battle_macros,
|
|
run.longest_next_back_alternation
|
|
);
|
|
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
|
|
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
|
|
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this
|
|
// harness's game-blind rotation it is a fact about the rotation and not about the macros,
|
|
// because every bound channel wins about equally often.
|
|
let backs = run.started.get("BACK").copied().unwrap_or(0);
|
|
eprintln!(
|
|
"`BACK` was {backs} of {} macro starts, none of them with no list open",
|
|
counts.started
|
|
);
|
|
|
|
// North is the road, and the gate is the first hop. Asserted when the run reaches it and
|
|
// reported when it does not: which way the fly walks is its own, and the objective being on
|
|
// the pad is what this harness can hold it to.
|
|
if run.route.contains(&VIRIDIAN_FOREST_NORTH_GATE) {
|
|
eprintln!("the fly left the forest north through the gate at 0x2f");
|
|
} else {
|
|
eprintln!(
|
|
"the forest's north gate was not reached in this run; route {:?}",
|
|
run.route
|
|
);
|
|
}
|
|
assert!(
|
|
run.objective_on_pad.contains(&VIRIDIAN_FOREST)
|
|
|| run.objective_on_pad.contains(&ROUTE_2)
|
|
|| run.objective_on_pad.contains(&VIRIDIAN_FOREST_NORTH_GATE),
|
|
"GO OBJECTIVE was on no overworld pad on the road north: {:?}",
|
|
run.objective_on_pad
|
|
);
|
|
}
|
|
|
|
/// From the stalled checkpoint, nothing the map draws is read as a text box.
|
|
///
|
|
/// **What this was written to settle** (2026-09-17, `infra/docs/macros-traps.md`): with the walk
|
|
/// fixes in, 41,012 frames of a 71,673-frame run read as `Scene::Dialog`, and two readings fitted
|
|
/// that — a real text box the pad cannot advance, or a `dialog` reading off map tiles that merely
|
|
/// look like a box. The dialog branch has two halves, `wFontLoaded`'s bit and the drawn border, so
|
|
/// the way to tell is to count the frames on which they disagree.
|
|
///
|
|
/// The answer was that the detector is right and the corner test was thin. Over 43,004 frames of
|
|
/// the real brain driving from this checkpoint: **0** frames had the font flag set with the four
|
|
/// corners drawn and the rest of the border missing — every `dialog` frame was a fully drawn
|
|
/// `TextBoxBorder` — while **315** frames had all four corners drawn with the font flag clear,
|
|
/// because the frame's tile ids ($79, $7b, $7d, $7e) are ordinary ground in the overworld
|
|
/// tilesets. So the flag was the only thing keeping a map out of the scene, and `waiting` now
|
|
/// reads the whole border instead of its corners.
|
|
///
|
|
/// This test holds the first half of that on the cartridge. The count of the second is printed
|
|
/// rather than asserted: it is a fact about which tiles the fly happens to walk past, and a run
|
|
/// that never sees one has not proved anything either way.
|
|
#[test]
|
|
fn the_map_is_never_read_as_a_text_box_from_the_stalled_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
let (mut real, mut fooled, mut no_corners, mut corners_only) = (0u32, 0u32, 0u32, 0u32);
|
|
for _ in 0..60_000 {
|
|
run.frame();
|
|
let text = flybrain_gb::pokemon_red::state::text_box(&mut run.gb);
|
|
let (corners, border) = flybrain_gb::pokemon_red::state::dialog_border(&mut run.gb);
|
|
match (text.open, corners, border) {
|
|
(true, true, true) => real += 1,
|
|
(true, true, false) => fooled += 1,
|
|
(true, false, _) => no_corners += 1,
|
|
(false, true, _) => corners_only += 1,
|
|
(false, false, _) => {}
|
|
}
|
|
}
|
|
eprintln!(
|
|
"dialog branch over 60,000 frames: real box {real}, fooled {fooled}, \
|
|
font without corners {no_corners}, corners without the font {corners_only}"
|
|
);
|
|
assert_eq!(
|
|
fooled, 0,
|
|
"a frame the detector would call `dialog` on four map tiles: the border was not drawn"
|
|
);
|
|
// `waiting` is never true without the WRAM flag, whatever the map is drawing.
|
|
assert!(
|
|
!flybrain_gb::pokemon_red::state::text_box(&mut run.gb).waiting
|
|
|| flybrain_gb::pokemon_red::state::text_box(&mut run.gb).open
|
|
);
|
|
}
|
|
|
|
/// From the stalled checkpoint, the objective is Oak, and the fly walks to him and presses A.
|
|
///
|
|
/// **Row 29** (`infra/docs/macros-traps.md`). With the objective pointing at Oak's *lab*, a place
|
|
/// that is only a map, `GO OBJECTIVE` arrived on the lab's own doormat and called that arriving;
|
|
/// `GO OUT` walked straight back out; the pair ran every three brain seconds at the door, live, for
|
|
/// most of a morning. A rung earned by a conversation is not a map — it is somebody to stand next
|
|
/// to — so the catalog names the *person*, `GO OBJECTIVE` arrives beside him facing him, and `TALK`
|
|
/// is on the pad on the next hold. While that person is on this map, untalked and unexcluded, the
|
|
/// ways out are not on the pad at all: there is nothing for the bounce to bounce on.
|
|
///
|
|
/// This is the chain on the cartridge with the game-blind rotation driving it: the parcel carried
|
|
/// and undelivered, the lab reached by way of Pallet Town rather than through the shut road, and
|
|
/// `TALK` taken in the room the rung is in. The delivery event itself is measured rather than
|
|
/// asserted; the comment at that assertion has the numbers and the reason.
|
|
#[test]
|
|
fn macros_mode_reaches_the_errand_and_takes_its_press_from_the_stalled_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
assert_eq!(run.map(), VIRIDIAN_CITY, "the checkpoint is the one the stream stalled on");
|
|
assert!(run.event(EVENT_GOT_OAKS_PARCEL), "the parcel is carried");
|
|
assert!(!run.event(EVENT_OAK_GOT_PARCEL), "and undelivered, which is what shuts the road");
|
|
|
|
run.drive_until("oak's lab", 120_000, |run| run.map() == OAKS_LAB);
|
|
// **The delivery itself is no longer asserted, and that is a measurement rather than a trim.**
|
|
//
|
|
// It was, when this test was written: `EVENT_OAK_GOT_PARCEL` at 27,167 frames on 504 macros.
|
|
// That held for exactly one trajectory. The battle sub-states of section 12.6 changed what the
|
|
// fly does in Route 1's grass -- it now chooses a move instead of pressing A at the text -- and
|
|
// the rotation's phase inside the lab moved with it: from the same checkpoint the fly reaches
|
|
// the lab, presses `TALK` ninety-four times, and does not deliver in **900,000 frames** (16
|
|
// brain hours).
|
|
//
|
|
// Which is the sibling tests' own finding in a new place: a game-blind rotation does not aim,
|
|
// and the delivery is one A press at one particular person. What the *macros* can be held to is
|
|
// that the fly is sent at the errand, gets there, and has the press available -- and that is
|
|
// what this test holds. The delivery belongs to a run with a brain in it and a reward for the
|
|
// rung. The gap the trajectory exposed is named in `infra/docs/macros-traps.md` row 31.
|
|
run.drive_until("a talk in the lab", 240_000, |run| {
|
|
run.map() == OAKS_LAB && run.started.get("TALK").is_some_and(|count| *count > 0)
|
|
});
|
|
assert_eq!(run.map(), OAKS_LAB, "in the room the rung is in");
|
|
assert!(run.started.get("TALK").is_some_and(|count| *count > 0), "{:?}", run.started);
|
|
let counts = run.layer.counts();
|
|
assert!(counts.done > counts.timeout, "the walks completed: {counts:?}");
|
|
assert!(counts.started < 3_000, "macros spent reaching the errand: {counts:?}");
|
|
eprintln!(
|
|
"the errand reached and pressed in {:.1} brain minutes on {} macros ({counts:?}), \
|
|
route {:?}, by name {:?}",
|
|
run.ms / 60_000.0,
|
|
counts.started,
|
|
run.route,
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// From the stalled checkpoint, the objective is the errand and the fly walks to it.
|
|
///
|
|
/// **The gate** (2026-09-17, `infra/docs/macros-traps.md` row 28). The fly stepped north out of
|
|
/// (18, 10) in Viridian City into a still sprite at (18, 9); the cartridge showed a box reading
|
|
/// "This is private property!", took the joypad and walked it back one tile. Every macro that hit
|
|
/// it ended `Done` — a scene change, which records nothing — so the same step was taken once per
|
|
/// hold for eight hours.
|
|
///
|
|
/// **What shut the road is two rungs the ladder had skipped.** The save has
|
|
/// `EVENT_GOT_OAKS_PARCEL` set and `EVENT_OAK_GOT_PARCEL` clear: the parcel is carried and
|
|
/// undelivered, so rungs 6 (the delivery) and 7 (the Pokédex) are unearned — while the rank reads
|
|
/// **8**, because the rank is the *maximum* over satisfied rungs and Viridian City had been stood
|
|
/// on. `objective` was `rank + 1`, which aimed the fly at Viridian Forest, north, through a gate
|
|
/// the cartridge keeps shut until Oak has his parcel. It is the lowest *unsatisfied* rung now,
|
|
/// which is Oak's lab, two maps south — and nothing in the macros knows any of that: the catalog
|
|
/// already held the place and the arithmetic was picking the wrong rung out of it.
|
|
///
|
|
/// So this asserts what the change makes true and what the cartridge can be held to: the fly
|
|
/// **leaves Viridian City and reaches Oak's lab**, where the errand is, on a bounded number of
|
|
/// macros.
|
|
///
|
|
/// **It takes the town's errands on the way, and that is section 13 working.** When this test was
|
|
/// written the lab was 1.4 brain minutes and 55 macros away; the mart and the Pokémon Center are
|
|
/// now ahead of the rung's place in `GO OBJECTIVE`, so the measured route is Viridian, the centre,
|
|
/// the mart, Route 1, Pallet Town, the lab — 13.5 brain minutes and 788 macros, of which 777
|
|
/// completed. The bound is what this test is for (the walks finish and the count is finite), not
|
|
/// the number itself.
|
|
///
|
|
/// **Route 2 is not asserted, and that is a measurement.** Delivering the parcel is one A press at
|
|
/// one particular person, and this readout is a rotation that knows nothing about the game: over
|
|
/// 600,000 frames — eleven brain hours — inside and around the lab it never pressed it, exactly as
|
|
/// the sibling test above records for the starter ("a game-blind stub does not press `TALK` in
|
|
/// front of a Pokéball on purpose"). That claim belongs to a run with a brain in it, and the road
|
|
/// north is behind it.
|
|
#[test]
|
|
fn the_objective_is_the_errand_and_macros_mode_walks_to_it() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
assert_eq!(run.map(), VIRIDIAN_CITY, "the checkpoint is the one the stream stalled on");
|
|
assert_eq!(run.adapter.progress().rank, 8, "rank 8, the maximum over the satisfied rungs");
|
|
assert!(run.event(EVENT_GOT_OAKS_PARCEL), "the parcel is carried");
|
|
assert!(!run.event(EVENT_OAK_GOT_PARCEL), "and undelivered, which is what shuts the road");
|
|
|
|
// South, not north: Route 1, Pallet Town, the lab.
|
|
run.drive_until("oak's lab", 120_000, |run| run.map() == OAKS_LAB);
|
|
assert!(run.route.contains(&PALLET_TOWN), "by way of the town: {:?}", run.route);
|
|
assert!(!run.route.contains(&ROUTE_2), "and not through the shut road: {:?}", run.route);
|
|
|
|
let counts = run.layer.counts();
|
|
assert!(counts.done > counts.timeout, "the walks completed: {counts:?}");
|
|
// Bounded rather than tight: the two errands of section 13 are ahead of the rung's place, so
|
|
// this is three buildings' worth of walking and not one town's.
|
|
assert!(counts.started < 1_500, "macros spent getting to the errand: {counts:?}");
|
|
eprintln!(
|
|
"the errand in {:.1} brain minutes on {} macros ({counts:?}), route {:?}, by name {:?}",
|
|
run.ms / 60_000.0,
|
|
counts.started,
|
|
run.route,
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// After a restore that added the macro roles, `TALK` wins a facing window.
|
|
///
|
|
/// **The live finding** (2026-09-17, after v0.3.6): forty-eight minutes in Oak's lab with `TALK`
|
|
/// never chosen once. The macro populations were firing — 27 to 145 Hz across the six roles — and
|
|
/// the decoder had no baseline for any of them, because `calibrate()` runs once at warm-up and the
|
|
/// live state was restored from a checkpoint written before the roles existed. An absent baseline
|
|
/// reads as zero, so the score `(rate + 1) / (baseline + 1)` becomes `rate + 1` and the group is
|
|
/// decided by raw rate: `macro_talk` at 41 Hz cannot beat `macro_frontier` at 54 in the one window
|
|
/// where `TALK` is on the pad at all.
|
|
///
|
|
/// So this drives the shipping loop from the checkpoint with a decoder state of exactly that shape
|
|
/// — calibrated, then every macro baseline removed, which is what the box restored — and counts
|
|
/// **facing windows**: runs of frames in which `TALK`'s channel is on the pad, the only chances
|
|
/// the fly gets. `TALK` is expected to win one of the first few, not one of the first hundred.
|
|
///
|
|
/// The stub's own rates are not the live ones and cannot be; what carries over is the *state shape*
|
|
/// and the rule that answers it. The live numbers are pinned in the unit tests and the `restore`
|
|
/// golden.
|
|
#[test]
|
|
fn talk_wins_a_facing_window_after_a_restore_that_added_the_macro_roles() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume_with(&rom, MacroMode::Macros, &checkpoint, true);
|
|
run.drive_until("a chosen TALK", 240_000, |run| {
|
|
run.started.get("TALK").is_some_and(|count| *count > 0)
|
|
});
|
|
assert!(
|
|
run.talk_windows > 0 && run.talk_windows <= 32,
|
|
"TALK won a facing window within a bounded number of them: {} windows, {:?}",
|
|
run.talk_windows,
|
|
run.started
|
|
);
|
|
eprintln!(
|
|
"TALK chosen after {} facing windows, {:.1} brain minutes, {:?}",
|
|
run.talk_windows,
|
|
run.ms / 60_000.0,
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// With the move list open on the cartridge, macros mode chooses a move and the turn proceeds.
|
|
///
|
|
/// **The live loop** (2026-09-17, rank 9, Viridian Forest, an hour and forty-one minutes): a wild
|
|
/// Kakuna, Bulbasaur at 8/28, the FIGHT list open with TACKLE showing 0 of 40 PP — and the pad was
|
|
/// one `NEXT`, every 268 brain milliseconds. `own_turn` was the top-level menu alone, so a frame
|
|
/// with the move list open read as *between turns*, whose pad is an A press on whatever the cursor
|
|
/// is sitting on. It pressed A at the move with no PP; the game said so; the box closed; the list
|
|
/// came back.
|
|
///
|
|
/// This drives the shipping loop from the checkpoint until a wild battle starts, presses A to open
|
|
/// the move list, and holds the three things that were wrong:
|
|
///
|
|
/// - the frame reads as the fly's turn, not as between turns;
|
|
/// - the pad over the open list is `ATTACK` and `BACK`, and never `NEXT`;
|
|
/// - `ATTACK` confirms a move that **has** PP, and the list closes: the turn proceeds.
|
|
///
|
|
/// The 0-PP-first-move case itself is pinned synthetically, in
|
|
/// `attack_over_an_open_move_list_chooses_a_move_that_has_pp` and
|
|
/// `attack_confirms_anyway_when_every_move_is_out_of_pp` — a fake can set a PP counter to zero and
|
|
/// a cartridge cannot be talked into it inside a test.
|
|
#[test]
|
|
fn macros_mode_chooses_a_move_from_an_open_list_on_the_cartridge() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
|
|
// A wild battle, by the fly's own walking: Route 1's grass is on the way to the errand.
|
|
run.drive_until("a wild battle", 240_000, |run| {
|
|
run.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0
|
|
});
|
|
|
|
// Advance the battle's opening text until the top-level menu is up, then open FIGHT. Raw
|
|
// presses rather than macros, because what is under test is the pad the *list* deals.
|
|
let mut opened = false;
|
|
for _ in 0..600 {
|
|
let menu = {
|
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut run.gb);
|
|
flybrain_gb::pokemon_red::macros::palette::battle_menu(&mut state)
|
|
};
|
|
// A *settled* list: one whose cursor the seam can place. The coordinates appear on the
|
|
// battle's opening frames before the engine has filled `wBattleMon*`, and that frame is
|
|
// not a choice the game is waiting for (`pokemon_red::state::battle`).
|
|
if matches!(
|
|
menu,
|
|
flybrain_gb::pokemon_red::macros::state::BattleMenu::Moves { cursor: Some(_), .. }
|
|
) {
|
|
opened = true;
|
|
break;
|
|
}
|
|
for phase in 0..16 {
|
|
run.gb.set_buttons(if phase < 8 { flybrain_gb::buttons::A } else { 0 });
|
|
run.gb.run_frame().expect("a frame should complete");
|
|
run.ms += MS_PER_FRAME;
|
|
}
|
|
let ms = run.ms;
|
|
run.adapter.sample(&mut run.gb, ms);
|
|
}
|
|
assert!(opened, "the move list never opened");
|
|
|
|
// What the scene and the pad say about it.
|
|
let scene = {
|
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut run.gb);
|
|
let state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState = &mut state;
|
|
state.scene()
|
|
};
|
|
assert!(
|
|
matches!(
|
|
scene,
|
|
flybrain_gb::pokemon_red::macros::state::Scene::Battle { own_turn: true, .. }
|
|
),
|
|
"the move list open is the fly's turn: {scene:?}"
|
|
);
|
|
let ledger = AdapterLedger(&run.adapter);
|
|
let _ = run.layer.observe(&mut run.gb, &ledger, run.ms);
|
|
let pad: Vec<String> =
|
|
run.layer.feed_palette().into_iter().map(|slot| slot.name).collect();
|
|
assert!(pad.iter().any(|name| name.starts_with("MOVE ")), "the list is chosen from: {pad:?}");
|
|
assert!(pad.iter().any(|name| name == "BACK"), "or backed out of: {pad:?}");
|
|
assert!(
|
|
!pad.iter().any(|name| name == "NEXT"),
|
|
"never an A press on whatever the cursor holds: {pad:?}"
|
|
);
|
|
|
|
// And `ATTACK` confirms a move that has PP, which closes the list: the turn proceeds.
|
|
let slot = {
|
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut run.gb);
|
|
flybrain_gb::pokemon_red::macros::palette::move_list(&mut state).and_then(|(cursor, _)| cursor)
|
|
}
|
|
.expect("a move to confirm");
|
|
let pp_before = run.gb.read_wram(
|
|
flybrain_gb::pokemon_red::symbols::ram::wBattleMonPP + u16::from(slot),
|
|
);
|
|
assert!(pp_before > 0, "the chosen move has PP: slot {slot} at {pp_before}");
|
|
|
|
// Lean on ATTACK's own channel so the script under test is the one that runs, from a clean
|
|
// decision: whatever the rotation was holding when the list opened is cancelled first, because
|
|
// a `BACK` already in flight would close the list before `ATTACK` could be offered.
|
|
let ms = run.ms;
|
|
let _ = run.layer.cancel(ms);
|
|
run.decoder.clear_holds(ms);
|
|
run.force_hot = Some("macro_move_1");
|
|
run.drive_until("a MOVE chosen", 6_000, |run| {
|
|
run.started.keys().any(|name| name.starts_with("MOVE "))
|
|
});
|
|
run.drive_until("the list closed", 6_000, |run| {
|
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut run.gb);
|
|
!matches!(
|
|
flybrain_gb::pokemon_red::macros::palette::battle_menu(&mut state),
|
|
flybrain_gb::pokemon_red::macros::state::BattleMenu::Moves { .. }
|
|
)
|
|
});
|
|
eprintln!(
|
|
"the move list closed on slot {slot} (PP was {pp_before}), macros {:?}, by name {:?}",
|
|
run.layer.counts(),
|
|
run.started
|
|
);
|
|
assert!(
|
|
run.started.keys().any(|name| name.starts_with("MOVE ")),
|
|
"a MOVE button is what closed it: {:?}",
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// From the gate-house stall's own checkpoint, macros mode leaves northward into the forest.
|
|
///
|
|
/// **The stall** (2026-09-17 17:32 UTC, `infra/docs/macros-traps.md` rows 32 and 33): rank 9
|
|
/// (Viridian Forest, next Pewter City) for four hours and eighteen minutes with the fly on map 50,
|
|
/// the ten-by-eight gate house between Route 2 and Viridian Forest. The pad was `GO FRONTIER` and
|
|
/// nothing else, and the last 40 KB of the event log was 175 `GO FRONTIER start`/`done` pairs —
|
|
/// one tile of walking each, one every 800 brain milliseconds. Two facts made it:
|
|
///
|
|
/// - the gate's two doormats are walkable ground the *reward* ledger can never record, because
|
|
/// `sample`'s payout gate rejects `wMovementFlags`' door and warp bits, so they were a frontier
|
|
/// nothing could retire and `GO FRONTIER` never exhausted;
|
|
/// - `geography::next_hop` had one node for `ROUTE_2`, whose two halves are not connected to each
|
|
/// other, so "the first hop to Pewter" from inside the gate was Route 2 *south* — the door the
|
|
/// fly had just come in by. `GO OBJECTIVE` aimed back out of it, `GO OUT` had no other tier, and
|
|
/// `GO WARP` — the north doors into the forest, which are the way to Pewter — was never on the
|
|
/// pad at all.
|
|
///
|
|
/// **What is asserted is the direction.** The first map the fly leaves the gate house for is
|
|
/// Viridian Forest, which is northward and toward the rung; before this branch it was Route 2,
|
|
/// southward, and the fly came straight back. And the gate house never deals a pad of one button
|
|
/// again, which is the loop's own signature. Both on a bounded number of macros: the measured run
|
|
/// leaves on **one macro in 155 frames**.
|
|
///
|
|
/// **The forest's north gate is not asserted, and that is a measurement rather than a trim.** With
|
|
/// this branch the fly crosses into the forest at once and plays it — 1,200,000 frames (5.6 brain
|
|
/// hours) from this checkpoint cover six maps and 20,595 macros, including a blackout to Pallet
|
|
/// Town and the walk back north — and it does not reach map 47. From about 600,000 frames on the
|
|
/// only macros that start are `ITEM`, `BACK` and `NEXT`: `ATTACK` does not start once more and
|
|
/// every overworld count freezes, which is a battle whose own turn the fly never ends. That is the
|
|
/// next trap, recorded as row 34 in `infra/docs/macros-traps.md`, and asserting Route 2's north
|
|
/// half here would be a test that fails on a trap it does not measure.
|
|
///
|
|
/// Gated on `FLY_ROM` and on a checkpoint whose map is the gate house, and skips cleanly without
|
|
/// either:
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
/// FLY_GATE_CHECKPOINT=.local/checkpoints/release-rank9-20260917T1732.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn macros_mode_leaves_the_forests_south_gate_northward() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = gate_checkpoint() else {
|
|
eprintln!("skipped: no FLY_GATE_CHECKPOINT / FLY_TRAP_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
if run.map() != VIRIDIAN_FOREST_SOUTH_GATE {
|
|
eprintln!(
|
|
"skipped: the checkpoint is on map {:#04x}, not the forest's south gate",
|
|
run.map()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Off the map the stall was on. Bounded at 24,000 frames, which is six brain minutes; the
|
|
// measured run needs 155 of them, because the north doors are seven tiles from the
|
|
// checkpoint's own tile and one walk covers that.
|
|
run.drive_until("off the gate house", 24_000, |run| {
|
|
run.route.iter().any(|map| *map != VIRIDIAN_FOREST_SOUTH_GATE)
|
|
});
|
|
assert_eq!(
|
|
run.route.first().copied(),
|
|
Some(VIRIDIAN_FOREST_SOUTH_GATE),
|
|
"route {:?}",
|
|
run.route
|
|
);
|
|
assert_eq!(
|
|
run.route.get(1).copied(),
|
|
Some(VIRIDIAN_FOREST),
|
|
"the first map out of the gate house is the forest, northward and toward the rung, not Route 2 behind it: route {:?}, by name {:?}",
|
|
run.route,
|
|
run.started
|
|
);
|
|
let counts = run.layer.counts();
|
|
assert!(counts.started <= 5, "macros spent leaving the gate house: {counts:?}");
|
|
|
|
// Then keep going, because the pad claim is about what the gate house deals *after* its
|
|
// frontier has been walked and its people talked to — which is the state four hours of stream
|
|
// was in, and which the session ledgers only reach by running.
|
|
for _ in 0..120_000 {
|
|
run.frame();
|
|
}
|
|
let gate_pad = run.pads.get(&VIRIDIAN_FOREST_SOUTH_GATE).copied().unwrap_or(0);
|
|
assert!(
|
|
gate_pad > 1,
|
|
"the gate house dealt a pad of {gate_pad} buttons: {:?}, by name {:?}",
|
|
run.pads,
|
|
run.started
|
|
);
|
|
|
|
let counts = run.layer.counts();
|
|
assert!(counts.done > 0, "no macro completed: {counts:?}");
|
|
eprintln!(
|
|
"out of the gate house northward on {} macros, smallest gate-house pad {gate_pad}, {:.1} brain minutes, route {:?}, {counts:?}, by name {:?}",
|
|
run.started.values().sum::<u32>(),
|
|
run.ms / 60_000.0,
|
|
run.route,
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// The checkpoint the gate-house test starts from, or `None` to skip.
|
|
///
|
|
/// Its own variable before `FLY_TRAP_CHECKPOINT`, because the two stalls on this rung have
|
|
/// different checkpoints and each test asserts the map its own stall was on. The test skips rather
|
|
/// than fails when the checkpoint it gets is the other one.
|
|
fn gate_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
let path = std::env::var_os("FLY_GATE_CHECKPOINT")
|
|
.or_else(|| std::env::var_os("FLY_TRAP_CHECKPOINT"))
|
|
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))?;
|
|
Some(
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
|
)
|
|
}
|
|
|
|
/// From the 0-PP battle's own checkpoint, the fly's own turn has a button that ends it.
|
|
///
|
|
/// **The stall** (`infra/docs/macros-traps.md` row 34, measured in Viridian Forest at 99.1 brain
|
|
/// minutes): a wild battle, a party of one, TACKLE / GROWL / LEECH SEED all at 0 PP, a potion in
|
|
/// the bag and a `RUN` the cartridge refuses. `ATTACK`'s precondition was `best_move(..).is_some()`
|
|
/// and `best_move` skips a move with no PP, so the button left the top-level menu — and the move
|
|
/// list, where `move_list_choice` confirms a slot anyway so that Struggle happens, is only ever
|
|
/// reached *through* FIGHT. What was left was `ITEM` over a bag it could only close and `NEXT` on
|
|
/// whatever the cursor held, which opens the party list, whose only bound button with one Pokémon
|
|
/// is `BACK`. 600,000 frames of it.
|
|
///
|
|
/// What is asserted: `ATTACK` is on the pad of the fly's own turn, the macro behind it **starts
|
|
/// and runs on the cartridge** from this state, that turn never deals one button, and the battle
|
|
/// ends — which is the whole claim, because a turn that can be ended is a battle that finishes one
|
|
/// way or the other. Which way is not asserted: with 6 of 34 HP against a trainer the measured run
|
|
/// faints and blacks out, and that is the game rather than the macro layer.
|
|
///
|
|
/// Gated on `FLY_ROM` and on a checkpoint in that state, which
|
|
/// `examples/scene_probe.rs` writes with `FLY_PROBE_CATCH=noattack FLY_PROBE_SAVE=…` — 99 brain
|
|
/// minutes of the stub from the gate-house checkpoint, the release box's own run carried forward.
|
|
/// `.local/` is not tracked, so this skips cleanly without it:
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
/// FLY_NOPP_CHECKPOINT=.local/checkpoints/release-nopp.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn macros_mode_ends_a_turn_with_no_move_left_on_the_cartridge() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = nopp_checkpoint() else {
|
|
eprintln!("skipped: no FLY_NOPP_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
if run.in_battle() == 0 {
|
|
eprintln!("skipped: the checkpoint is not in a battle");
|
|
return;
|
|
}
|
|
assert_eq!(run.party(), 1, "a party of one is half of what made the turn unanswerable");
|
|
|
|
// First the script, with the attack channel leaned on rather than rotated to. That is what
|
|
// `force_hot` is for in this harness -- the one question that is about a *script* rather than
|
|
// about which button the fly picks -- because a rotation may spend the whole turn on `NEXT`
|
|
// and prove nothing about `ATTACK` either way.
|
|
run.force_hot = Some("macro_move_1");
|
|
run.drive_until("a `MOVE` to start on this turn", 12_000, |run| {
|
|
run.started.keys().any(|name| name.starts_with("MOVE "))
|
|
});
|
|
run.force_hot = None;
|
|
|
|
// Then the battle ends. Bounded at 60,000 frames, which is 17 brain minutes; the measured run
|
|
// needs 1,536.
|
|
run.drive_until("the battle to end", 60_000, |run| run.in_battle() == 0);
|
|
|
|
assert!(
|
|
run.move_button_on_battle_pad,
|
|
"no `MOVE` button was ever on the pad of the fly's own turn: {:?}, by name {:?}",
|
|
run.battle_pad,
|
|
run.started
|
|
);
|
|
assert!(
|
|
run.battle_pad.is_some_and(|dealt| dealt > 1),
|
|
"the fly's own turn dealt a pad of {:?} buttons: by name {:?}",
|
|
run.battle_pad,
|
|
run.started
|
|
);
|
|
eprintln!(
|
|
"the turn ended in {:.1} brain minutes, smallest battle pad {:?}, {:?}, by name {:?}",
|
|
run.ms / 60_000.0,
|
|
run.battle_pad,
|
|
run.layer.counts(),
|
|
run.started
|
|
);
|
|
}
|
|
|
|
/// The checkpoint the no-PP test starts from, or `None` to skip.
|
|
///
|
|
/// Its own variable and no fallback: the other checkpoints in this file are overworld states and
|
|
/// this test asserts a battle, so taking one of them would fail on a state it does not measure.
|
|
fn nopp_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
let path = std::env::var_os("FLY_NOPP_CHECKPOINT")?;
|
|
Some(
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// Section 13: the errands, a purchase and a heal, from the release box's own checkpoint
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// `GO SHOP` walks the fly into the Viridian mart, once, and then to its counter.
|
|
///
|
|
/// **What is proven here, and what is not.** The errand works: from the release container's own
|
|
/// Viridian checkpoint the fly is inside the mart in 1.4 brain minutes, the errand is discharged
|
|
/// on arrival and `GO SHOP` is never on the city's pad again. Inside, `GO SHOP` walks it to a tile
|
|
/// it can talk to the clerk from — which needs the counter reach, because every tile beside a
|
|
/// clerk behind a desk is a wall.
|
|
///
|
|
/// **The purchase is not ROM-proven from this checkpoint**, and the mechanism is named rather than
|
|
/// papered over (`infra/docs/macros-traps.md` rows 33b and 34b). Two things are in the way, both
|
|
/// measured on the cartridge on 2026-09-17:
|
|
///
|
|
/// - the screen-buffer tile read disagrees with itself on a map smaller than the screen. In the
|
|
/// Viridian mart the same tile reads walkable-and-counter from one of the fly's tiles and
|
|
/// wall-and-not-counter from another two tiles away, because an 8x8 map cannot centre under a
|
|
/// ten-by-nine view. `facing_target`'s reach is taken from the *map id* for exactly that reason,
|
|
/// but the walk's own goals are still priced off the tile read;
|
|
/// - a counter *faced* is lost to the next hold. `GO SHOP` ends standing at the counter looking at
|
|
/// the clerk, and `TALK` is on the pad there — but a hold is 800 ms and whichever macro wins the
|
|
/// next one turns the fly away before the A press happens. The `reached` window brings `GO SHOP`
|
|
/// back in ten brain minutes, so it is bounded rather than a loop; it is not a proof.
|
|
///
|
|
/// The purchase scripts themselves are covered by the fake-game tests
|
|
/// (`a_purchase_navigates_by_the_items_place_in_the_stock_list`), and the *stock* read is proven
|
|
/// against the cartridge below: Viridian's counter sells no Potion.
|
|
#[test]
|
|
fn go_shop_enters_the_mart_once_and_walks_to_its_counter() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
assert_eq!(run.map(), VIRIDIAN_CITY, "the checkpoint is the one the stream stalled on");
|
|
|
|
// The errand is outstanding at the checkpoint: the ledger is session state and a restore
|
|
// starts empty (`docs/design/macros.md` section 13).
|
|
run.frame();
|
|
assert!(
|
|
run.layer.bound_channels().iter().any(|channel| channel == "macro_go_shop"),
|
|
"`GO SHOP` is on the pad with the errand outstanding: {:?}",
|
|
run.layer.bound_channels()
|
|
);
|
|
|
|
// Into the mart, on `GO SHOP`'s own channel. Viridian is a wide city and the mart is on the
|
|
// far side of it, so this is several budgets' worth of walking and several holds.
|
|
run.force_hot = Some("macro_go_shop");
|
|
let mart = u32::from(flybrain_gb::pokemon_red::maps::VIRIDIAN_MART);
|
|
run.drive_until("inside the mart", 240_000, |run| run.map() == mart);
|
|
|
|
// Inside, `GO SHOP` is still the walk to the clerk -- the half of section 13 the ledger does
|
|
// not gate, because the errand's remaining job is to put the fly where the purchases can be
|
|
// pressed. Every tile beside the clerk is a wall, so reaching one at all is the counter reach
|
|
// doing the work (`docs/design/macros-wram.md` section 7).
|
|
let mut shop_seen = false;
|
|
let mut faced_the_counter = false;
|
|
let mut pad_in_the_mart: Vec<String> = Vec::new();
|
|
run.drive_until("the counter faced", 60_000, |run| {
|
|
if run.map() != mart {
|
|
return false;
|
|
}
|
|
let bound = run.layer.bound_channels();
|
|
let shop = bound.iter().any(|channel| channel == "macro_go_shop");
|
|
// The frames straight after a warp report the new map with the *old* coordinates -- the
|
|
// header changes before the position does -- so the pad on those is dealt against a tile
|
|
// of the city. Waiting for `GO SHOP` to appear is waiting for the fly to actually be in
|
|
// the building.
|
|
if !shop_seen {
|
|
shop_seen = shop;
|
|
if shop_seen {
|
|
pad_in_the_mart = bound.clone();
|
|
}
|
|
return false;
|
|
}
|
|
// `GO SHOP` leaving the pad inside the building *is* the counter having been faced: the
|
|
// reached ledger is what retires it (`palette::counter_aims`).
|
|
faced_the_counter |= !shop;
|
|
faced_the_counter
|
|
});
|
|
eprintln!(
|
|
"the mart's pad on arrival: {pad_in_the_mart:?}, counter stocks {:?}, wallet {}",
|
|
run.counter_stock(),
|
|
run.money()
|
|
);
|
|
assert!(faced_the_counter, "`GO SHOP` walked to the clerk and retired it");
|
|
// And while the counter was unfaced, nothing on the pad could leave the building: that is what
|
|
// stops the one visit this area gets being spent on the doormat (`palette::counter_pending`).
|
|
assert!(
|
|
!pad_in_the_mart.iter().any(|channel| channel == "macro_go_out"),
|
|
"no way out while the errand's counter is unfaced: {pad_in_the_mart:?}"
|
|
);
|
|
|
|
// Out again -- the suppression is released now -- and the errand is paid: `GO SHOP` is not on
|
|
// the city's pad a second time. That is "once per area per run", and it is what makes the
|
|
// errand impossible to loop on.
|
|
run.force_hot = Some("macro_go_out");
|
|
run.drive_until("back out onto the city", 120_000, |run| run.map() == VIRIDIAN_CITY);
|
|
run.force_hot = None;
|
|
for _ in 0..600 {
|
|
run.frame();
|
|
}
|
|
assert!(
|
|
!run.layer.bound_channels().iter().any(|channel| channel == "macro_go_shop"),
|
|
"the errand is paid and never offered again: {:?}",
|
|
run.layer.bound_channels()
|
|
);
|
|
}
|
|
|
|
/// `GO HEAL` walks the fly into the Viridian Pokémon Center and `HEAL` restores the party.
|
|
///
|
|
/// The nurse is `object_event 3, 1, SPRITE_NURSE` behind the counter tile at (3, 2), so none of
|
|
/// the four tiles around her can be stood on: the walk aims at (3, 3) and faces up, over the
|
|
/// counter, which is the only place the cartridge lets her be spoken to from
|
|
/// (`docs/design/macros-wram.md` section 7).
|
|
///
|
|
/// `HEAL` is on the pad only while the party is hurt or statused, so the test hurts it first —
|
|
/// by *reading* whether it already is, and skipping the assertion if the checkpoint's party is
|
|
/// already full, because this crate never writes game memory (`docs/loop-review.md`).
|
|
#[test]
|
|
fn go_heal_enters_the_centre_and_heal_restores_the_party() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = checkpoint() else {
|
|
eprintln!("skipped: no FLY_TRAP_CHECKPOINT / FLY_MACRO_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
assert_eq!(run.map(), VIRIDIAN_CITY);
|
|
run.frame();
|
|
assert!(
|
|
run.layer.bound_channels().iter().any(|channel| channel == "macro_go_heal"),
|
|
"`GO HEAL` is on the pad with the errand outstanding: {:?}",
|
|
run.layer.bound_channels()
|
|
);
|
|
|
|
let centre = u32::from(flybrain_gb::pokemon_red::maps::VIRIDIAN_POKECENTER);
|
|
// The pad is captured *from inside the walk*, on the frames the fly is actually standing in
|
|
// the building. Measured 2026-09-17: given six hundred free frames after arriving it walks
|
|
// straight back out, and the centre's own row is only dealt while it is in there -- which is
|
|
// the mistake this test made on its first run.
|
|
let mut heal_seen = false;
|
|
let mut hurt_in_centre = false;
|
|
let mut rested_in_centre = false;
|
|
run.force_hot = Some("macro_go_heal");
|
|
run.drive_until("the centre's own pad", 240_000, |run| {
|
|
if run.map() != centre {
|
|
return false;
|
|
}
|
|
heal_seen |= run.layer.bound_channels().iter().any(|channel| channel == "macro_heal");
|
|
if run.party_rested() {
|
|
rested_in_centre = true;
|
|
} else {
|
|
hurt_in_centre = true;
|
|
}
|
|
heal_seen || rested_in_centre
|
|
});
|
|
run.force_hot = None;
|
|
eprintln!(
|
|
"in the centre: HEAL seen {heal_seen}, party hurt {hurt_in_centre}, rested \
|
|
{rested_in_centre}"
|
|
);
|
|
|
|
if !hurt_in_centre {
|
|
eprintln!(
|
|
"the checkpoint's party is already full and this crate never writes game memory, \
|
|
so `HEAL`'s restore is not asserted from this state; the walk to the counter is"
|
|
);
|
|
// What *is* asserted then: the button was never on the pad, which is its own
|
|
// precondition working (section 13: "only when at least one party member is not at full
|
|
// HP or has a status").
|
|
assert!(!heal_seen, "a rested party is no reason to rest");
|
|
return;
|
|
}
|
|
|
|
assert!(heal_seen, "`HEAL` was never on the centre's pad with a hurt party");
|
|
run.force_hot = Some("macro_heal");
|
|
run.drive_until("the party reads back full", 120_000, |run| run.party_rested());
|
|
run.force_hot = None;
|
|
assert!(run.party_rested(), "the nurse healed the party");
|
|
}
|
|
|
|
/// The rung-10 Pewter checkpoint, or `None` to skip.
|
|
///
|
|
/// Its own variable, like `FLY_FOREST_CHECKPOINT`: the tests above assert the map their envelope
|
|
/// is on and one envelope cannot be two maps.
|
|
fn building_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
std::env::var_os("FLY_BUILDING_CHECKPOINT").map(|path| {
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
})
|
|
}
|
|
|
|
/// From the rung-10 checkpoint: the fly leaves the building, and no pad is `MENU` and a way back.
|
|
///
|
|
/// **What was live** (2026-09-22, thirty-one minutes after v0.4.3 deployed): rank 10, PEWTER CITY,
|
|
/// the fly on **map 0x35 -- the upper floor of the Pewter museum**, fourteen blocks by eight, one
|
|
/// warp at (7, 7) down to the floor below, two signs and three exhibits. For thirty brain minutes
|
|
/// the macro starts were `MENU` 82, `BACK` 82 and `GO FRONTIER` 8, the event log alternating `MENU
|
|
/// start/done, BACK start/done`.
|
|
///
|
|
/// Every candidate list on that map empties: `geography` has no row for the museum, so `next_hop`
|
|
/// answers `None` and `GO OBJECTIVE` has nothing to aim at (the objective itself is map 0x36 --
|
|
/// the gym leader, rung 11's BOULDER BADGE); the three exhibits and two signs are *reached* and
|
|
/// retired for the session; the four unstood tiles are walked or excluded; and the one way out is
|
|
/// a **passage** whose blocked window `unexcluded_exits` respects. That left `MENU`, which opens a
|
|
/// scene whose pad is `CLOSE`, `CONFIRM` and `BACK` -- and `BACK` closes it again.
|
|
///
|
|
/// The claims here are about the pad and about *leaving*, not about where the fly goes next:
|
|
///
|
|
/// - `MENU` is on **no** overworld pad, on any map the run stands on (section 12.11). On v0.4.3
|
|
/// this set holds every one of them, which is what makes this the regression test.
|
|
/// - no overworld pad is empty, which since 12.11 rests on the way out rather than on `MENU`.
|
|
/// - the fly **leaves map 0x35** on a bounded number of macros.
|
|
/// - `MENU`/`BACK` never alternate, and `THROW BALL` never reports `blocked` -- the two residuals
|
|
/// v0.4.3 left (63 starts, 63 blocked, mean sixty-nine frames).
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
/// FLY_BUILDING_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = building_checkpoint() else {
|
|
eprintln!("skipped: no FLY_BUILDING_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
let from = run.map();
|
|
assert_eq!(from, MUSEUM_2F, "the checkpoint is the building the stream stalled in");
|
|
// Rung 10 is stood on, so the objective is rung 11 -- the BOULDER BADGE, which is the gym
|
|
// leader and so a *person* on the gym's map.
|
|
assert_eq!(
|
|
run.objective_map(),
|
|
Some(0x36),
|
|
"the objective is the Pewter gym, where the badge is"
|
|
);
|
|
|
|
// Driven the whole budget rather than stopped at the door: leaving is one claim and "`MENU`
|
|
// is on no pad" is a claim about every pad the run deals, so the run keeps going and keeps
|
|
// recording. Thirty-three brain minutes, against the thirty the live run spent not leaving.
|
|
let mut left = None;
|
|
for frame in 0..120_000u32 {
|
|
run.frame();
|
|
if left.is_none() && run.map() != from {
|
|
left = Some(frame);
|
|
}
|
|
}
|
|
eprintln!(
|
|
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, MENU on the pad \
|
|
of {:?}, empty pads {:?}, blocked {:?}",
|
|
run.ms / 60_000.0,
|
|
run.route,
|
|
run.started,
|
|
run.menu_on_pad,
|
|
run.empty_overworld_pads,
|
|
run.blocked
|
|
);
|
|
|
|
assert!(
|
|
run.menu_on_pad.is_empty(),
|
|
"`MENU` was on an overworld pad: {:?}",
|
|
run.menu_on_pad
|
|
);
|
|
assert_eq!(run.started.get("MENU"), None, "`MENU` cannot start if it is on no pad");
|
|
// One is a lone `BACK`, which is an ordinary press in a list; two is the pair, and the live
|
|
// run did it eighty-two times each for thirty brain minutes.
|
|
assert!(
|
|
run.longest_menu_back_alternation < 2,
|
|
"`MENU`/`BACK` alternated {} times in a row",
|
|
run.longest_menu_back_alternation
|
|
);
|
|
assert!(
|
|
run.empty_overworld_pads.is_empty(),
|
|
"an overworld pad was empty on {:?}",
|
|
run.empty_overworld_pads
|
|
);
|
|
let Some(left) = left else { panic!("the fly never left map {from:#04x}") };
|
|
eprintln!("it left map {from:#04x} on frame {left}");
|
|
assert!(
|
|
run.macros_on_the_first_map < 400,
|
|
"leaving the building cost {} macros",
|
|
run.macros_on_the_first_map
|
|
);
|
|
// The v0.4.3 residual, on the cartridge: every `THROW BALL` was blocked because the step read
|
|
// the battle menu's cursor while the bag was still drawing.
|
|
assert_eq!(
|
|
run.blocked.get("THROW BALL"),
|
|
None,
|
|
"`THROW BALL` reported blocked: {:?}",
|
|
run.blocked
|
|
);
|
|
}
|
|
|
|
/// The rung-10 Pokemon Center checkpoint, or `None` to skip.
|
|
fn center_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
std::env::var_os("FLY_CENTER_CHECKPOINT").map(|path| {
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
})
|
|
}
|
|
|
|
/// From the rung-10 Pokemon Center checkpoint: the fly leaves the centre and stops answering YES.
|
|
///
|
|
/// **What was live** (2026-09-22, v0.4.4, rank 10 PEWTER CITY): the fly on **map 0x3a at (3, 3)**,
|
|
/// facing the nurse over her counter, and since the 09:39 restart the macro starts were `YES`
|
|
/// **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, the event log ending `YES start/done` for
|
|
/// ever. This is row 41, first measured in the rung-9 trap hunt and named as the next trap by
|
|
/// 12.11.
|
|
///
|
|
/// **What the survey found** (`infra/docs/macros-traps.md` row 41, and
|
|
/// `examples/scene_probe.rs`'s `FLY_PROBE_CATCH=nurse`): the nurse's conversation is a ring of
|
|
/// **forty-six A presses** -- welcome, the offer, the YES/NO box on **one** frame of the
|
|
/// forty-six, "OK. We'll need your POKeMON.", the machine, "fighting fit!", "We hope to see you
|
|
/// again!", the box closes for a single frame, and the next A press opens the whole thing again.
|
|
/// The party read **70/70 and healthy** throughout, so every press of it changed nothing, and
|
|
/// `HEAL` was never in it: its precondition reads the live party and answers no. What was on the
|
|
/// pad was the dialog's `NEXT`, `YES`, `NO` -- two names for one A press -- and `TALK` to get back
|
|
/// in, whose ledger entry was read one tile shorter than its own precondition and so was never
|
|
/// written.
|
|
///
|
|
/// The claims, none of them about where the fly goes next:
|
|
///
|
|
/// - `YES` starts **under five** in the whole run, against 1,278 in the rung-9 hunt from the same
|
|
/// room. Not zero: the fly may legitimately answer a hurt party's prompt.
|
|
/// - `NEXT` is on **no** pad while a readable YES/NO prompt is open (12.10 in a dialog).
|
|
/// - `TALK` is on **no** pad while the fly faces a nurse the party has no use for.
|
|
/// - the fly **leaves map 0x3a** on a bounded number of macros.
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
/// FLY_CENTER_CHECKPOINT=.local/checkpoints/release-rank10-pokecenter.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = center_checkpoint() else {
|
|
eprintln!("skipped: no FLY_CENTER_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
let from = run.map();
|
|
assert_eq!(from, PEWTER_POKECENTER, "the checkpoint is the room the stream stalled in");
|
|
// The premise of the whole trap: there was nothing to heal.
|
|
assert!(run.party_rested(), "the checkpoint's party is already full and healthy");
|
|
|
|
let mut left = None;
|
|
for frame in 0..120_000u32 {
|
|
run.frame();
|
|
if left.is_none() && run.map() != from {
|
|
left = Some(frame);
|
|
}
|
|
}
|
|
eprintln!(
|
|
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, dialog frames {} \
|
|
(prompt on {}), blocked {:?}",
|
|
run.ms / 60_000.0,
|
|
run.route,
|
|
run.started,
|
|
run.dialog_frames,
|
|
run.prompt_frames,
|
|
run.blocked
|
|
);
|
|
eprintln!("macros spent in the centre: {:?}", run.started_on_the_first_map);
|
|
|
|
assert!(
|
|
!run.next_on_a_prompt,
|
|
"`NEXT` was on the pad at a YES/NO box, where an A press is `YES`"
|
|
);
|
|
assert!(
|
|
!run.talk_at_a_rested_nurse,
|
|
"`TALK` was on the pad at a nurse the party had no use for"
|
|
);
|
|
// In the **centre**, which is what row 41 is about: the run goes on to leave Pewter's gym
|
|
// door and fight there, and the gym's own boxes are the fly playing the game rather than the
|
|
// ring. 1,278 of 1,295 in the rung-9 hunt from this room; 2,142 live.
|
|
let yes = run.started_on_the_first_map.get("YES").copied().unwrap_or(0);
|
|
assert!(yes < 5, "`YES` started {yes} times in the centre: {:?}", run.started_on_the_first_map);
|
|
let Some(left) = left else {
|
|
panic!("the fly never left map {from:#04x}: {:?}", run.started)
|
|
};
|
|
eprintln!("it left map {from:#04x} on frame {left}");
|
|
assert!(
|
|
run.macros_on_the_first_map < 400,
|
|
"leaving the centre cost {} macros",
|
|
run.macros_on_the_first_map
|
|
);
|
|
}
|
|
|
|
/// The rung-10 Pewter checkpoint, or `None` to skip.
|
|
fn pewter_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
std::env::var_os("FLY_PEWTER_CHECKPOINT").map(|path| {
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
})
|
|
}
|
|
|
|
/// The rung-11 Pewter checkpoint, or `None` to skip.
|
|
fn rank_eleven_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
std::env::var_os("FLY_RANK11_CHECKPOINT").map(|path| {
|
|
flysim::store::load(std::path::Path::new(&path))
|
|
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
})
|
|
}
|
|
|
|
/// From the rung-11 Pewter checkpoint: the fly leaves the town it has finished with.
|
|
///
|
|
/// **What was live** (2026-09-22 15:52 UTC, the badge won, rank 11 with `MT. MOON` next): the
|
|
/// overworld pad had shrunk to `GO ROUTE` alone and `GO ROUTE` completed every ~420 ms at a net of
|
|
/// zero tiles, start and done back to back for minutes, with an occasional `GO FRONTIER` and a
|
|
/// `GO OUT` bounce of 180 ms. The trap hunt from this checkpoint on `main` is row 54 verbatim:
|
|
/// **`GO FRONTIER` 122, `GO HEAL` 129, `GO ROUTE` 126, every one of them `done` at a mean net of
|
|
/// 0.0 tiles**, over **14 distinct tiles** in six brain minutes, 17 of 17 windows flagged, and the
|
|
/// repeated sequence printed as `GO ROUTE, GO FRONTIER, GO HEAL` x34 to x42.
|
|
///
|
|
/// The mechanism is row 54's, one town further on: a restore clears the errand ledger, so Pewter's
|
|
/// mart and centre are outstanding again although the run has been inside both; `objective_place`
|
|
/// puts the errand ahead of the rung, so `GO ROUTE`'s second tier aimed at the mart's door; and the
|
|
/// errand's own aim at a door the fly was standing on settled where it stood. The badge was already
|
|
/// won, so the objective was two maps away and none of it moved the fly.
|
|
///
|
|
/// The claims: the fly **leaves Pewter City** for Route 3, no chain of walks completes at a net of
|
|
/// zero tiles more than three times in a row, and neither errand is offered in a town the run has
|
|
/// already shopped and healed in.
|
|
#[test]
|
|
fn the_fly_leaves_pewter_from_the_rung_eleven_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = rank_eleven_checkpoint() else {
|
|
eprintln!("skipped: no FLY_RANK11_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
assert_eq!(run.map(), PEWTER_CITY, "the checkpoint is the town the stream stalled in");
|
|
|
|
let mut left = None;
|
|
for frame in 0..200_000u32 {
|
|
run.frame();
|
|
if left.is_none() && run.map() == ROUTE_3 {
|
|
left = Some(frame);
|
|
}
|
|
}
|
|
eprintln!(
|
|
"from Pewter in {:.1} brain minutes: route {:?}, macros {:?}, the longest chain of walks that completed at a net of zero tiles {} ({:?})",
|
|
run.ms / 60_000.0,
|
|
run.route,
|
|
run.started,
|
|
run.worst_net_zero_streak,
|
|
run.worst_net_zero_chain
|
|
);
|
|
assert!(
|
|
left.is_some(),
|
|
"the fly never left Pewter City (map {:#04x}, route {:?}, macros {:?})",
|
|
run.map(),
|
|
run.route,
|
|
run.started
|
|
);
|
|
assert!(
|
|
run.worst_net_zero_streak <= 3,
|
|
"{} walks in a row completed without moving the fly: {:?}",
|
|
run.worst_net_zero_streak,
|
|
run.worst_net_zero_chain
|
|
);
|
|
// Section 13's errand, paid by a building this run has already been inside: both of Pewter's
|
|
// are in the adapter's lifetime map ledger at this checkpoint, so neither button is dealt and
|
|
// the objective is the rung's own place two maps away.
|
|
let errands = run.started.get("GO HEAL").copied().unwrap_or(0)
|
|
+ run.started.get("GO SHOP").copied().unwrap_or(0);
|
|
assert!(
|
|
errands < 10,
|
|
"{errands} errand walks in a town the run has already shopped and healed in: {:?}",
|
|
run.started
|
|
);
|
|
assert!(
|
|
!run.route.contains(&PEWTER_MART) || run.route.iter().filter(|map| **map == PEWTER_MART).count() < 3,
|
|
"the fly walked in and out of the mart: {:?}",
|
|
run.route
|
|
);
|
|
}
|
|
|
|
/// From the rung-10 Pewter checkpoint: the fly gets out of the museum and into the gym.
|
|
///
|
|
/// **What was live** (2026-09-22, v0.4.5, rank 10 PEWTER CITY, five and a half hours on the
|
|
/// rung): the fly on **map 0x34**, the museum's ground floor, and since the 11:30 restart the
|
|
/// macro starts were `GO FRONTIER` **1,235**, `BACK` **678**, `GO OBJECTIVE` 267, `GO OUT` 242,
|
|
/// `YES` 169 -- with the objective, rung 11's BOULDER BADGE, two doors away in map `0x36`. Two
|
|
/// "Stuck" rollbacks fired inside the last half hour and put the fly back where it started.
|
|
///
|
|
/// **What the survey found** (`infra/docs/macros-traps.md`, 2026-09-22, and
|
|
/// `examples/scene_probe.rs`): three things, none of them the text box the brief expected.
|
|
///
|
|
/// - `BACK` was not in a text box at all. It is on no overworld and no dialog pad, so every one
|
|
/// of those 678 was `Scene::Unknown` -- which is *also* what a frame of the overworld reads as
|
|
/// while the cartridge drives the fly through a door. An A and a B press into somebody else's
|
|
/// script (section 12.13).
|
|
/// - the museum had **no row on the map graph**, so from inside it `next_hop` answered nothing
|
|
/// and `GO OBJECTIVE` had no goal: the road to the gym did not exist indoors (12.7's rule, and
|
|
/// the residual the previous review named).
|
|
/// - the museum's frontier is 39 tiles the run has never stood on, of which almost all are
|
|
/// behind the admission desk: unreachable, refused `no route`, excluded for ten brain minutes
|
|
/// by the blocked ledger and then offered again, once per hold (section 12.14).
|
|
///
|
|
/// The claims, none of them about which button the fly presses:
|
|
///
|
|
/// - the fly reaches **map 0x36**, the gym's own interior, on a bounded number of macros;
|
|
/// - it stands **facing the thing the rung names** while it is there, which is all `GO OBJECTIVE`
|
|
/// ever promises (12.5);
|
|
/// - `BACK` on a `dialog` or `unknown` frame stays **under five**, against 189 in the hunt;
|
|
/// - no frame deals a pad on an `Unknown` with nothing drawn on it.
|
|
///
|
|
/// ```sh
|
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
/// FLY_PEWTER_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \
|
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
|
/// ```
|
|
#[test]
|
|
fn the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint() {
|
|
let rom = skip_without_rom!();
|
|
let Some(checkpoint) = pewter_checkpoint() else {
|
|
eprintln!("skipped: no FLY_PEWTER_CHECKPOINT");
|
|
return;
|
|
};
|
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
let from = run.map();
|
|
assert_eq!(from, MUSEUM_1F, "the checkpoint is the room the stream stalled in");
|
|
|
|
let mut reached = None;
|
|
let mut macros_to_the_gym = 0;
|
|
for frame in 0..200_000u32 {
|
|
run.frame();
|
|
if reached.is_none() {
|
|
macros_to_the_gym = run.started.values().sum::<u32>();
|
|
if run.map() == PEWTER_GYM {
|
|
reached = Some(frame);
|
|
}
|
|
}
|
|
}
|
|
eprintln!(
|
|
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, GO FRONTIER by \
|
|
map {:?}, TALK on the pad on {:?}, TALK starts {:?}, BACK in a box {}, unknown pads \
|
|
with no box {}",
|
|
run.ms / 60_000.0,
|
|
run.route,
|
|
run.started,
|
|
run.frontier_by_map,
|
|
run.talk_on_pad_by_map,
|
|
run.talk_starts_by_map,
|
|
run.back_in_a_box,
|
|
run.unknown_pads_with_no_box
|
|
);
|
|
|
|
let Some(frame) = reached else {
|
|
panic!(
|
|
"the fly never reached the gym (map {:#04x}, route {:?}, macros {:?})",
|
|
run.map(),
|
|
run.route,
|
|
run.started
|
|
)
|
|
};
|
|
let progress = run.adapter.progress();
|
|
eprintln!(
|
|
"it reached map {PEWTER_GYM:#04x} on frame {frame}, on {macros_to_the_gym} macros; \
|
|
the run ends on rank {} ({}) with {} badges",
|
|
progress.rank,
|
|
progress.rank_label,
|
|
run.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wObtainedBadges).count_ones()
|
|
);
|
|
assert!(
|
|
macros_to_the_gym < 600,
|
|
"reaching the gym cost {macros_to_the_gym} macros: {:?}",
|
|
run.started
|
|
);
|
|
assert!(
|
|
run.talk_on_pad_by_map.contains(&PEWTER_GYM),
|
|
"the fly never stood in front of one of the gym's people: {:?}",
|
|
run.talk_on_pad_by_map
|
|
);
|
|
assert!(
|
|
run.objective_on_pad.contains(&PEWTER_GYM),
|
|
"`GO OBJECTIVE` had nothing to aim at inside the gym: {:?}",
|
|
run.objective_on_pad
|
|
);
|
|
// Section 12.13, against the 189 the hunt measured on map 0x02 alone.
|
|
assert!(
|
|
run.back_in_a_box < 5,
|
|
"`BACK` was dealt {} times on a dialog or unknown frame",
|
|
run.back_in_a_box
|
|
);
|
|
assert_eq!(
|
|
run.unknown_pads_with_no_box, 0,
|
|
"a pad was dealt on an unknown frame with nothing drawn on it"
|
|
);
|
|
// Section 12.14: the museum's frontier is proved unreachable once and then left alone. The
|
|
// live run started GO FRONTIER 1,235 times over these two floors and a town.
|
|
let museum: u32 = run.frontier_by_map.get(&MUSEUM_1F).copied().unwrap_or(0)
|
|
+ run.frontier_by_map.get(&MUSEUM_2F).copied().unwrap_or(0);
|
|
assert!(
|
|
museum < 40,
|
|
"`GO FRONTIER` started {museum} times on the museum's floors: {:?}",
|
|
run.frontier_by_map
|
|
);
|
|
assert!(
|
|
run.route.contains(&PEWTER_CITY),
|
|
"the road to the gym is out of the museum's front door: {:?}",
|
|
run.route
|
|
);
|
|
// Row 54: the cycle the last branch's after arm left behind. `GO FRONTIER`, `GO HEAL` and
|
|
// `GO ROUTE` each ended where they began, for seven and a half brain minutes over five tiles.
|
|
// Three in a row is the bound: a walk that arrives by turning, a walk cut short by a battle
|
|
// and a walk that finds its goal underfoot are each ordinary on their own.
|
|
eprintln!(
|
|
"the longest chain of walks that completed at a net of zero tiles: {} ({:?})",
|
|
run.worst_net_zero_streak, run.worst_net_zero_chain
|
|
);
|
|
assert!(
|
|
run.worst_net_zero_streak <= 3,
|
|
"{} walks in a row completed without moving the fly: {:?}",
|
|
run.worst_net_zero_streak,
|
|
run.worst_net_zero_chain
|
|
);
|
|
}
|