From 60f09b79a38103cefb49073e3f2d838733b5b59b Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:20:56 +0000 Subject: [PATCH] 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. --- .../flysim/crates/flybrain-core/src/agent.rs | 8 +- .../crates/flysim/examples/palette_bench.rs | 213 ++----- .../crates/flysim/examples/room_escape.rs | 364 +++++------ .../crates/flysim/examples/scene_probe.rs | 32 +- .../crates/flysim/examples/trap_hunt.rs | 574 ++++++++---------- services/flysim/crates/flysim/src/frame.rs | 25 +- .../flysim/crates/flysim/tests/rom_catch.rs | 20 +- .../crates/flysim/tests/rom_macros_mode.rs | 30 +- 8 files changed, 535 insertions(+), 731 deletions(-) diff --git a/services/flysim/crates/flybrain-core/src/agent.rs b/services/flysim/crates/flybrain-core/src/agent.rs index 0cb5caa..f5cf4b6 100644 --- a/services/flysim/crates/flybrain-core/src/agent.rs +++ b/services/flysim/crates/flybrain-core/src/agent.rs @@ -259,10 +259,10 @@ impl NeuralAgent { /// scene has put on the pad (`docs/design/macros.md` section 12). A host with no macro group /// -- every caller that came before it -- passes `None` and decodes exactly as it always did. /// - /// `flysim`'s sim loop does not come through here (it drives the network and the decoder - /// itself, so that the macro layer can read the emulator between the two), but the bench that - /// measures the two arms against each other does, and a bench whose macro group could win a - /// channel the scene never bound would be measuring something the stream cannot do. + /// `flysim` does not come through here: its frame (`flysim::frame::LegacyFrame`) drives the + /// network and the decoder itself, so that the macro layer can read the emulator between the + /// two, and it installs a frame and its rewards straight after the frame rather than after the + /// next ticks. Every `flysim` harness runs that frame too. pub fn tick_bound( &mut self, frame: &[u8], diff --git a/services/flysim/crates/flysim/examples/palette_bench.rs b/services/flysim/crates/flysim/examples/palette_bench.rs index 4d3b152..0f1c3ae 100644 --- a/services/flysim/crates/flysim/examples/palette_bench.rs +++ b/services/flysim/crates/flysim/examples/palette_bench.rs @@ -70,19 +70,20 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use flybrain_core::agent::{ - AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions, + AgentConfig, NeuralAgent, }; use flybrain_core::dataset::load_brain_dataset_from_dir; -use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask}; +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::recovery::{NeuralRecovery, recover_game}; use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; use flysim::config::Config; +use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts}; use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer}; use flysim::snapshot::MacroMode; +use flysim::trace::FrameTrace; /// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up. const REDS_HOUSE_2F: u32 = 0x26; @@ -103,25 +104,12 @@ fn emulator(rom: &[u8]) -> Emulator { .expect("binjgb should accept the cartridge") } -/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it. -struct AgentRecovery<'a> { - agent: &'a mut NeuralAgent, -} +/// Whether a macro owned the buttons of a frame: read as the executor hands the mask over. +struct MacroOwned(bool); -impl NeuralRecovery for AgentRecovery<'_> { - fn clear_decoder_holds(&mut self) { - let ms = self.agent.network.ms; - self.agent.decoder.clear_holds(ms); - } - - fn clear_eligibility(&mut self) { - let ms = self.agent.network.ms; - self.agent.network.plasticity.clear_eligibility(ms); - } - - fn set_visual_frame(&mut self, frame: &[u8]) { - let (width, height) = (self.agent.frame.width, self.agent.frame.height); - self.agent.network.set_visual_frame(frame, width, height); +impl FrameObserver for MacroOwned { + fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) { + self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some()); } } @@ -244,11 +232,9 @@ fn boot_to_bedroom(rom: &[u8]) -> Vec { /// One arm: `hours` brain hours of the sim loop's frame order, unthrottled. /// -/// The order is `simloop.rs`'s (steps 2 to 10), as `NeuralAgent::tick` expresses it: the frame and -/// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer -/// is consulted at exactly the two points the loop consults it — after the decode, before the -/// buttons reach the emulator, and after the frame and its payouts — so the arm measures the -/// wiring under test rather than a second implementation of it. +/// The frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the +/// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the +/// brain through `NeuralAgent::tick`, one frame behind the stream's order.) fn run_arm( rom: &[u8], data: &Arc, @@ -270,48 +256,39 @@ fn run_arm( .or(preset.exclusive.as_ref()) .expect("the preset has a group") .hold_ms; - let blocked_ms = preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms; let mut agent_config = AgentConfig::with_decoder(preset); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut arm = Arm { mode: mode.as_str(), ..Arm::default() }; - match start { - Start::Fresh { state, warmup_ms } => { - emulator.import_state(state).expect("the booted state should import"); - agent_config.warmup_ms = *warmup_ms; - } - Start::Live { checkpoint } => { - emulator - .import_state(&checkpoint.runtime.emulator) - .expect("the checkpoint's emulator state should import"); - adapter - .import_state(&checkpoint.runtime.reward) - .expect("the checkpoint's reward ledger should import"); - let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| { - flybrain_gb::ratchet::Snapshot { - game: checkpoint.runtime.ratchet_game.clone(), - frame: checkpoint.runtime.ratchet_frame.clone(), - } - }); - ratchet - .import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len()) - .expect("the checkpoint's ratchet state should import"); - } + if let Start::Fresh { warmup_ms, .. } = start { + agent_config.warmup_ms = *warmup_ms; } 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")); } + let mut frame = LegacyFrame::new() + .with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file")); match start { - Start::Fresh { .. } => { - agent.warmup(Some(emulator.framebuffer())).expect("warm-up"); - } - Start::Live { checkpoint } => { - agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import"); - let (width, height) = (agent.frame.width, agent.frame.height); - agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); + Start::Fresh { state, warmup_ms: _ } => { + emulator.import_state(state).expect("the booted state should import"); + frame.frame_buffer.copy_from_slice(emulator.framebuffer()); + agent.warmup(Some(&frame.frame_buffer)).expect("warm-up"); } + // The stream's own restore, into the stream's own frame. + Start::Live { checkpoint } => frame + .restore( + &mut Parts { + agent: &mut agent, + emulator: &mut emulator, + adapter: &mut adapter, + ratchet: &mut ratchet, + macros: None, + }, + checkpoint, + ) + .expect("the checkpoint should restore"), } // The layer under test, built the way the sim loop builds it: from the configuration, so raw @@ -328,136 +305,62 @@ fn run_arm( let began_ms = agent.network.ms; let until = began_ms + hours * HOUR_MS; - let mut frame = emulator.framebuffer().to_vec(); - let mut payouts: Vec = Vec::new(); - let mut location = adapter.location(); - let mut blocked_since_ms = began_ms; - let mut held_channel: Option = agent.decoder.current().map(str::to_string); let mut rank = adapter.progress().rank; let tiles_at_start = adapter.progress().unique_locations; arm.rungs.push((rank, adapter.progress().rank_label, 0.0)); - // The scene has not been observed yet, so the first frame is decided on an empty palette, - // which presses nothing. That is one frame, and it is the honest starting state. + // One observation before the first frame, as the sim loop takes after a restore, so frame one + // is decided on a real palette. 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 rewards: Vec = payouts - .iter() - .map(|event| { - NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) - }) - .collect(); - let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; - - // The blocked-direction cooldown's input, as `simloop.rs` computes it. - let ms = agent.network.ms; - let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms) - .then(|| agent.decoder.current().map(str::to_string)) - .flatten(); - // The scene's own macro buttons, from the palette the previous frame's `observe` dealt: - // the same mask the sim loop passes (`docs/design/macros.md` section 12). `None` in the - // raw arm, which has no layer and no macro group at all. - let bound = macros.as_ref().map(MacroLayer::bound_channels); - let result = agent - .tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref()) - .expect("a tick"); - let held = agent.decoder.current().map(str::to_string); - if held != held_channel { - held_channel = held; - blocked_since_ms = ms; - } - - // Step 4, with the layer in the middle of it in macros mode and absent in raw mode. - let ms = agent.network.ms; - let mut mask = to_button_mask(&result.active); - if let Some(layer) = macros.as_mut() { - let ledger = AdapterLedger(&adapter); - let decision = layer.decide(&result.active, mask, ms, &mut emulator, &ledger); - mask = decision.mask; - if let Some(silence) = decision.silence { + let mut parts = Parts { + agent: &mut agent, + emulator: &mut emulator, + adapter: &mut adapter, + ratchet: &mut ratchet, + macros: macros.as_mut(), + }; + let mut owned = MacroOwned(false); + let transition = frame.transition(&mut parts, &mut owned).expect("a frame"); + let ms = transition.ms; + let executed = &transition.executed; + if let Some(layer) = parts.macros.as_deref() { + if let Some(silence) = executed.silence { *arm.silence.entry(silence.label()).or_insert(0) += 1; } - for event in decision.events.iter().filter(|event| event.outcome.is_none()) { + for event in executed.events.iter().filter(|event| event.outcome.is_none()) { *arm.by_rank.entry(event.slot).or_insert(0) += 1; } - if layer.running().is_some() { + if owned.0 { arm.macro_frames += 1; } + *arm.scenes.entry(layer.scene_name()).or_insert(0) += 1; } - if mask == 0 { + if executed.mask == 0 { arm.idle_frames += 1; } - emulator.set_buttons(mask as u8); - emulator.run_frame().expect("a frame should complete"); arm.frames += 1; - frame.copy_from_slice(emulator.framebuffer()); - - // Steps 7 to 9, then the scene. - payouts = adapter.sample(&mut emulator, ms); - for event in &payouts { + for event in &transition.evaluated.rewards { arm.reward += event.value; *arm.payouts.entry(event.kind).or_insert(0) += 1; } - if let Some(layer) = macros.as_mut() { - let ledger = AdapterLedger(&adapter); - let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); - *arm.scenes.entry(layer.scene_name()).or_insert(0) += 1; - } - - let now = adapter.location(); - if now.is_some() && now != location { - location = now; - blocked_since_ms = ms; - } - arm.digest = hash(arm.digest, u64::from(mask)); - if let Some((map, x, y)) = location { + arm.digest = hash(arm.digest, u64::from(executed.mask)); + if let Some((map, x, y)) = frame.location { arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y)); } - // Step 10: the ratchet, with the adapter's own policy. - let progress = adapter.progress(); + let progress = transition.evaluated.progress; if progress.rank != rank { rank = progress.rank; arm.rungs.push((rank, progress.rank_label, ms - began_ms)); } - let safe = adapter.safe_for_snapshot(); - let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; - let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { - game: emulator.export_state().expect("state export"), - frame: frame.clone(), - }); - let recover = ratchet.observe_with_game_over( - safe, - u64::from(progress.rank), - progress.unique_locations as u64, - ms as u64, - adapter.game_over(), - || captured.expect("the ratchet only captures when a snapshot was prepared"), - ); - if recover { - let snapshot = flybrain_gb::ratchet::Snapshot { - game: ratchet.game().expect("a recovery has a snapshot").to_vec(), - frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(), - }; - let restored = { - let mut neural = AgentRecovery { agent: &mut agent }; - recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot) - .expect("recovering the game") - }; - frame.copy_from_slice(&restored); - emulator.set_buttons(0); + let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary"); + if boundary.rollback.is_some() { arm.recoveries += 1; - location = adapter.location(); - held_channel = None; - blocked_since_ms = ms; - // The sim loop abandons a running macro on a rollback, and so does this. - if let Some(layer) = macros.as_mut() { - layer.cancel(ms); - } } } diff --git a/services/flysim/crates/flysim/examples/room_escape.rs b/services/flysim/crates/flysim/examples/room_escape.rs index 5a39a16..561cb1a 100644 --- a/services/flysim/crates/flysim/examples/room_escape.rs +++ b/services/flysim/crates/flysim/examples/room_escape.rs @@ -77,7 +77,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use flybrain_core::agent::{ - AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, RewardEvent as NeuralReward, TickOptions, + AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, }; use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask}; @@ -88,8 +88,8 @@ use flybrain_gb::adapter::{GameAdapter, MemoryReader}; use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::ratchet::Ratchet; -use flybrain_gb::recovery::{NeuralRecovery, recover_game}; use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; +use flysim::frame::{FrameObserver, FramePhase, LegacyFrame, Parts}; /// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house. const REDS_HOUSE_2F: u32 = 0x26; @@ -496,28 +496,6 @@ fn print_table(title: &str, cells: &BTreeMap<(String, u64, u64, u64), Cell>, roo // The real brain // ------------------------------------------------------------------------------------------- -/// The neural half of a ratchet recovery, exactly as `flysim::simloop` wires it. -struct AgentRecovery<'a> { - agent: &'a mut NeuralAgent, -} - -impl NeuralRecovery for AgentRecovery<'_> { - fn clear_decoder_holds(&mut self) { - let ms = self.agent.network.ms; - self.agent.decoder.clear_holds(ms); - } - - fn clear_eligibility(&mut self) { - let ms = self.agent.network.ms; - self.agent.network.plasticity.clear_eligibility(ms); - } - - fn set_visual_frame(&mut self, frame: &[u8]) { - let (width, height) = (self.agent.frame.width, self.agent.frame.height); - self.agent.network.set_visual_frame(frame, width, height); - } -} - /// Where one brain run starts. enum Start<'a> { /// A save state and the map it stands in: a fresh fly, warmed up here. @@ -666,6 +644,97 @@ fn survey(rom: &[u8], state: &[u8]) -> Survey { (reachable, exits) } +/// The room-escape instrumentation inside the stream's frame: the decoder as it stood before +/// the decode and after it, read at the one point between the two. +struct Escape<'a> { + /// The readout before this frame's decode. + before: Option, + hold_start: (Option<(u32, u32, u32)>, f64), + run_winner: Option, + run_length: f64, + start_map: u32, + exits: &'a BTreeMap<(u32, u32), Vec<&'static str>>, + hold_ms: f64, + trace: Trace, +} + +impl FrameObserver for Escape<'_> { + fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) { + if phase == FramePhase::Ticked { + self.before = Some(agent.decoder.export_state()); + } + } + + fn before_execute(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) { + let Some(before) = self.before.take() else { return }; + let agent = &*parts.agent; + let after = agent.decoder.export_state(); + if after.next_decision == before.next_decision { + return; + } + let ms = agent.network.ms; + let location = frame.location; + let trace = &mut self.trace; + trace.decisions += 1; + let winner = after.current.clone().expect("a decision names a winner"); + // The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw, the + // calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it + // with the winner is what "the incumbent won by hysteresis" means. + let adjusted = |channel: &str| { + let index = DIRECTIONS.iter().position(|name| *name == channel).expect("a direction"); + let role = ROLES[index]; + let rate = agent.network.rates.get_or_zero(role); + let base = after.baseline.get_or_zero(role); + (rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel)) + }; + let mut argmax = DIRECTIONS[0]; + for channel in DIRECTIONS.iter().skip(1) { + if adjusted(channel) > adjusted(argmax) { + argmax = channel; + } + } + if argmax != winner { + trace.hysteresis_holds += 1; + } + if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) { + *trace.wins.entry(name).or_insert(0) += 1; + } + if self.run_winner.as_deref() == Some(winner.as_str()) { + self.run_length += 1.0; + } else { + if self.run_length > 0.0 { + trace.runs.push(self.run_length); + } + self.run_winner = Some(winner.clone()); + self.run_length = 1.0; + } + + // Was the hold that just ended a wall bump? The location now against the location at the + // previous decision, for the direction that was held in between. + if let (Some(previous), Some(held)) = (self.hold_start.0, before.current.as_deref()) + && ms - self.hold_start.1 >= self.hold_ms + && location == Some(previous) + && let Some(name) = DIRECTIONS.iter().find(|name| **name == held) + { + *trace.blocked_holds.entry(name).or_insert(0) += 1; + } + self.hold_start = (location, ms); + + // The decision this whole exercise is about: standing on a tile one press from leaving, + // did the readout choose that press? Only on the starting map: the bedroom has walkable + // tiles at the same coordinates and they are not these exits. + if let Some(leaving) = location + .filter(|(map, _, _)| *map == self.start_map) + .and_then(|(_, x, y)| self.exits.get(&(x, y))) + { + trace.exit_decisions += 1; + if leaving.iter().any(|direction| *direction == winner) { + trace.exit_decisions_taken += 1; + } + } + } +} + /// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a /// live checkpoint -- the real reward ledger and the real ratchet. /// @@ -697,174 +766,81 @@ fn brain_trace( let mut emulator = emulator(rom); let mut adapter = PokemonRedReward::new(); let mut agent_config = AgentConfig::with_decoder(config.clone()); - let (hold_ms, blocked_ms) = { - let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group"); - (group.hold_ms, group.blocked_ms) - }; + let hold_ms = + config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms; let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut trace = Trace::default(); - - let start_map = match &start { - Start::Fresh { state, map, warmup_ms } => { - emulator.import_state(state).expect("the starting state should import"); - agent_config.warmup_ms = *warmup_ms; - *map - } - Start::Live { checkpoint, .. } => { - emulator - .import_state(&checkpoint.runtime.emulator) - .expect("the checkpoint's emulator state should import"); - adapter - .import_state(&checkpoint.runtime.reward) - .expect("the checkpoint's reward ledger should import"); - let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| { - flybrain_gb::ratchet::Snapshot { - game: checkpoint.runtime.ratchet_game.clone(), - frame: checkpoint.runtime.ratchet_frame.clone(), - } - }); - ratchet - .import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len()) - .expect("the checkpoint's ratchet state should import"); - u32::from(emulator.read8(ram::wCurMap)) - } - }; + if let Start::Fresh { warmup_ms, .. } = &start { + agent_config.warmup_ms = *warmup_ms; + } 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")); } - match &start { - Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"), - Start::Live { checkpoint, rng } => { - let mut state = checkpoint.agent.clone(); - state.network.rng = *rng; - agent.import_state(&state).expect("the checkpoint's agent state should import"); - let (width, height) = (agent.frame.width, agent.frame.height); - agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); + // The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer. + let mut frame = LegacyFrame::new(); + let start_map = match &start { + Start::Fresh { state, map, .. } => { + emulator.import_state(state).expect("the starting state should import"); + frame.frame_buffer.copy_from_slice(emulator.framebuffer()); + agent.warmup(Some(&frame.frame_buffer)).expect("warm-up"); + *map } - } + Start::Live { checkpoint, rng } => { + let mut checkpoint = (*checkpoint).clone(); + checkpoint.agent.network.rng = *rng; + frame + .restore( + &mut Parts { + agent: &mut agent, + emulator: &mut emulator, + adapter: &mut adapter, + ratchet: &mut ratchet, + macros: None, + }, + &checkpoint, + ) + .expect("the checkpoint should restore"); + u32::from(emulator.read8(ram::wCurMap)) + } + }; trace.maps.push(start_map); let began_ms = agent.network.ms; let until = began_ms + minutes * 60_000.0; - // The sim loop's own order (`simloop.rs`, steps 2 to 10), as `NeuralAgent::tick` expresses it: - // the frame and the payouts handed to a tick are the ones the previous tick's buttons produced. - let mut frame = emulator.framebuffer().to_vec(); - let mut payouts: Vec = Vec::new(); - let mut location = adapter.location(); - // The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly - // as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet. - let mut blocked_since_ms = began_ms; - let mut held_channel: Option = agent.decoder.current().map(str::to_string); - let mut hold_start = (location, began_ms); - let mut run_winner: Option = None; - let mut run_length = 0.0f64; + let mut escape = Escape { + before: None, + hold_start: (frame.location, began_ms), + run_winner: None, + run_length: 0.0, + start_map, + exits, + hold_ms, + trace, + }; // Reported on its own: the longest stretch with no movement at all, whatever was held. let mut still_since_ms = began_ms; let tiles_at_start = adapter.progress().unique_locations; - trace.tiles = tiles_at_start; + escape.trace.tiles = tiles_at_start; while agent.network.ms < until { - let rewards: Vec = payouts - .iter() - .map(|event| { - NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) - }) - .collect(); - let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; + let mut parts = Parts { + agent: &mut agent, + emulator: &mut emulator, + adapter: &mut adapter, + ratchet: &mut ratchet, + macros: None, + }; + let location_before = frame.location; + let transition = frame.transition(&mut parts, &mut escape).expect("a frame"); + let ms = transition.ms; + let trace = &mut escape.trace; + trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::(); + trace.tiles = transition.evaluated.progress.unique_locations; - // The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the - // channel the readout is holding, once the adapter's location has stood still for a whole - // hold. `blocked_ms == 0` is the rule switched off, and reports nothing. - let ms = agent.network.ms; - let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms) - .then(|| agent.decoder.current().map(str::to_string)) - .flatten(); - - let before = agent.decoder.export_state(); - let result = agent.tick_blocked(&frame, &options, blocked.as_deref()).expect("a tick"); - let after = agent.decoder.export_state(); - let held = agent.decoder.current().map(str::to_string); - if held != held_channel { - held_channel = held; - blocked_since_ms = ms; - } - - if after.next_decision != before.next_decision { - trace.decisions += 1; - let winner = after.current.clone().expect("a decision names a winner"); - // The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw - // (`tick` decodes on the post-step rates and nothing changes them afterwards), the - // calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it - // with the winner is what "the incumbent won by hysteresis" means. - let adjusted = |channel: &str| { - let index = - DIRECTIONS.iter().position(|name| *name == channel).expect("a direction"); - let role = ROLES[index]; - let rate = agent.network.rates.get_or_zero(role); - let base = after.baseline.get_or_zero(role); - (rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel)) - }; - let mut argmax = DIRECTIONS[0]; - for channel in DIRECTIONS.iter().skip(1) { - if adjusted(channel) > adjusted(argmax) { - argmax = channel; - } - } - if argmax != winner { - trace.hysteresis_holds += 1; - } - if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) { - *trace.wins.entry(name).or_insert(0) += 1; - } - if run_winner.as_deref() == Some(winner.as_str()) { - run_length += 1.0; - } else { - if run_length > 0.0 { - trace.runs.push(run_length); - } - run_winner = Some(winner.clone()); - run_length = 1.0; - } - - // Was the hold that just ended a wall bump? The location now against the location at - // the previous decision, for the direction that was held in between. - if let (Some(previous), Some(held)) = (hold_start.0, before.current.as_deref()) - && ms - hold_start.1 >= hold_ms - && location == Some(previous) - && let Some(name) = DIRECTIONS.iter().find(|name| **name == held) - { - *trace.blocked_holds.entry(name).or_insert(0) += 1; - } - hold_start = (location, ms); - - // The decision this whole exercise is about: standing on a tile one press from - // leaving, did the readout choose that press? Only on the starting map: the bedroom - // has walkable tiles at the same coordinates and they are not these exits. - if let Some(leaving) = location - .filter(|(map, _, _)| *map == start_map) - .and_then(|(_, x, y)| exits.get(&(x, y))) - { - trace.exit_decisions += 1; - if leaving.iter().any(|direction| *direction == winner) { - trace.exit_decisions_taken += 1; - } - } - } - - emulator.set_buttons(to_button_mask(&result.active) as u8); - emulator.run_frame().expect("a frame should complete"); - frame.copy_from_slice(emulator.framebuffer()); - let ms = agent.network.ms; - payouts = adapter.sample(&mut emulator, ms); - trace.reward += payouts.iter().map(|event| event.value).sum::(); - trace.tiles = adapter.progress().unique_locations; - - let now = adapter.location(); - if now.is_some() && now != location { - location = now; - blocked_since_ms = ms; + let location = frame.location; + if location != location_before { still_since_ms = ms; } trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms); @@ -876,40 +852,15 @@ fn brain_trace( *trace.exit_tile_frames.entry(tile).or_insert(0) += 1; } - // Step 10: the ratchet, with the adapter's own policy and the checkpoint's own budget. - let progress = adapter.progress(); - let safe = adapter.safe_for_snapshot(); - let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; - let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { - game: emulator.export_state().expect("state export"), - frame: frame.clone(), - }); - let recover = ratchet.observe( - safe, - u64::from(progress.rank), - progress.unique_locations as u64, - ms as u64, - || captured.expect("the ratchet only captures when a snapshot was prepared"), - ); - if recover { - let snapshot = flybrain_gb::ratchet::Snapshot { - game: ratchet.game().expect("a recovery has a snapshot").to_vec(), - frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(), - }; - let restored = { - let mut neural = AgentRecovery { agent: &mut agent }; - recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot) - .expect("recovering the game") - }; - frame.copy_from_slice(&restored); - emulator.set_buttons(0); - trace.recoveries += 1; - location = adapter.location(); - held_channel = None; - blocked_since_ms = ms; + // The ratchet, with the adapter's own policy and the checkpoint's own budget. + let progress = transition.evaluated.progress; + let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary"); + if boundary.rollback.is_some() { + escape.trace.recoveries += 1; still_since_ms = ms; - hold_start = (location, ms); + escape.hold_start = (frame.location, ms); } + let trace = &mut escape.trace; let Some(map) = adapter.map_id() else { continue }; if trace.maps.last() != Some(&map) { @@ -925,6 +876,7 @@ fn brain_trace( } } } + let Escape { mut trace, run_length, .. } = escape; if run_length > 0.0 { trace.runs.push(run_length); } diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 1850775..89115db 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -808,6 +808,8 @@ fn accept_survey( // [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor // bytes say "the move list" -- which is the whole of what the seam read before row 50. let mut readings = [[0usize; 2]; 2]; + // The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout. + let mut legacy = flysim::frame::LegacyFrame::new(); println!("\n## Row 50: every battle frame, pressed at\n"); println!("```"); @@ -823,18 +825,9 @@ fn accept_survey( } let bound = layer.bound_channels(); let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound)); - let mask = { - let ledger = AdapterLedger(adapter); - layer.decide(&active, 0, *ms, gb, &ledger).mask - }; - gb.set_buttons(mask as u8); - gb.run_frame().expect("a frame should complete"); + legacy.execute(Some(&mut *layer), &active, 0, *ms, gb, &*adapter); *ms += MS_PER_FRAME; - adapter.sample(gb, *ms); - { - let ledger = AdapterLedger(adapter); - let _ = layer.observe(gb, &ledger, *ms); - } + legacy.stub_advance(Some(&mut *layer), gb, adapter, *ms).expect("a frame should complete"); let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue }; let geom = move_cursor_geometry(gb); @@ -1801,6 +1794,8 @@ fn main() { let mut noattack = 0usize; let mut before = (0u8, 0u8, 0u8); let mut surveyed = 0usize; + // The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout. + let mut legacy = flysim::frame::LegacyFrame::new(); for frame in 0..budget { let bursting = ms < next_burst + BURST_MS; let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]); @@ -1810,18 +1805,11 @@ fn main() { } let bound = layer.bound_channels(); let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound)); - let mask = { - let ledger = AdapterLedger(&adapter); - layer.decide(&active, 0, ms, &mut gb, &ledger).mask - }; - gb.set_buttons(mask as u8); - gb.run_frame().expect("a frame should complete"); + legacy.execute(Some(&mut layer), &active, 0, ms, &mut gb, &adapter); ms += MS_PER_FRAME; - adapter.sample(&mut gb, ms); - { - let ledger = AdapterLedger(&adapter); - let _ = layer.observe(&mut gb, &ledger, ms); - } + legacy + .stub_advance(Some(&mut layer), &mut gb, &mut adapter, ms) + .expect("a frame should complete"); if catch_script && gb.read8(ram::wSimulatedJoypadStatesIndex) != 0 && gb.read8(ram::wCurMap) == 1 diff --git a/services/flysim/crates/flysim/examples/trap_hunt.rs b/services/flysim/crates/flysim/examples/trap_hunt.rs index 704dcfd..b5ad5e5 100644 --- a/services/flysim/crates/flysim/examples/trap_hunt.rs +++ b/services/flysim/crates/flysim/examples/trap_hunt.rs @@ -36,9 +36,11 @@ //! | `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 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 +//! 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. @@ -46,18 +48,19 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use flybrain_core::agent::{AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions}; +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, to_button_mask}; +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::recovery::{NeuralRecovery, recover_game}; 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; @@ -154,28 +157,6 @@ fn env_usize(name: &str, default: usize) -> usize { std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default) } -/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it. -struct AgentRecovery<'a> { - agent: &'a mut NeuralAgent, -} - -impl NeuralRecovery for AgentRecovery<'_> { - fn clear_decoder_holds(&mut self) { - let ms = self.agent.network.ms; - self.agent.decoder.clear_holds(ms); - } - - fn clear_eligibility(&mut self) { - let ms = self.agent.network.ms; - self.agent.network.plasticity.clear_eligibility(ms); - } - - fn set_visual_frame(&mut self, frame: &[u8]) { - let (width, height) = (self.agent.frame.width, self.agent.frame.height); - self.agent.network.set_visual_frame(frame, width, height); - } -} - /// 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)>); @@ -312,6 +293,161 @@ struct Trap { 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, + scene_run: (&'static str, u64, f64), + /// Read before `decide`, because `decide` is what starts the macro whose scene this is. + dialog_map: Option, + 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) { + 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::().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( @@ -325,8 +461,6 @@ fn run( ) -> Trace { let began_wall = std::time::Instant::now(); let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1"); - let mut stub_hold = 0usize; - let mut stub_next_ms = f64::NEG_INFINITY; let mut emulator = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES) .expect("binjgb should accept the cartridge"); let mut adapter = PokemonRedReward::new(); @@ -339,34 +473,28 @@ fn run( .or(preset.exclusive.as_ref()) .expect("the preset has a group") .hold_ms; - let blocked_ms = - preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms; let agent_config = AgentConfig::with_decoder(preset); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); - - emulator - .import_state(&checkpoint.runtime.emulator) - .expect("the checkpoint's emulator state should import"); - adapter - .import_state(&checkpoint.runtime.reward) - .expect("the checkpoint's reward ledger should import"); - let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| { - flybrain_gb::ratchet::Snapshot { - game: checkpoint.runtime.ratchet_game.clone(), - frame: checkpoint.runtime.ratchet_frame.clone(), - } - }); - ratchet - .import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len()) - .expect("the checkpoint's ratchet state should import"); - 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")); } - agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import"); - let (width, height) = (agent.frame.width, agent.frame.height); - agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); + // 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(); @@ -391,239 +519,90 @@ fn run( let began_ms = agent.network.ms; let until = began_ms + minutes * MINUTE_MS; - let mut frame = emulator.framebuffer().to_vec(); - let mut payouts: Vec = Vec::new(); - let mut location = adapter.location(); - let mut blocked_since_ms = began_ms; - let mut held_channel: Option = agent.decoder.current().map(str::to_string); - let mut rank = adapter.progress().rank; - let mut running: Option = None; + 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; - // The scene of the frames in a row, for "stuck in a text box" against "in and out of one". - let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms); - let mut 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 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 rewards: Vec = payouts - .iter() - .map(|event| { - NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) - }) - .collect(); - let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; - let ms = agent.network.ms; - let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms) - .then(|| agent.decoder.current().map(str::to_string)) - .flatten(); - let bound = macros.as_ref().map(MacroLayer::bound_channels); - let result = agent - .tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref()) - .expect("a tick"); - // 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. - let active: Vec = if stub { - let hot = STUB_CHANNELS[(stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()]; - if ms >= stub_next_ms { - stub_next_ms = ms + hold_ms; - stub_hold += 1; - } - bound - .as_deref() - .unwrap_or_default() - .iter() - .filter(|channel| *channel == hot) - .cloned() - .collect() - } else { - result.active.clone() + let mut parts = Parts { + agent: &mut agent, + emulator: &mut emulator, + adapter: &mut adapter, + ratchet: &mut ratchet, + macros: macros.as_mut(), }; - let held = agent.decoder.current().map(str::to_string); - if held != held_channel { - held_channel = held; - blocked_since_ms = ms; - } - - let ms = agent.network.ms; - let mut mask = to_button_mask(&active); - // Read before `decide`, because `decide` is what starts the macro whose scene this is. - let layer_scene = macros.as_ref().map_or("", MacroLayer::scene_name); - let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown") - .then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map)) - .flatten(); - let battle_sub = battle_sub_state(&mut emulator); - if let Some(sub) = battle_sub { - *trace.battle_frames.entry(sub).or_insert(0) += 1; - let pad = trace.battle_pads.entry(sub).or_default(); - for channel in bound.as_deref().unwrap_or_default() { - pad.insert(channel.clone()); - } - } - if let Some(layer) = macros.as_mut() { - let ledger = AdapterLedger(&adapter); - let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger); - mask = decision.mask; - for event in &decision.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) = dialog_map { - *trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1; - } - if let Some(sub) = 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(&mut emulator) - .and_then(|battle| battle.own) - .zip(slot.parse::().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(&mut emulator, id) - == Some(true) - }) { - trace.move_starts.1 += 1; - } - } - 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) = 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(&mut emulator); - let (corners, border) = - flybrain_gb::pokemon_red::state::dialog_border(&mut 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) = macros.as_ref() { - let name = layer.scene_name(); - *trace.scenes.entry(name).or_insert(0) += 1; - if name == scene_run.0 { - scene_run.1 += 1; - } else { - scene_run = (name, 1, ms); - } - let longest = trace.longest_scene.entry(name).or_insert((0, 0.0)); - if scene_run.1 > longest.0 { - *longest = (scene_run.1, scene_run.2 - 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(&mut emulator) - .map(|player| (player.map, player.x, player.y)); - if let Some(key) = where_ { - *trace.dialog_frames.entry(key).or_insert(0) += 1; - } - } - } - emulator.set_buttons(mask as u8); - emulator.run_frame().expect("a frame should complete"); - trace.frames += 1; - frame.copy_from_slice(emulator.framebuffer()); - - payouts = adapter.sample(&mut emulator, ms); - for payout in &payouts { - let entry = trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0)); + 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 = - emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0; - let won = payouts.iter().any(|payout| matches!(payout.kind, "battle" | "trainer")); + 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; @@ -638,19 +617,15 @@ fn run( (false, None) => {} } } - if let Some(layer) = macros.as_mut() { - let ledger = AdapterLedger(&adapter); - let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); - } if trace_every_ms > 0.0 && ms >= next_trace { next_trace = ms + trace_every_ms; - let scene = macros.as_ref().map_or("", MacroLayer::scene_name); + 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(&mut emulator).err(); - let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); + 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| { @@ -658,22 +633,18 @@ fn run( 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(&mut emulator); + 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 now = adapter.location(); - if now.is_some() && now != location { - location = now; - blocked_since_ms = ms; - } + let location = frame.location; if let Some((map, x, y)) = location { - trace.steps.push((ms, map, x, y)); + hunt.trace.steps.push((ms, map, x, y)); } - if let Some(run) = running.as_mut() { + if let Some(run) = hunt.running.as_mut() { run.frames += 1; if let Some(at) = location { run.tiles.insert(at); @@ -685,47 +656,18 @@ fn run( } } - let progress = adapter.progress(); + let progress = transition.evaluated.progress; if progress.rank != rank { rank = progress.rank; - trace.rungs.push((rank, progress.rank_label, ms - began_ms)); + hunt.trace.rungs.push((rank, progress.rank_label, ms - began_ms)); } - let safe = adapter.safe_for_snapshot(); - let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; - let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { - game: emulator.export_state().expect("state export"), - frame: frame.clone(), - }); - let recover = ratchet.observe_with_game_over( - safe, - u64::from(progress.rank), - progress.unique_locations as u64, - ms as u64, - adapter.game_over(), - || captured.expect("the ratchet only captures when a snapshot was prepared"), - ); - if recover { - let snapshot = flybrain_gb::ratchet::Snapshot { - game: ratchet.game().expect("a recovery has a snapshot").to_vec(), - frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(), - }; - let restored = { - let mut neural = AgentRecovery { agent: &mut agent }; - recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot) - .expect("recovering the game") - }; - frame.copy_from_slice(&restored); - emulator.set_buttons(0); - trace.recoveries += 1; - location = adapter.location(); - held_channel = None; - blocked_since_ms = ms; - if let Some(layer) = macros.as_mut() { - layer.cancel(ms); - } - running = None; + 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(), diff --git a/services/flysim/crates/flysim/src/frame.rs b/services/flysim/crates/flysim/src/frame.rs index eb48f61..ebc88b9 100644 --- a/services/flysim/crates/flysim/src/frame.rs +++ b/services/flysim/crates/flysim/src/frame.rs @@ -332,6 +332,25 @@ impl LegacyFrame { Ok(Transition { ticks, ms, bound, active, executed, audio, evaluated }) } + /// The rest of phase B and phase C behind a stub readout, for the drivers that measure the + /// macros without a brain (the ROM tests, the scene probe). The driver decodes its stub, + /// calls [`LegacyFrame::execute`] with no raw mask, reads what it measures, and then this runs + /// the frame and evaluates it. There is no commit and no ratchet. + /// + /// A stub has no phase A to advance its clock in, so it keeps its own and advances it with the + /// emulator frame: it decides at its clock and evaluates at `evaluate_ms`, one frame later. + pub fn stub_advance( + &mut self, + macros: Option<&mut MacroLayer>, + emulator: &mut Emulator, + adapter: &mut dyn GameAdapter, + evaluate_ms: f64, + ) -> Result { + self.run(emulator)?; + let _ = self.take_frame(emulator); + Ok(self.evaluate(emulator, adapter, macros, evaluate_ms)) + } + /// Phase A: brain ticks and the decode, masked to `bound`. pub fn prepare( &mut self, @@ -427,12 +446,6 @@ impl LegacyFrame { Ok(self.take_frame(emulator)) } - /// [`LegacyFrame::advance`] without a brain, for the stub-readout drivers. - pub fn advance_stub(&mut self, emulator: &mut Emulator) -> Result> { - self.run(emulator)?; - Ok(self.take_frame(emulator)) - } - fn run(&mut self, emulator: &mut Emulator) -> Result<()> { emulator.set_buttons(self.buttons as u8); emulator diff --git a/services/flysim/crates/flysim/tests/rom_catch.rs b/services/flysim/crates/flysim/tests/rom_catch.rs index 014048b..f731ebd 100644 --- a/services/flysim/crates/flysim/tests/rom_catch.rs +++ b/services/flysim/crates/flysim/tests/rom_catch.rs @@ -43,6 +43,7 @@ use flybrain_gb::{ AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, }; use flysim::config::Config; +use flysim::frame::LegacyFrame; use flysim::macros::{MacroLayer, macro_layer}; use flysim::snapshot::MacroMode; @@ -92,6 +93,8 @@ struct Run { gb: Emulator, adapter: PokemonRedReward, layer: MacroLayer, + /// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout. + legacy: LegacyFrame, decoder: PopulationDecoder, channels: Vec<&'static str>, ms: f64, @@ -124,6 +127,7 @@ impl Run { gb, adapter, layer, + legacy: LegacyFrame::new(), decoder, channels, ms: 0.0, @@ -161,18 +165,14 @@ impl Run { }; let bound = self.layer.bound_channels(); let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); - let mask = { - let ledger = AdapterLedger(&self.adapter); - self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger).mask - }; - self.gb.set_buttons(mask as u8); - self.gb.run_frame().expect("a frame should complete"); + self.legacy.execute(Some(&mut self.layer), &active, 0, self.ms, &mut self.gb, &self.adapter); self.ms += MS_PER_FRAME; self.frame += 1; - let ms = self.ms; - self.payouts.extend(self.adapter.sample(&mut self.gb, ms)); - let ledger = AdapterLedger(&self.adapter); - let _ = self.layer.observe(&mut self.gb, &ledger, ms); + let evaluated = self + .legacy + .stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms) + .expect("a frame should complete"); + self.payouts.extend(evaluated.rewards); } fn catches(&self) -> Vec<&RewardEvent> { diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 8377f7d..af890a2 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -42,6 +42,7 @@ use flybrain_gb::{ AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons, }; use flysim::config::Config; +use flysim::frame::LegacyFrame; use flysim::macros::{MacroLayer, macro_layer}; use flysim::snapshot::MacroMode; @@ -147,6 +148,8 @@ struct Run { gb: Emulator, adapter: PokemonRedReward, layer: MacroLayer, + /// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout. + frame: LegacyFrame, /// The readout under test: the shipping decoder, fed by hand. decoder: PopulationDecoder, /// The macro channels, in the decoder's own order, for the rotation. @@ -402,6 +405,7 @@ impl Run { gb, adapter, layer, + frame: LegacyFrame::new(), decoder, channels, ms, @@ -520,6 +524,7 @@ impl Run { gb, adapter, layer, + frame: LegacyFrame::new(), decoder, channels, ms, @@ -815,9 +820,15 @@ impl Run { 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, blocked, done) = { + let decision = self.frame.execute( + Some(&mut self.layer), + &active, + 0, + self.ms, + &mut self.gb, + &self.adapter, + ); let started: Vec<&'static str> = decision .events .iter() @@ -840,7 +851,7 @@ impl Run { }) .map(|event| event.name) .collect(); - (decision.mask, started, blocked, done) + (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. @@ -963,15 +974,10 @@ impl Run { 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); - } + self.frame + .stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms) + .expect("a frame should complete"); // 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) {