flybrain/services/flysim/crates/flysim/examples/trap_hunt.rs
acamilo 60f09b79a3 flysim: every harness runs the legacy frame: the trap hunt, the benches, the ROM tests' and the probe's stub drivers
trap_hunt, palette_bench and room_escape ran the frame through NeuralAgent::tick, which installs
a frame and its rewards after the next ticks: one frame behind the stream, with the ratchet
observed without the objective signal and a rollback that never re-observed the scene. They now
restore the way the stream restores and run LegacyFrame::transition and ::boundary, looking in
through FrameObserver; FLY_TRACE works in each of them. The stub-readout drivers of
rom_macros_mode, rom_catch and scene_probe run LegacyFrame::execute and ::stub_advance, which are
the same calls they made, in the same order.
2026-09-23 20:15:20 +00:00

1052 lines
46 KiB
Rust

//! Loop detector: run macros mode from a checkpoint and name every window the fly spent going
//! nowhere.
//!
//! The Viridian stall of 2026-09-17 (`infra/docs/macros-traps.md`) was two hours and seventeen
//! minutes of `GO NPC done, GO OUT done, NEXT done, GO FRONTIER done`, every three brain seconds,
//! over three tiles — and nothing in the loop, the ratchet or the feed said so out loud. This
//! example is the thing that says so, on the dev box, before a release: it runs the real sim loop
//! from a checkpoint and reports every two-brain-minute window in which
//!
//! - the fly stood on **fewer than [`MIN_TILES`] distinct `(map, tile)`s**, or
//! - **one macro sequence repeated more than [`MAX_REPEATS`] times** (any period up to
//! [`MAX_PERIOD`] macros).
//!
//! Neither is a rule about what the fly *should* do — it is free to stand still, and silence
//! waits. Both are rules about what a *loop* looks like from outside: the macros finish, the
//! ledgers do not move, and the ground under the fly is the same three tiles.
//!
//! ```sh
//! FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
//! FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \
//! FLY_TRAP_MINUTES=30 cargo run --release -p flysim --example trap_hunt
//! ```
//!
//! Without the ROM it prints how to run it and nothing else. The cartridge is never copied into
//! this repository; it is read from the path in `FLY_ROM` and nothing else. Neither is a
//! checkpoint: `.local/` is not tracked.
//!
//! | env | default | meaning |
//! | --- | --- | --- |
//! | `FLY_ROM` | — | the cartridge; without it this prints instructions |
//! | `FLY_TRAP_CHECKPOINT` | `FLY_MACRO_CHECKPOINT` | the `FLYSIM01` checkpoint to start from |
//! | `FLY_TRAP_MINUTES` | 30 | brain minutes to run |
//! | `FLY_TRAP_STUB` | unset | `1` drives the macros from a rotating stub over [`STUB_CHANNELS`] instead of the brain's readout, so two builds with different channel lists are comparable |
//! | `FLY_TRAP_MODE` | `macros` | `macros` or `raw` |
//! | `FLY_MACRO_BRAIN` | `FLY_DATASET`, else `data/fafb-v783` | the connectome |
//! | `FLY_TRAP_THREADS` | 4 | sweep threads |
//! | `FLY_TRAP_SEED` | 20260917 | seeds the palette |
//! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) |
//! | `FLY_TRACE` | unset | a path: the stream's per-frame trace of this run (`flysim::trace`), comparable with the service's own |
//!
//! The frame is `flysim::frame::LegacyFrame`, the one the stream runs, restored the way the stream
//! restores it, so what this measures is the loop that ships rather than a second implementation
//! of it; `FLY_TRACE` records it in the stream's own trace format. Without a
//! checkpoint it refuses rather than booting the intro: a trap hunt is about a state the stream
//! was actually in.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use flybrain_core::agent::{AgentConfig, NeuralAgent};
use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator};
use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
#[path = "support/ledgers.rs"]
mod ledgers;
/// One brain minute in milliseconds.
const MINUTE_MS: f64 = 60_000.0;
/// The window a trap is measured over: two brain minutes, which is the ratchet's own stall window
/// (120 brain seconds, `flybrain_gb::ratchet::STALL_MS`) — long enough that a walk across a town
/// fits inside it and short enough that a loop cannot hide in it.
const WINDOW_MS: f64 = 2.0 * MINUTE_MS;
/// How far one window's start is from the next. Fifteen brain seconds, so a loop that begins
/// anywhere is inside some window whole rather than split across two.
const WINDOW_STEP_MS: f64 = 15_000.0;
/// Distinct `(map, tile)`s a window must hold to count as going somewhere.
///
/// Four, which is the operator's number and the shape of the Viridian loop: the tile outside the door, the
/// doormat inside it, and one tile of frontier. Three is a loop; four is a walk.
const MIN_TILES: usize = 4;
/// Times one macro sequence may repeat inside a window before it is a loop.
const MAX_REPEATS: usize = 10;
/// The longest macro sequence a repeat is looked for at. Eight, which is longer than any scene's
/// pad, so a cycle that visits every button of a scene in turn is still caught.
const MAX_PERIOD: usize = 8;
fn env_f64(name: &str, default: f64) -> f64 {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
}
/// Every macro channel the hunt's stub rotates over, whatever the build under test has.
///
/// `FLY_TRAP_STUB=1` replaces the brain's readout with one hot macro channel per hold, rotating
/// over this list — and the list is spelled out here rather than taken from
/// `flybrain_gb::macro_channels` **so that two builds with different channel lists get the same
/// rotation**. That is the only way a before/after over a change that adds macro types can be
/// compared at all: `tools/build_flywire.py` re-deals every `macro_<type>` population whenever a
/// type is added (206 neurons thirty-one ways is not twenty-two ways plus nine), so the *brain's*
/// preference over macros is a different function in the two arms and a run driven by it cannot
/// separate "the macros got worse" from "the populations moved".
///
/// A name the build under test does not have simply wins no hold, which is the honest reading of
/// a build that does not have that button — so the list is the **union** over the builds being
/// compared rather than either one's own, or an arm loses a scene it can act in.
const STUB_CHANNELS: [&str; 32] = [
"macro_go_objective",
"macro_go_out",
"macro_go_warp",
"macro_go_route",
"macro_go_item",
"macro_go_npc",
"macro_go_frontier",
"macro_go_shop",
"macro_go_heal",
"macro_talk",
"macro_menu",
"macro_next",
"macro_yes",
"macro_no",
"macro_close",
"macro_confirm",
"macro_back",
// `macro_attack` is the *union*, not a mistake: it is the button the build before section 14
// ends a battle with, and a list without it left that build's arm sitting in one unbroken
// battle for 69,977 of 71,673 frames — a fact about the rotation, not about the macros. Each
// arm gets every button it has, and a name the build does not have wins no hold.
"macro_attack",
"macro_move_1",
"macro_move_2",
"macro_move_3",
"macro_move_4",
"macro_switch",
"macro_item",
"macro_throw_ball",
"macro_run",
"macro_buy_potion",
"macro_buy_ball",
"macro_buy_antidote",
"macro_buy_repel",
"macro_heal",
"macro_leave",
];
/// Holds one stub channel stays hot before the rotation moves on.
///
/// One, so twenty brain minutes is about twenty-four passes over the thirty-one — enough that
/// every button is offered many times and short enough that no single one owns the run.
const STUB_HOLDS_PER_CHANNEL: usize = 1;
fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
}
/// Where the fly stood on one frame, and what it started on it.
/// A refused macro and the `(map, x, y)` it was refused on.
type RefusedAt = (&'static str, Option<(u32, u32, u32)>);
struct Trace {
/// `(brain ms, map, x, y)` for every frame the adapter could place the player on.
steps: Vec<(f64, u32, u32, u32)>,
/// `(brain ms, macro name)` for every macro that started.
starts: Vec<(f64, &'static str)>,
/// One entry per macro that ran to an outcome, for the walk report.
episodes: Vec<Episode>,
began_ms: f64,
ended_ms: f64,
frames: u64,
recoveries: u64,
rungs: Vec<(u32, &'static str, f64)>,
outcomes: BTreeMap<&'static str, u64>,
/// What `FLY_TRAP_SEED_*` rebuilt, when anything (row 57).
seeded: Option<String>,
/// `refused` outcomes by macro, and the run of one macro refused with the fly on one tile:
/// row 57's pad was one button refused 740 times running.
refusals: BTreeMap<&'static str, u64>,
refusal_run: (Option<RefusedAt>, u64),
longest_refusal_run: (u64, &'static str),
/// Frames spent in each scene, so a window full of macros can be read back to the scene that
/// dealt them.
scenes: BTreeMap<&'static str, u64>,
/// The scene and the adapter's mode on the last frame, for a run that ended somewhere odd.
ended_in: (&'static str, String),
/// The longest run of consecutive frames in one scene, and when it began.
longest_scene: BTreeMap<&'static str, (u64, f64)>,
/// Frames spent in a text box, by the map they were spent on, and the tile the fly was on.
///
/// "The scene reads `dialog` and stays there" is the one trap in `infra/docs/macros-traps.md`
/// that the hunt could report and not *locate*: a scene histogram says two thirds of a run was
/// a text box and nothing about which box. A map and a tile name the conversation, which is
/// what a fix has to be about.
dialog_frames: BTreeMap<(u8, u8, u8), u64>,
/// Macro starts in a text box, by name and map: which press the fly answered it with.
dialog_macros: BTreeMap<(&'static str, u8), u64>,
/// Every input of the detector's disputed branch on the last frame
/// (`pokemon_red::scene::why_unknown`).
ended_why: String,
/// The whole-map grid on the last frame, as [`grid_line`] reads it
/// (`docs/design/macros.md` section 15): how much of the map is ground, how much of it the fly
/// could reach from where it stopped, and how much of that it had never stood on. A hunt that
/// ends with reachable far below walkable ended fenced in, which no amount of re-planning was
/// ever going to fix.
ended_grid: String,
/// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four
/// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`).
///
/// `font_corners_no_border` is the false positive the 2026-09-17 residual has to be tested
/// for: a frame the detector calls `dialog` on four map tiles that hold frame tile ids.
font_corners_border: u64,
font_corners_no_border: u64,
font_no_corners: u64,
corners_no_font: u64,
/// Frames spent in each battle sub-state, by [`battle_sub_state`]'s name.
///
/// The scene histogram says "battle" and a battle has five sub-states with five different
/// pads (`docs/design/macros.md` section 12.6, 13.1), so a loop inside one of them is
/// invisible above. This is what names it.
battle_frames: BTreeMap<&'static str, u64>,
/// Macro starts by battle sub-state: which button the fly pressed on which of the five pads.
battle_starts: BTreeMap<(&'static str, &'static str), u64>,
/// Every macro channel that was bound in each battle sub-state, over the whole run.
///
/// The pad's composition, measured rather than read off the table: "which sub-state offers
/// `BACK`" is a question about the build under test and not about the document.
battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
/// Row 60: every battle as `(frames, macro starts, won)`, the one running, payouts by kind,
/// and `MOVE n` starts against how many chose a move the cartridge answers with nothing.
battles: Vec<(u64, u64, bool)>,
battle_now: Option<(u64, u64, bool)>,
payouts_by_kind: BTreeMap<&'static str, (u64, f64)>,
move_starts: (u64, u64),
wall_seconds: f64,
}
/// Which sub-state of a battle this frame is, or `None` when no battle is running.
///
/// The five the pad is dealt by: the top-level menu, the move list, the party list, the bag, the
/// forced switch, and the frames between turns where no list is accepting input.
fn battle_sub_state(emulator: &mut flybrain_gb::Emulator) -> Option<&'static str> {
use flybrain_gb::pokemon_red::macros::state::BattleMenu;
let battle = flybrain_gb::pokemon_red::state::battle(emulator)?;
if battle.forced_switch {
return Some("forced switch");
}
Some(match battle.menu {
BattleMenu::Main { .. } => "main menu",
BattleMenu::Moves { cursor: Some(_), .. } => "move list",
BattleMenu::Moves { cursor: None, .. } => "move list, no cursor",
BattleMenu::Party { .. } => "party list",
BattleMenu::Bag { .. } => "bag",
BattleMenu::None => "between turns",
})
}
/// The macro that owns the buttons, with where it began and the ground it has covered since.
struct Running {
name: &'static str,
from: Option<(u32, u32, u32)>,
tiles: BTreeSet<(u32, u32, u32)>,
frames: usize,
reach: u32,
}
/// One macro from its start to its outcome, as the frames underneath it saw it.
///
/// What separates the three ways a walk can spend the frame cap (`infra/docs/macros-traps.md`):
/// a walk crossing a town covers `tiles` in a straight-ish line and ends `net` tiles from where it
/// began; a walk oscillating over the walkable window's edge covers two or three tiles and ends
/// where it started; a blocked one never leaves its tile at all.
struct Episode {
name: &'static str,
outcome: &'static str,
frames: usize,
/// Distinct `(map, x, y)` the player stood on while it ran.
tiles: usize,
/// Manhattan distance from the first tile to the last.
net: u32,
/// The furthest the player ever got from the tile it started on.
reach: u32,
}
/// One window the hunt flagged.
struct Trap {
at_minute: f64,
tiles: usize,
/// The repeated sequence and how many times it ran, when that is what flagged it.
cycle: Option<(String, usize)>,
macros: usize,
}
/// The hunt's look inside the stream's frame (`flysim::frame`): what it reads before and after
/// the executor decides, and the stub readout. Everything else is the frame's own order.
struct Hunt {
stub: bool,
stub_hold: usize,
stub_next_ms: f64,
hold_ms: f64,
running: Option<Running>,
scene_run: (&'static str, u64, f64),
/// Read before `decide`, because `decide` is what starts the macro whose scene this is.
dialog_map: Option<u8>,
battle_sub: Option<&'static str>,
trace: Trace,
}
impl FrameObserver for Hunt {
/// The brain is still ticked -- the frame order, the plasticity and the cost are the run's --
/// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses and
/// in nothing else.
fn readout(&mut self, ms: f64, bound: Option<&[String]>, active: &mut Vec<String>) {
if !self.stub {
return;
}
let hot = STUB_CHANNELS[(self.stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
if ms >= self.stub_next_ms {
self.stub_next_ms = ms + self.hold_ms;
self.stub_hold += 1;
}
*active = bound.unwrap_or_default().iter().filter(|channel| *channel == hot).cloned().collect();
}
fn before_execute(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
let layer_scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
self.dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(parts.emulator).map(|p| p.map))
.flatten();
self.battle_sub = battle_sub_state(parts.emulator);
if let Some(sub) = self.battle_sub {
*self.trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = self.trace.battle_pads.entry(sub).or_default();
for channel in parts.macros.as_deref().map(MacroLayer::bound_channels).unwrap_or_default() {
pad.insert(channel);
}
}
}
fn executed(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, executed: &Executed) {
let ms = parts.agent.network.ms;
let location = frame.location;
let trace = &mut self.trace;
for event in &executed.events {
match event.outcome {
None => {
trace.starts.push((ms, event.name));
// Which press answered a box, and on which map: 991 `YES` in twenty brain
// minutes is a fact about one conversation, and this is what says which.
if let Some(map) = self.dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = self.battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
if let Some(battle) = trace.battle_now.as_mut() {
battle.1 += 1;
}
if let Some(slot) = event.name.strip_prefix("MOVE ") {
trace.move_starts.0 += 1;
let id = flybrain_gb::pokemon_red::state::battle(parts.emulator)
.and_then(|battle| battle.own)
.zip(slot.parse::<usize>().ok())
.and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten())
.map(|entry| entry.id);
if id.is_some_and(|id| {
flybrain_gb::pokemon_red::state::move_without_effect(parts.emulator, id)
== Some(true)
}) {
trace.move_starts.1 += 1;
}
}
self.running = Some(Running {
name: event.name,
from: location,
tiles: location.into_iter().collect(),
frames: 0,
reach: 0,
});
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = self.running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
ax.abs_diff(x) + ay.abs_diff(y)
}
_ => 0,
};
trace.episodes.push(Episode {
name: run.name,
outcome: outcome.as_str(),
frames: run.frames,
tiles: run.tiles.len(),
net,
reach: run.reach,
});
}
}
}
}
{
let text = flybrain_gb::pokemon_red::state::text_box(parts.emulator);
let (corners, border) = flybrain_gb::pokemon_red::state::dialog_border(parts.emulator);
match (text.open, corners, border) {
(true, true, true) => trace.font_corners_border += 1,
(true, true, false) => trace.font_corners_no_border += 1,
(true, false, _) => trace.font_no_corners += 1,
(false, true, _) => trace.corners_no_font += 1,
(false, false, _) => {}
}
}
if let Some(layer) = parts.macros.as_deref() {
let name = layer.scene_name();
*trace.scenes.entry(name).or_insert(0) += 1;
if name == self.scene_run.0 {
self.scene_run.1 += 1;
} else {
self.scene_run = (name, 1, ms);
}
let longest = trace.longest_scene.entry(name).or_insert((0, 0.0));
if self.scene_run.1 > longest.0 {
*longest = (self.scene_run.1, self.scene_run.2 - trace.began_ms);
}
// Where the text box is, which is the half the scene histogram could not say.
if name == "dialog" || name == "unknown" {
let where_ = flybrain_gb::pokemon_red::state::player(parts.emulator)
.map(|player| (player.map, player.x, player.y));
if let Some(key) = where_ {
*trace.dialog_frames.entry(key).or_insert(0) += 1;
}
}
}
}
}
/// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where
/// the fly stood and what it started.
fn run(
rom: &[u8],
data: &Arc<flybrain_core::dataset::BrainDataset>,
checkpoint: &flysim::store::Checkpoint,
mode: MacroMode,
minutes: f64,
threads: usize,
seed: u32,
) -> Trace {
let began_wall = std::time::Instant::now();
let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1");
let mut emulator = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge");
let mut adapter = PokemonRedReward::new();
let channels =
if mode.dealt() { flybrain_gb::macro_channels("pokemon-red") } else { Vec::new() };
let preset = gameboy_decoder_config_with_macros(&channels);
let hold_ms = preset
.macros
.as_ref()
.or(preset.exclusive.as_ref())
.expect("the preset has a group")
.hold_ms;
let agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
}
// The stream's own restore, into the stream's own frame: a fresh process's readout transient
// (`restore: legacy-transient-reset`), which is what the fly has after the service restarts.
let mut frame = LegacyFrame::new()
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
checkpoint,
)
.expect("the checkpoint should restore");
let mut config = Config::default();
config.loop_.game = "pokemon-red".to_string();
config.macros.mode = mode;
let mut macros: Option<MacroLayer> = macro_layer(&config, hold_ms, seed);
let mut seeded_note: Option<String> = None;
// Row 57: a trap dealt by session ledgers does not come back from a checkpoint, because a
// restore starts them empty. `FLY_TRAP_SEED_*` rebuilds them on the real palette, so both arms
// of a hunt start inside the state the live session was in.
if let Some(flavour) = config.macros.mode.palette_mode() {
let mut palette =
flybrain_gb::pokemon_red::macros::PokemonPalette::with_mode(seed, flavour);
let ms = agent.network.ms;
if let Some(seeded) =
ledgers::seed(&mut palette, &mut emulator, &adapter, ms, "FLY_TRAP_SEED")
{
eprintln!("seeded the session ledgers: {seeded}");
seeded_note = Some(seeded);
macros = Some(MacroLayer::new(Box::new(palette), hold_ms));
}
}
let began_ms = agent.network.ms;
let until = began_ms + minutes * MINUTE_MS;
let rank = adapter.progress().rank;
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
// faces, and which text box the detector is looking at. Off unless asked for, because it is a
// diagnostic and the tables above are the report.
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
let mut next_trace = began_ms;
let mut hunt = Hunt {
stub,
stub_hold: 0,
stub_next_ms: f64::NEG_INFINITY,
hold_ms,
running: None,
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
scene_run: ("", 0, began_ms),
dialog_map: None,
battle_sub: None,
trace: Trace {
steps: Vec::new(),
starts: Vec::new(),
episodes: Vec::new(),
began_ms,
ended_ms: began_ms,
frames: 0,
recoveries: 0,
rungs: vec![(rank, adapter.progress().rank_label, 0.0)],
outcomes: BTreeMap::new(),
scenes: BTreeMap::new(),
dialog_frames: BTreeMap::new(),
dialog_macros: BTreeMap::new(),
ended_in: ("", String::new()),
longest_scene: BTreeMap::new(),
ended_why: String::new(),
ended_grid: String::new(),
font_corners_border: 0,
font_corners_no_border: 0,
font_no_corners: 0,
corners_no_font: 0,
battle_frames: BTreeMap::new(),
battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(),
battles: Vec::new(),
battle_now: None,
payouts_by_kind: BTreeMap::new(),
move_starts: (0, 0),
wall_seconds: 0.0,
seeded: seeded_note,
refusals: BTreeMap::new(),
refusal_run: (None, 0),
longest_refusal_run: (0, ""),
},
};
let mut rank = rank;
// One observation before the first frame, as the sim loop takes after a restore.
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
}
while agent.network.ms < until {
let mut parts = Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: macros.as_mut(),
};
let transition = frame.transition(&mut parts, &mut hunt).expect("a frame");
let ms = transition.ms;
hunt.trace.frames += 1;
for payout in &transition.evaluated.rewards {
let entry = hunt.trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
*entry = (entry.0 + 1, entry.1 + payout.value);
}
{
use flybrain_gb::MemoryReader;
let trace = &mut hunt.trace;
let fighting =
parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
let won = transition
.evaluated
.rewards
.iter()
.any(|payout| matches!(payout.kind, "battle" | "trainer"));
match (fighting, trace.battle_now.as_mut()) {
(true, Some(battle)) => {
battle.0 += 1;
battle.2 |= won;
}
(true, None) => trace.battle_now = Some((1, 0, won)),
(false, Some(_)) => {
let mut battle = trace.battle_now.take().expect("a battle");
battle.2 |= won;
trace.battles.push(battle);
}
(false, None) => {}
}
}
if trace_every_ms > 0.0 && ms >= next_trace {
next_trace = ms + trace_every_ms;
let scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
// Read before the state borrows the emulator: this is the same call the state makes,
// and the only one that can say *which* refusal a frame is.
let refusal = flybrain_gb::pokemon_red::state::map_grid(parts.emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(parts.emulator);
let state: &mut dyn MacroState = &mut state;
let player = state.player();
let ahead = player.and_then(|player| {
let ahead = Tile::new(player.x, player.y).step(player.facing)?;
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
});
let ground = grid_line(state, player, refusal);
let why = flybrain_gb::pokemon_red::scene::why_unknown(parts.emulator);
println!(
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
(ms - began_ms) / MINUTE_MS
);
}
let location = frame.location;
if let Some((map, x, y)) = location {
hunt.trace.steps.push((ms, map, x, y));
}
if let Some(run) = hunt.running.as_mut() {
run.frames += 1;
if let Some(at) = location {
run.tiles.insert(at);
if let (Some((map, x, y)), (on, ax, ay)) = (run.from, at)
&& map == on
{
run.reach = run.reach.max(ax.abs_diff(x) + ay.abs_diff(y));
}
}
}
let progress = transition.evaluated.progress;
if progress.rank != rank {
rank = progress.rank;
hunt.trace.rungs.push((rank, progress.rank_label, ms - began_ms));
}
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
if boundary.rollback.is_some() {
hunt.trace.recoveries += 1;
hunt.running = None;
}
}
let mut trace = hunt.trace;
trace.ended_in = (
macros.as_ref().map_or("", MacroLayer::scene_name),
adapter.mode().to_string(),
);
trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
trace.ended_grid = {
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
let state: &mut dyn MacroState = &mut state;
let player = state.player();
grid_line(state, player, refusal)
};
trace.ended_ms = agent.network.ms;
trace.wall_seconds = began_wall.elapsed().as_secs_f64();
trace
}
/// The longest run of one repeated block in `names`, as `(block, repeats)`.
///
/// Every period up to [`MAX_PERIOD`], every offset, so a cycle that starts part-way into the
/// window is found where it starts rather than missed. A period of one is a single macro over and
/// over, which is the shape the battle-text deadlock of v0.2.4 had.
fn longest_cycle(names: &[&'static str]) -> Option<(String, usize)> {
let mut best: Option<(String, usize)> = None;
for period in 1..=MAX_PERIOD.min(names.len()) {
let mut start = 0;
while start + period <= names.len() {
let block = &names[start..start + period];
let mut repeats = 1;
while start + period * (repeats + 1) <= names.len()
&& &names[start + period * repeats..start + period * (repeats + 1)] == block
{
repeats += 1;
}
if best.as_ref().is_none_or(|(_, known)| repeats > *known) {
best = Some((block.join(", "), repeats));
}
start += period * repeats;
}
}
best
}
/// Every window of the trace that is a trap by either rule.
fn hunt(trace: &Trace) -> Vec<Trap> {
let mut traps = Vec::new();
let mut at = trace.began_ms;
while at + WINDOW_MS <= trace.ended_ms {
let until = at + WINDOW_MS;
let tiles: BTreeSet<(u32, u32, u32)> = trace
.steps
.iter()
.filter(|(ms, ..)| *ms >= at && *ms < until)
.map(|(_, map, x, y)| (*map, *x, *y))
.collect();
let names: Vec<&'static str> = trace
.starts
.iter()
.filter(|(ms, _)| *ms >= at && *ms < until)
.map(|(_, name)| *name)
.collect();
let cycle = longest_cycle(&names).filter(|(_, repeats)| *repeats > MAX_REPEATS);
// A window with no macro in it at all is a fly that chose nothing, which is the doctrine
// working rather than a trap: silence waits. Only a window that *did* things and got
// nowhere counts.
let stuck = tiles.len() < MIN_TILES && !names.is_empty();
if stuck || cycle.is_some() {
traps.push(Trap {
at_minute: (at - trace.began_ms) / MINUTE_MS,
tiles: tiles.len(),
cycle,
macros: names.len(),
});
}
at += WINDOW_STEP_MS;
}
traps
}
/// One row per `(macro, outcome)`: how long it ran and how much ground it covered.
///
/// This is the diagnostic half of the hunt. The windows say *that* the fly is going nowhere; this
/// says which macro spent the frames and whether it was walking, oscillating or stuck: `tiles` and
/// `net` near one on a `timeout` that spent the whole cap is a walk re-planning in place, and
/// `net` in the tens is a walk the cap simply cut in half.
fn walk_report(trace: &Trace) {
if trace.episodes.is_empty() {
return;
}
let mut rows: BTreeMap<(&'static str, &'static str), Vec<&Episode>> = BTreeMap::new();
for episode in &trace.episodes {
rows.entry((episode.name, episode.outcome)).or_default().push(episode);
}
println!("\n| macro | outcome | n | mean frames | mean tiles | mean net | mean reach | max net |");
println!("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |");
for ((name, outcome), episodes) in &rows {
let n = episodes.len() as f64;
let mean = |total: usize| total as f64 / n;
println!(
"| {name} | {outcome} | {} | {:.0} | {:.1} | {:.1} | {:.1} | {} |",
episodes.len(),
mean(episodes.iter().map(|e| e.frames).sum()),
mean(episodes.iter().map(|e| e.tiles).sum()),
mean(episodes.iter().map(|e| e.net as usize).sum()),
mean(episodes.iter().map(|e| e.reach as usize).sum()),
episodes.iter().map(|e| e.net).max().unwrap_or(0),
);
}
}
/// The whole-map grid in one line: what a stalled walk looks like from outside.
///
/// `docs/design/macros.md` section 15. Walkable is how much of the map is ground, reachable is
/// how much of that the fly can get to from where it is standing (the directed walls respected),
/// and unstood is how much of *that* this run has never been on -- which is the frontier's own
/// candidate pool. A walk that cannot finish is one of three shapes and these numbers tell them
/// apart: fenced in (reachable far below walkable), nothing left to explore (unstood zero), or no
/// grid at all, in which case the walks are back on the ten-by-nine window and the reason is
/// named.
fn grid_line(
state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState,
player: Option<flybrain_gb::pokemon_red::macros::state::Player>,
refusal: Option<flybrain_gb::pokemon_red::state::GridRefusal>,
) -> String {
let Some(player) = player else { return "grid: no player".to_string() };
let Some(grid) = state.map_grid() else {
// Which of section 15's refusals this frame is, rather than a bare "none": a walk that is
// on the window reading should say why it is.
return format!("grid: none ({})", refusal.map_or("unknown", |refusal| refusal.label()));
};
let unstood = grid
.walkable_tiles()
.into_iter()
.filter(|(x, y)| !state.tile_visited(*x, *y))
.count();
format!(
"grid map={:#04x} {}x{} walkable={} reachable={} unstood={}",
grid.map(),
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(player.x, player.y),
unstood
)
}
fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else {
println!(
"FLY_ROM is not set, so there is nothing to hunt.\n\
\n FLY_ROM=/path/to/pokemon-red.gb \\\n \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \\\n \
FLY_TRAP_MINUTES=30 cargo run --release -p flysim --example trap_hunt\n\n\
It reports every two-brain-minute window in which the fly stood on fewer than {} \n\
distinct (map, tile)s or repeated one macro sequence more than {} times.",
MIN_TILES, MAX_REPEATS
);
return;
};
let rom = std::fs::read(&path)
.unwrap_or_else(|error| panic!("FLY_ROM is {path:?} but could not be read: {error}"));
let Some(checkpoint_path) = std::env::var_os("FLY_TRAP_CHECKPOINT")
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))
else {
println!(
"FLY_TRAP_CHECKPOINT is not set. A trap hunt is about a state the stream was really \n\
in, so this example does not boot the intro to invent one: point it at a FLYSIM01 \n\
checkpoint (`.local/checkpoints/...`, never committed)."
);
return;
};
let checkpoint = flysim::store::load(Path::new(&checkpoint_path))
.expect("FLY_TRAP_CHECKPOINT should be a FLYSIM01 envelope");
let brain = std::env::var_os("FLY_MACRO_BRAIN")
.or_else(|| std::env::var_os("FLY_DATASET"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("data/fafb-v783"));
let data = Arc::new(
load_brain_dataset_from_dir(&brain)
.unwrap_or_else(|error| panic!("loading the connectome at {brain:?}: {error}")),
);
let minutes = env_f64("FLY_TRAP_MINUTES", 30.0);
let threads = env_usize("FLY_TRAP_THREADS", 4);
let seed = env_usize("FLY_TRAP_SEED", 20_260_917) as u32;
let mode = match std::env::var("FLY_TRAP_MODE").unwrap_or_else(|_| "macros".to_string()).as_str()
{
"raw" => MacroMode::Raw,
"macros" | "palette" | "plan" => MacroMode::Macros,
other => panic!("FLY_TRAP_MODE: {other:?} is not a mode (raw or macros)"),
};
let trace = run(&rom, &data, &checkpoint, mode, minutes, threads, seed);
let traps = hunt(&trace);
let windows = {
let mut count = 0usize;
let mut at = trace.began_ms;
while at + WINDOW_MS <= trace.ended_ms {
count += 1;
at += WINDOW_STEP_MS;
}
count
};
let ground: BTreeSet<(u32, u32, u32)> =
trace.steps.iter().map(|(_, map, x, y)| (*map, *x, *y)).collect();
println!("# Trap hunt: {} mode\n", mode.as_str());
println!(
"{:.2} brain minutes over {} frames in {:.0} s wall, from `{}`.\n",
(trace.ended_ms - trace.began_ms) / MINUTE_MS,
trace.frames,
trace.wall_seconds,
Path::new(&checkpoint_path).display()
);
if let Some(seeded) = &trace.seeded {
println!("Session ledgers rebuilt before the first frame (`FLY_TRAP_SEED_*`): {seeded}.\n");
}
println!("| measure | value |");
println!("| --- | ---: |");
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
println!("| distinct (map, tile) | {} |", ground.len());
println!("| the map at the end | {} |", trace.ended_grid);
println!("| macros started | {} |", trace.starts.len());
for (outcome, count) in &trace.outcomes {
println!("| {outcome} | {count} |");
}
if !trace.refusals.is_empty() {
let by: Vec<String> =
trace.refusals.iter().map(|(name, count)| format!("`{name}` {count}")).collect();
println!("| refused, by macro | {} |", by.join(", "));
println!(
"| longest run of one macro refused on one tile | {} (`{}`) |",
trace.longest_refusal_run.0, trace.longest_refusal_run.1
);
}
println!("| recoveries | {} |", trace.recoveries);
println!("| windows examined | {windows} |");
println!("| windows flagged | {} |", traps.len());
println!();
if traps.is_empty() {
println!(
"No window of {:.0} brain minutes held fewer than {} distinct (map, tile)s or one \
macro sequence more than {} times.\n",
WINDOW_MS / MINUTE_MS,
MIN_TILES,
MAX_REPEATS
);
} else {
println!("| at (brain min) | tiles | macros | repeated sequence |");
println!("| ---: | ---: | ---: | --- |");
for trap in &traps {
let cycle = match &trap.cycle {
Some((block, repeats)) => format!("`{block}` x{repeats}"),
None => "—".to_string(),
};
println!(
"| {:.2} | {} | {} | {} |",
trap.at_minute, trap.tiles, trap.macros, cycle
);
}
println!();
}
if !trace.dialog_frames.is_empty() {
println!("\n| text box on map | tile | frames |");
println!("| --- | --- | ---: |");
let mut rows: Vec<((u8, u8, u8), u64)> =
trace.dialog_frames.iter().map(|(key, n)| (*key, *n)).collect();
rows.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
for ((map, x, y), frames) in rows.into_iter().take(8) {
println!("| {map:#04x} | ({x}, {y}) | {frames} |");
}
}
if !trace.dialog_macros.is_empty() {
println!("\n| press in a text box | map | n |");
println!("| --- | --- | ---: |");
let mut rows: Vec<((&str, u8), u64)> =
trace.dialog_macros.iter().map(|(key, n)| (*key, *n)).collect();
rows.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
for ((name, map), n) in rows.into_iter().take(8) {
println!("| {name} | {map:#04x} | {n} |");
}
}
if !trace.battle_frames.is_empty() {
println!("\n| battle sub-state | frames | pad |");
println!("| --- | ---: | --- |");
for (sub, frames) in &trace.battle_frames {
let pad = trace
.battle_pads
.get(sub)
.map(|set| {
set.iter()
.map(|c| c.strip_prefix("macro_").unwrap_or(c).to_string())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
println!("| {sub} | {frames} | {pad} |");
}
println!("\n| macro start | battle sub-state | n |");
println!("| --- | --- | ---: |");
let mut rows: Vec<((&str, &str), u64)> =
trace.battle_starts.iter().map(|(key, n)| (*key, *n)).collect();
rows.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
for ((name, sub), n) in rows {
println!("| {name} | {sub} | {n} |");
}
}
battle_report(&trace);
println!("\n| scene | frames | longest run | run began (brain min) |");
println!("| --- | ---: | ---: | ---: |");
for (scene, frames) in &trace.scenes {
let (longest, at) = trace.longest_scene.get(scene).copied().unwrap_or((0, 0.0));
println!("| {scene} | {frames} | {longest} | {:.2} |", at / MINUTE_MS);
}
println!("\n| the dialog branch's two halves | frames |");
println!("| --- | ---: |");
println!("| font set, corners drawn, whole border drawn | {} |", trace.font_corners_border);
println!(
"| font set, corners drawn, **border not** (a false `dialog`) | {} |",
trace.font_corners_no_border
);
println!("| font set, corners not drawn (menu or unknown) | {} |", trace.font_no_corners);
println!("| corners drawn, font clear (harmless: overworld) | {} |", trace.corners_no_font);
println!(
"\nEnded in scene `{}`, adapter mode `{}`:\n\n```\n{}\n```",
trace.ended_in.0, trace.ended_in.1, trace.ended_why
);
walk_report(&trace);
for (rank, label, ms) in &trace.rungs {
println!("- rung {rank} {label} at {:.2} brain minutes", ms / MINUTE_MS);
}
println!(
"\nWindows overlap by design ({:.0} s apart over a {:.0} s window), so one loop is \
reported by every window it fills.",
WINDOW_STEP_MS / 1000.0,
WINDOW_MS / 1000.0
);
}
/// Row 60's numbers: payouts by kind, every battle's length and whether it paid a win, and the
/// `MOVE n` starts that chose a move the cartridge answers with nothing.
fn battle_report(trace: &Trace) {
println!("\n| payout kind | n | total |");
println!("| --- | ---: | ---: |");
for (kind, (n, total)) in &trace.payouts_by_kind {
println!("| {kind} | {n} | {total:.2} |");
}
let mut lengths: Vec<u64> = trace.battles.iter().map(|battle| battle.0).collect();
lengths.sort_unstable();
let at = |q: f64| -> u64 {
if lengths.is_empty() {
return 0;
}
lengths[((lengths.len() - 1) as f64 * q).round() as usize]
};
println!("\n| battles | n |");
println!("| --- | ---: |");
println!("| ended | {} |", trace.battles.len());
println!("| won (a battle or trainer payout) | {} |", trace.battles.iter().filter(|b| b.2).count());
println!(
"| still running at the end | {} |",
trace.battle_now.map_or("no".to_string(), |b| format!("{} frames, {} macros", b.0, b.1))
);
println!(
"| frames, median / p90 / max | {} / {} / {} |",
at(0.5),
at(0.9),
lengths.last().copied().unwrap_or(0)
);
println!(
"| macros per battle, max | {} |",
trace.battles.iter().map(|b| b.1).max().unwrap_or(0)
);
println!(
"| `MOVE n` starts / on a move without effect | {} / {} |",
trace.move_starts.0, trace.move_starts.1
);
}