survey: the hunt can start inside a live session's ledgers, and counts refusals

The seeding moves from scene_probe into examples/support/ledgers.rs and
trap_hunt reads it as FLY_TRAP_SEED_*, so both arms of a hunt can start
inside the state the live session was in: row 57's pad was dealt by
ledgers a restore starts empty, and a hunt from the bare checkpoint
never meets it. The report gains refusals by macro and the longest run
of one macro refused with the fly on one tile.

scene_probe's own choice seed is FLY_PROBE_RNG now, so it does not read
as one of the FLY_PROBE_SEED_* ledgers.
This commit is contained in:
flybrain 2026-09-23 06:23:13 +00:00
parent b8a5c8fc52
commit 2d04abaf52
3 changed files with 158 additions and 91 deletions

View file

@ -46,6 +46,9 @@ use flysim::config::Config;
use flysim::macros::macro_layer; use flysim::macros::macro_layer;
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
#[path = "support/ledgers.rs"]
mod ledgers;
const MS_PER_FRAME: f64 = 1000.0 / 59.7275; const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
const BURST_MS: f64 = 100.0; const BURST_MS: f64 = 100.0;
const HOLDS_PER_SLOT: usize = 3; const HOLDS_PER_SLOT: usize = 3;
@ -1337,94 +1340,6 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
println!("\n{}", separator_table(&classes)); println!("\n{}", separator_table(&classes));
} }
/// Rebuild a live session's ledgers from the environment, because a restore starts them empty.
///
/// `FLY_PROBE_SEED_PUSHED="x,y;x,y"` walls tiles of the loaded map the way a scripted push-back
/// does; `FLY_PROBE_SEED_EXHAUSTED=1` marks its frontier unreachable (section 12.14);
/// `FLY_PROBE_SEED_TALKED=1` writes every person and sign on it into the talked ledger;
/// `FLY_PROBE_SEED_BLOCKED="warp2;east;south"` rests those exits for the window. The tile underfoot is
/// always recorded as stood on.
fn seed_ledgers(
macros: &mut flybrain_gb::pokemon_red::macros::PokemonPalette,
gb: &mut Emulator,
adapter: &PokemonRedReward,
ms: f64,
) {
use flybrain_gb::MacroPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, TargetKey};
use flybrain_gb::pokemon_red::macros::{Tile, path};
let Some(player) = state::player(gb) else { return };
let map = player.map;
macros.clock(ms);
let (things, covered): (Vec<_>, Vec<Tile>) = {
let ledger = AdapterLedger(adapter);
macros.inspect(gb, &ledger, |state: &mut dyn MacroState| {
let mut all = path::person_targets(state);
all.extend(path::interactable_targets(state));
let mut covered = Vec::new();
if let Some(size) = state.map_size() {
for y in 0..size.height {
for x in 0..size.width {
// `all` seeds every tile: the live fact "no new ground for hours",
// which is the one thing that would have cleared the frontier mark.
let all = std::env::var("FLY_PROBE_SEED_STOOD").is_ok_and(|v| v == "all");
if all || state.tile_visited(x, y) {
covered.push(Tile::new(x, y));
}
}
}
}
(all, covered)
})
};
let (talked, targets, stood, pushed, frontiers) = macros.ledgers_mut();
targets.clock(ms);
// The tile underfoot is ground the live session had stood on, or its first observe here
// would read it as new ground and clear the frontier mark being rebuilt.
stood.record(map, Tile::new(player.x, player.y));
// `FLY_PROBE_SEED_STOOD=1`: every tile of this map the adapter's lifetime ledger has, as a
// session that had walked the town for an hour would have them.
if std::env::var("FLY_PROBE_SEED_STOOD").is_ok_and(|value| value == "1" || value == "all") {
for tile in &covered {
stood.record(map, *tile);
}
}
if let Ok(tiles) = std::env::var("FLY_PROBE_SEED_PUSHED") {
for pair in tiles.split(';').filter(|pair| !pair.is_empty()) {
let mut xy = pair.split(',').map(|n| n.trim().parse::<u8>().expect("x,y"));
let (x, y) = (xy.next().expect("x"), xy.next().expect("y"));
pushed.record(map, Tile::new(x, y));
}
}
if std::env::var("FLY_PROBE_SEED_EXHAUSTED").is_ok_and(|value| value == "1") {
frontiers.record(map);
}
if std::env::var("FLY_PROBE_SEED_TALKED").is_ok_and(|value| value == "1") {
for (_, target) in &things {
talked.record(map, *target);
}
}
if let Ok(keys) = std::env::var("FLY_PROBE_SEED_BLOCKED") {
for key in keys.split(';').filter(|key| !key.is_empty()) {
let id = match key {
"north" => ExitId::Edge(Edge::North),
"south" => ExitId::Edge(Edge::South),
"east" => ExitId::Edge(Edge::East),
"west" => ExitId::Edge(Edge::West),
warp => ExitId::Warp(warp.trim_start_matches("warp").parse().expect("warpN")),
};
targets.record_blocked(map, TargetKey::Exit(id));
}
}
println!(
"\n- seeded: pushed {:?}, frontier marks {:?}, talked {} things",
pushed,
frontiers,
talked.len()
);
}
/// `FLY_PROBE_CATCH=route`. The live trap was map 2, scene `overworld`, a pad of `GO ROUTE` alone, /// `FLY_PROBE_CATCH=route`. The live trap was map 2, scene `overworld`, a pad of `GO ROUTE` alone,
/// refused about 740 times per ten brain minutes for hours with no button pressed. The ledgers /// refused about 740 times per ten brain minutes for hours with no button pressed. The ledgers
/// that dealt that pad are session state and a restore starts them empty, so this *earns* them: /// that dealt that pad are session state and a restore starts them empty, so this *earns* them:
@ -1434,7 +1349,7 @@ fn seed_ledgers(
/// the frame the way the macros do -- ledgers included -- and says which list emptied why, and /// the frame the way the macros do -- ledgers included -- and says which list emptied why, and
/// whether the route search can reach any of the one button's goals. /// whether the route search can reach any of the one button's goals.
/// ///
/// `FLY_PROBE_FRAMES` bounds the drive; `FLY_PROBE_SEED` changes the choices; `FLY_PROBE_PREFER` /// `FLY_PROBE_FRAMES` bounds the drive; `FLY_PROBE_RNG` changes the choices; `FLY_PROBE_PREFER`
/// (comma-separated names) presses those buttons whenever they are dealt. /// (comma-separated names) presses those buttons whenever they are dealt.
/// ///
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette /// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
@ -1445,7 +1360,7 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path}; use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path};
let budget = env_usize("FLY_PROBE_FRAMES", 240_000); let budget = env_usize("FLY_PROBE_FRAMES", 240_000);
let mut rng = env_usize("FLY_PROBE_SEED", 20_260_923) as u32 | 1; let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1;
let hold_frames = 48usize; let hold_frames = 48usize;
let trace_frames = env_usize("FLY_PROBE_TRACE_FRAMES", 0); let trace_frames = env_usize("FLY_PROBE_TRACE_FRAMES", 0);
// `FLY_PROBE_CATCH_AFTER=0` reads the frame at once, before any choice. // `FLY_PROBE_CATCH_AFTER=0` reads the frame at once, before any choice.
@ -1454,7 +1369,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
.map(|value| value.split(',').map(|name| name.trim().to_string()).collect()) .map(|value| value.split(',').map(|name| name.trim().to_string()).collect())
.unwrap_or_default(); .unwrap_or_default();
let mut macros = PokemonPalette::new(SEED); let mut macros = PokemonPalette::new(SEED);
seed_ledgers(&mut macros, gb, adapter, *ms); if let Some(seeded) = ledgers::seed(&mut macros, gb, adapter, *ms, "FLY_PROBE_SEED") {
println!("\n- seeded: {seeded}");
}
let mut running = false; let mut running = false;
let mut since_decision = hold_frames; let mut since_decision = hold_frames;
let mut last_pad = String::new(); let mut last_pad = String::new();

View file

@ -0,0 +1,95 @@
//! Rebuild a live session's macro ledgers from the environment, because a restore starts them
//! empty (`docs/design/macros.md` section 12: the ledgers are session state and never reach the
//! checkpoint). Shared by `scene_probe` and `trap_hunt`; `infra/docs/macros-traps.md` row 57 is
//! the trap that needed it: the pad that stalled was dealt by ledgers no checkpoint carries.
//!
//! Every variable is `<PREFIX>_<NAME>`, so the two examples keep their own namespaces:
//!
//! | name | meaning |
//! | --- | --- |
//! | `PUSHED` | `"x,y;x,y"`: tiles of the loaded map walled the way a scripted push-back walls them |
//! | `EXHAUSTED` | `1`: the loaded map's frontier marked unreachable (section 12.14) |
//! | `TALKED` | `1`: every person and sign on the loaded map written into the talked ledger |
//! | `BLOCKED` | `"warp2;east"`: those exits rested for the blocked window |
//! | `STOOD` | `1`: the adapter's covered ground; `all`: every tile of the map (no new ground at all) |
//!
//! The tile underfoot is always recorded as stood on, or the first observe would read it as new
//! ground and clear the frontier mark being rebuilt. Nothing is seeded when no variable is set.
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, TargetKey};
use flybrain_gb::pokemon_red::macros::{Tile, path};
use flybrain_gb::pokemon_red::{PokemonRedReward, state};
use flybrain_gb::{AdapterLedger, Emulator, MacroPalette};
/// Seed `macros` from `<prefix>_*` at brain millisecond `ms`, and say what was seeded.
pub fn seed(
macros: &mut PokemonPalette,
gb: &mut Emulator,
adapter: &PokemonRedReward,
ms: f64,
prefix: &str,
) -> Option<String> {
let var = |name: &str| std::env::var(format!("{prefix}_{name}")).ok();
let names = ["PUSHED", "EXHAUSTED", "TALKED", "BLOCKED", "STOOD"];
if names.iter().all(|name| var(name).is_none()) {
return None;
}
let player = state::player(gb)?;
let map = player.map;
let stood_mode = var("STOOD").unwrap_or_default();
macros.clock(ms);
let (things, covered): (Vec<_>, Vec<Tile>) =
macros.inspect(gb, &AdapterLedger(adapter), |state: &mut dyn MacroState| {
let mut all = path::person_targets(state);
all.extend(path::interactable_targets(state));
let mut covered = Vec::new();
if let Some(size) = state.map_size() {
for y in 0..size.height {
for x in 0..size.width {
if stood_mode == "all" || state.tile_visited(x, y) {
covered.push(Tile::new(x, y));
}
}
}
}
(all, covered)
});
let (talked, targets, stood, pushed, frontiers) = macros.ledgers_mut();
targets.clock(ms);
stood.record(map, Tile::new(player.x, player.y));
if stood_mode == "1" || stood_mode == "all" {
for tile in &covered {
stood.record(map, *tile);
}
}
for pair in var("PUSHED").unwrap_or_default().split(';').filter(|pair| !pair.is_empty()) {
let mut xy = pair.split(',').map(|n| n.trim().parse::<u8>().expect("PUSHED is x,y;x,y"));
let (x, y) = (xy.next().expect("an x"), xy.next().expect("a y"));
pushed.record(map, Tile::new(x, y));
}
if var("EXHAUSTED").as_deref() == Some("1") {
frontiers.record(map);
}
if var("TALKED").as_deref() == Some("1") {
for (_, target) in &things {
talked.record(map, *target);
}
}
for key in var("BLOCKED").unwrap_or_default().split(';').filter(|key| !key.is_empty()) {
let id = match key {
"north" => ExitId::Edge(Edge::North),
"south" => ExitId::Edge(Edge::South),
"east" => ExitId::Edge(Edge::East),
"west" => ExitId::Edge(Edge::West),
warp => ExitId::Warp(warp.trim_start_matches("warp").parse().expect("BLOCKED warpN")),
};
targets.record_blocked(map, TargetKey::Exit(id));
}
Some(format!(
"map {map:#04x}: pushed {pushed:?}, frontier marks {frontiers:?}, {} things talked to, \
{} tiles stood on",
talked.len(),
stood.len()
))
}

View file

@ -35,6 +35,7 @@
//! | `FLY_MACRO_BRAIN` | `FLY_DATASET`, else `data/fafb-v783` | the connectome | //! | `FLY_MACRO_BRAIN` | `FLY_DATASET`, else `data/fafb-v783` | the connectome |
//! | `FLY_TRAP_THREADS` | 4 | sweep threads | //! | `FLY_TRAP_THREADS` | 4 | sweep threads |
//! | `FLY_TRAP_SEED` | 20260917 | seeds the palette | //! | `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) |
//! //!
//! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this //! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this
//! measures is the loop that ships rather than a second implementation of it. Without a //! measures is the loop that ships rather than a second implementation of it. Without a
@ -58,6 +59,9 @@ use flysim::config::Config;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
#[path = "support/ledgers.rs"]
mod ledgers;
/// One brain minute in milliseconds. /// One brain minute in milliseconds.
const MINUTE_MS: f64 = 60_000.0; const MINUTE_MS: f64 = 60_000.0;
@ -186,6 +190,13 @@ struct Trace {
recoveries: u64, recoveries: u64,
rungs: Vec<(u32, &'static str, f64)>, rungs: Vec<(u32, &'static str, f64)>,
outcomes: BTreeMap<&'static str, u64>, 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<(&'static str, Option<(u32, u32, u32)>)>, 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 /// Frames spent in each scene, so a window full of macros can be read back to the scene that
/// dealt them. /// dealt them.
scenes: BTreeMap<&'static str, u64>, scenes: BTreeMap<&'static str, u64>,
@ -352,6 +363,22 @@ fn run(
config.loop_.game = "pokemon-red".to_string(); config.loop_.game = "pokemon-red".to_string();
config.macros.mode = mode; config.macros.mode = mode;
let mut macros: Option<MacroLayer> = macro_layer(&config, hold_ms, seed); 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 began_ms = agent.network.ms;
let until = began_ms + minutes * MINUTE_MS; let until = began_ms + minutes * MINUTE_MS;
@ -394,6 +421,10 @@ fn run(
battle_starts: BTreeMap::new(), battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(), battle_pads: BTreeMap::new(),
wall_seconds: 0.0, wall_seconds: 0.0,
seeded: seeded_note,
refusals: BTreeMap::new(),
refusal_run: (None, 0),
longest_refusal_run: (0, ""),
}; };
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
@ -483,6 +514,18 @@ fn run(
} }
Some(outcome) => { Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1; *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) = running.take() { if let Some(run) = running.take() {
let net = match (run.from, location) { let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => { (Some((map, x, y)), Some((at, ax, ay))) if map == at => {
@ -847,6 +890,9 @@ fn main() {
trace.wall_seconds, trace.wall_seconds,
Path::new(&checkpoint_path).display() 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!("| measure | value |");
println!("| --- | ---: |"); println!("| --- | ---: |");
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0)); println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
@ -856,6 +902,15 @@ fn main() {
for (outcome, count) in &trace.outcomes { for (outcome, count) in &trace.outcomes {
println!("| {outcome} | {count} |"); 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!("| recoveries | {} |", trace.recoveries);
println!("| windows examined | {windows} |"); println!("| windows examined | {windows} |");
println!("| windows flagged | {} |", traps.len()); println!("| windows flagged | {} |", traps.len());