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.
This commit is contained in:
flybrain 2026-09-23 05:46:56 +00:00
parent d5d9249ea9
commit c9212e5498
2 changed files with 370 additions and 1 deletions

View file

@ -165,6 +165,44 @@ impl PokemonPalette {
self.frontiers.len() 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<R>(
&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. /// Take whatever the machine's last finished macro earned into the session's ledgers.
fn record_talk(&mut self) { fn record_talk(&mut self) {
if let Some((map, target)) = self.machine.take_talked() { if let Some((map, target)) = self.machine.take_talked() {

View file

@ -1337,6 +1337,324 @@ 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,
/// 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<String> = 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<String, u64> = BTreeMap::new();
let mut outcomes: BTreeMap<String, u64> = BTreeMap::new();
let mut single_refusals = 0u32;
let mut caught_at: Option<usize> = 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::<usize>().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::<Vec<_>>());
}
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. /// 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 /// 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) let mut gb = Emulator::new(&rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge"); .expect("binjgb should accept the cartridge");
let mut adapter = PokemonRedReward::new(); 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"); adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
let channels = flybrain_gb::macro_channels("pokemon-red"); let channels = flybrain_gb::macro_channels("pokemon-red");
@ -1436,6 +1760,13 @@ fn main() {
return; 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 budget = env_usize("FLY_PROBE_FRAMES", 200_000);
let stuck_after = env_usize("FLY_PROBE_STUCK", 600); let stuck_after = env_usize("FLY_PROBE_STUCK", 600);
let mut next_burst = ms; let mut next_burst = ms;