From c9212e5498322dce4e5a4507ce341d676a5becc7 Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 05:46:56 +0000 Subject: [PATCH] survey: the route probe, and a seam to rebuild a session's ledgers FLY_PROBE_CATCH=route drives the real PokemonPalette from a checkpoint, one uniform choice per hold, prints every pad it deals and every refusal with its reason, and reads the frame the pad comes down to one refusing button on: which list emptied why, the no-window ledgers, and whether the route search reaches each way out. FLY_PROBE_SEED_* rebuild the session ledgers a restore starts empty; FLY_PROBE_RATCHET starts from the ratchet's rollback snapshot; FLY_PROBE_HOLD presses raw directions first. PokemonPalette gains inspect(), fences() and a doc(hidden) ledgers_mut() for the survey. The loop never calls them. --- .../src/pokemon_red/macros/driver.rs | 38 ++ .../crates/flysim/examples/scene_probe.rs | 333 +++++++++++++++++- 2 files changed, 370 insertions(+), 1 deletion(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 169c559..9cf77ac 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -165,6 +165,44 @@ impl PokemonPalette { self.frontiers.len() } + /// Read the frame exactly as the macros read it -- this session's ledgers included -- and hand + /// the state to `visit`. A survey seam (`examples/scene_probe.rs`), not a decision path: it + /// writes nothing, and what it sees is what the next `observe` would deal from. + pub fn inspect( + &mut self, + memory: &mut dyn MemoryReader, + ledger: &dyn RunLedger, + visit: impl FnOnce(&mut dyn MacroState) -> R, + ) -> R { + let mut state = PokeState::with_ledgers( + memory, + ledger, + &self.talked, + &self.targets, + &self.stood, + &self.areas, + &self.pushed, + ) + .caching_grid(&mut self.grids) + .with_frontiers(&self.frontiers); + visit(&mut state) + } + + /// The session's no-window ledgers, for a survey line: the tiles the cartridge has pushed the + /// fly off and the maps whose frontier is marked unreachable. + pub fn fences(&self) -> (&Pushed, &Frontiers) { + (&self.pushed, &self.frontiers) + } + + /// The session's ledgers, writable, for a survey that has to rebuild a live session's state + /// from a checkpoint (a restore starts them empty by design). Never called by the loop. + #[doc(hidden)] + pub fn ledgers_mut( + &mut self, + ) -> (&mut Talked, &mut Targets, &mut Stood, &mut Pushed, &mut Frontiers) { + (&mut self.talked, &mut self.targets, &mut self.stood, &mut self.pushed, &mut self.frontiers) + } + /// Take whatever the machine's last finished macro earned into the session's ledgers. fn record_talk(&mut self) { if let Some((map, target)) = self.machine.take_talked() { diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 9648ced..d7ffb38 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -1337,6 +1337,324 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64 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) = { + 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::().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, +/// 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: +/// it drives the real [`PokemonPalette`] from the checkpoint, choosing uniformly among whatever the +/// scene binds once per hold (the brain's hold, not a ranking), and prints every pad it deals and +/// every refusal with its reason. When the pad is down to one button that keeps refusing, it reads +/// 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. +/// +/// `FLY_PROBE_FRAMES` bounds the drive; `FLY_PROBE_SEED` changes the choices; `FLY_PROBE_PREFER` +/// (comma-separated names) presses those buttons whenever they are dealt. +/// +/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette +fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { + use flybrain_gb::MacroPalette; + use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey}; + use flybrain_gb::pokemon_red::macros::path::Way; + use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path}; + + let budget = env_usize("FLY_PROBE_FRAMES", 240_000); + let mut rng = env_usize("FLY_PROBE_SEED", 20_260_923) as u32 | 1; + let hold_frames = 48usize; + let trace_frames = env_usize("FLY_PROBE_TRACE_FRAMES", 0); + // `FLY_PROBE_CATCH_AFTER=0` reads the frame at once, before any choice. + let catch_after = env_usize("FLY_PROBE_CATCH_AFTER", 20) as u32; + let prefer: Vec = std::env::var("FLY_PROBE_PREFER") + .map(|value| value.split(',').map(|name| name.trim().to_string()).collect()) + .unwrap_or_default(); + let mut macros = PokemonPalette::new(SEED); + seed_ledgers(&mut macros, gb, adapter, *ms); + let mut running = false; + let mut since_decision = hold_frames; + let mut last_pad = String::new(); + let mut refusals: BTreeMap = BTreeMap::new(); + let mut outcomes: BTreeMap = BTreeMap::new(); + let mut single_refusals = 0u32; + let mut caught_at: Option = None; + + // `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is + // every eight frames: what the cartridge does with a press, before any macro is asked. + if let Ok(holds) = std::env::var("FLY_PROBE_HOLD") { + println!("\n## Raw holds before the drive\n\n```"); + for hold in holds.split(',') { + let (name, frames) = hold.split_once(':').unwrap_or((hold, "32")); + let mask = match name { + "right" => flybrain_gb::buttons::RIGHT, + "left" => flybrain_gb::buttons::LEFT, + "up" => flybrain_gb::buttons::UP, + "down" => flybrain_gb::buttons::DOWN, + "a" => flybrain_gb::buttons::A, + "b" => flybrain_gb::buttons::B, + _ => 0, + }; + for index in 0..frames.parse::().unwrap_or(32) { + gb.set_buttons(mask); + gb.run_frame().expect("a frame should complete"); + *ms += MS_PER_FRAME; + adapter.sample(gb, *ms); + if index % 8 == 7 { + println!( + "{name:>5} +{index:<3} {:?} scene {:?} sim={} flags5={:#04x} joyignore={:#04x}", + state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)), + scene::detect(gb), + gb.read8(ram::wSimulatedJoypadStatesIndex), + gb.read8(ram::wStatusFlags5), + gb.read8(ram::wJoyIgnore), + ); + } + } + } + println!("```"); + } + println!("\n## The drive: the real palette, one uniform choice per hold\n\n```"); + for frame in 0..budget { + macros.clock(*ms); + let observed = { + let ledger = AdapterLedger(&*adapter); + macros.observe(gb, &ledger) + }; + let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect(); + let player = state::player(gb); + let pad = format!("{:?} {names:?}", observed.scene); + if pad != last_pad { + println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y))); + last_pad = pad; + } + let mut mask = 0u8; + if running { + let ledger = AdapterLedger(&*adapter); + match macros.step(gb, &ledger) { + Some(held) => mask = held, + None => running = false, + } + } else if since_decision >= hold_frames && !observed.bindings.is_empty() { + since_decision = 0; + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + // `FLY_PROBE_PREFER` names buttons to press whenever they are dealt, first one first: + // the live brain's measured favourites, so the survey can earn the ledgers the live + // session earned. A survey's driver, never the fly's: nothing in the crate reads it. + let preferred = prefer + .iter() + .find_map(|want| observed.bindings.iter().find(|binding| binding.name == want)); + let binding = preferred + .unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]); + let ledger = AdapterLedger(&*adapter); + match macros.start(binding.slot, gb, &ledger) { + flybrain_gb::Started::Running(_) => { + running = true; + if let Some(held) = macros.step(gb, &ledger) { + mask = held; + } else { + running = false; + } + } + flybrain_gb::Started::Refused { name, reason } => { + let key = format!("{} {reason}", name.unwrap_or("-")); + *refusals.entry(key.clone()).or_default() += 1; + if names.len() == 1 && name.is_some() { + single_refusals += 1; + } else { + single_refusals = 0; + } + if refusals[&key] <= 3 || single_refusals == 1 { + println!( + "f{frame:<6} {:?} refused {key} (pad {names:?})", + player.map(|p| (p.map, p.x, p.y)) + ); + } + } + } + } + if let Some((name, outcome)) = macros.take_finished() { + *outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1; + if !matches!(outcome, flybrain_gb::Outcome::Done) { + println!( + "f{frame:<6} {:?} {name} {outcome:?}", + state::player(gb).map(|p| (p.map, p.x, p.y)) + ); + } + } + since_decision += 1; + if frame < trace_frames { + println!( + " t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {}", + state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)), + macros.running(), + macros.fences().1, + macros.stood() + ); + } + gb.set_buttons(mask); + gb.run_frame().expect("a frame should complete"); + *ms += MS_PER_FRAME; + adapter.sample(gb, *ms); + if single_refusals >= catch_after { + caught_at = Some(frame); + break; + } + } + println!("```\n"); + println!("- refusals: {refusals:?}"); + println!("- outcomes: {outcomes:?}"); + let Some(frame) = caught_at else { + println!("- pushed tiles at the end (no window): {:?}", macros.fences().0); + println!("\nThe pad never came down to one refusing button in {budget} frames."); + return; + }; + println!( + "\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\n", + frame as f64 * MS_PER_FRAME / 60_000.0 + ); + let (pushed, frontiers) = macros.fences(); + println!("- pushed tiles (no window): {pushed:?}"); + println!("- frontier marks (no window): {frontiers:?}"); + let ledger = AdapterLedger(&*adapter); + macros.inspect(gb, &ledger, |state: &mut dyn MacroState| { + let player = state.player().expect("a loaded map"); + println!("- player: {player:?}"); + println!("- objective: {:?}", state.objective()); + println!("- `objective_goals`: {:?}", palette::objective_goals(state)); + println!("- `objective_targets`: {:?}", palette::objective_targets(state)); + println!("- `untalked_people`: {:?}", palette::untalked_people(state)); + println!("- `untalked_objects`: {:?}", palette::untalked_objects(state)); + println!("- `facing_untalked`: {}", palette::facing_untalked(state)); + println!("- `frontier_aims`: {} tiles", palette::frontier_aims(state).len()); + println!("- `frontier_exhausted`: {}", state.frontier_exhausted()); + println!("- `stranded`: {}", palette::stranded(state)); + for way in [Way::Exit, Way::Passage, Way::Route] { + println!("- `ways({way:?})`: {:?}", palette::ways(state, way).iter().map(|exit| (exit.id, exit.tile)).collect::>()); + } + println!("\n### Every way out, and whether the route search reaches it from here\n"); + for exit in path::exits(state) { + let reach = path::route(state, &[exit.tile]).map(|route| route.goal.is_some()); + println!( + "- {:?} at ({:2},{:2}) way {:?} -> {:?}: map_visited {:?}, blocked {}, reachable {:?}", + exit.id, + exit.tile.x, + exit.tile.y, + exit.way, + exit.destination(player.map), + exit.destination(player.map).map(|map| state.map_visited(map)), + state.blocked(TargetKey::Exit(exit.id)), + reach + ); + } + println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```"); + for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) { + let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6)) + .map(|x| { + if x == player.x && y == player.y { + '@' + } else if state.pushed_tile(x, y) { + 'P' + } else { + match state.walkable(x, y) { + flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.', + flybrain_gb::pokemon_red::macros::state::Walkable::No => '#', + flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?', + } + } + }) + .collect(); + println!("y{y:2} x{:2}.. {row}", player.x.saturating_sub(6)); + } + println!("```"); + }); +} + /// How the candidate readings of "a two-option box is up" separate the frames of a survey. /// /// Three columns, because three things could say it and only a measurement says which: the @@ -1372,7 +1690,13 @@ fn main() { let mut gb = Emulator::new(&rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES) .expect("binjgb should accept the cartridge"); let mut adapter = PokemonRedReward::new(); - gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state"); + // `FLY_PROBE_RATCHET=1` starts from the ratchet's best snapshot instead: where a "Stuck" + // rollback puts the fly, which is where a live session that spent its rollbacks resumed from. + if std::env::var("FLY_PROBE_RATCHET").is_ok_and(|value| value == "1") { + gb.import_state(&checkpoint.runtime.ratchet_game).expect("the ratchet's snapshot"); + } else { + 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"); @@ -1436,6 +1760,13 @@ fn main() { return; } + // Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette, + // and read the frame the pad comes down to one refusing button on. + if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") { + route_survey(&mut gb, &mut adapter, &mut ms); + return; + } + let budget = env_usize("FLY_PROBE_FRAMES", 200_000); let stuck_after = env_usize("FLY_PROBE_STUCK", 600); let mut next_burst = ms;