Merge refactor/legacy-frame: FND-01, one LegacyFrame for the service and every harness, FLY_TRACE and the sugar journal

This commit is contained in:
acamilo 2026-09-23 21:43:45 +00:00
commit 4d82f7db74
18 changed files with 2033 additions and 1037 deletions

View file

@ -20,7 +20,8 @@ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
cargo run --release -p flysim --example palette_bench cargo run --release -p flysim --example palette_bench
``` ```
Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real Both arms are the sim loop's own frame order (`flysim::frame::LegacyFrame` since 2026-09-23;
before that `NeuralAgent::tick`'s, one frame behind the stream) over the real connectome (`data/fafb-v783`), the real
Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward
catalog, and the real ratchet on the adapter's own recovery policy. The only difference between catalog, and the real ratchet on the adapter's own recovery policy. The only difference between
them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it, them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it,

View file

@ -151,6 +151,15 @@ window every 15 brain seconds, so a loop is caught wherever it starts) in which
A window with no macro in it is not flagged: silence waits, and that is the doctrine working. A window with no macro in it is not flagged: silence waits, and that is the doctrine working.
**2026-09-23, FND-01.** The hunt now runs `flysim::frame::LegacyFrame`, the frame the service
runs, restored the way the service restores (no held channel, no location, the blocked window at
brain time 0). Before that it ticked the brain through `NeuralAgent::tick`, one frame behind the
stream: each frame and its rewards reached the brain after the next ticks, the ratchet was
observed without the objective signal, and a rollback did not re-observe the scene. Hunts from
before and after the change are not comparable number for number; compare two arms built from
the same side of it. `FLY_TRACE=<path>` writes the run in the service's own per-frame trace
format (`flysim::trace`), so a hunt can be diffed against the service from the same checkpoint.
```sh ```sh
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \ FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \

View file

@ -508,6 +508,7 @@ dependencies = [
"jsonschema", "jsonschema",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",

View file

@ -259,10 +259,10 @@ impl NeuralAgent {
/// scene has put on the pad (`docs/design/macros.md` section 12). A host with no macro group /// 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. /// -- 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 /// `flysim` does not come through here: its frame (`flysim::frame::LegacyFrame`) drives the
/// itself, so that the macro layer can read the emulator between the two), but the bench that /// network and the decoder itself, so that the macro layer can read the emulator between the
/// measures the two arms against each other does, and a bench whose macro group could win a /// two, and it installs a frame and its rewards straight after the frame rather than after the
/// channel the scene never bound would be measuring something the stream cannot do. /// next ticks. Every `flysim` harness runs that frame too.
pub fn tick_bound( pub fn tick_bound(
&mut self, &mut self,
frame: &[u8], frame: &[u8],

View file

@ -37,6 +37,7 @@ axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { version = "1", features = [ tokio = { version = "1", features = [
"rt-multi-thread", "rt-multi-thread",
"net", "net",

View file

@ -70,19 +70,20 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use flybrain_core::agent::{ use flybrain_core::agent::{
AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions, AgentConfig, NeuralAgent,
}; };
use flybrain_core::dataset::load_brain_dataset_from_dir; 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_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter; use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet; 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 flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer}; use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
/// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up. /// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up.
const REDS_HOUSE_2F: u32 = 0x26; const REDS_HOUSE_2F: u32 = 0x26;
@ -103,25 +104,12 @@ fn emulator(rom: &[u8]) -> Emulator {
.expect("binjgb should accept the cartridge") .expect("binjgb should accept the cartridge")
} }
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it. /// Whether a macro owned the buttons of a frame: read as the executor hands the mask over.
struct AgentRecovery<'a> { struct MacroOwned(bool);
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> { impl FrameObserver for MacroOwned {
fn clear_decoder_holds(&mut self) { fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) {
let ms = self.agent.network.ms; self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some());
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);
} }
} }
@ -244,11 +232,9 @@ fn boot_to_bedroom(rom: &[u8]) -> Vec<u8> {
/// One arm: `hours` brain hours of the sim loop's frame order, unthrottled. /// 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 frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the
/// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer /// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the
/// is consulted at exactly the two points the loop consults it — after the decode, before the /// brain through `NeuralAgent::tick`, one frame behind the stream's order.)
/// 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.
fn run_arm( fn run_arm(
rom: &[u8], rom: &[u8],
data: &Arc<flybrain_core::dataset::BrainDataset>, data: &Arc<flybrain_core::dataset::BrainDataset>,
@ -270,48 +256,39 @@ fn run_arm(
.or(preset.exclusive.as_ref()) .or(preset.exclusive.as_ref())
.expect("the preset has a group") .expect("the preset has a group")
.hold_ms; .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 agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut arm = Arm { mode: mode.as_str(), ..Arm::default() }; let mut arm = Arm { mode: mode.as_str(), ..Arm::default() };
match start { if let Start::Fresh { warmup_ms, .. } = start {
Start::Fresh { state, warmup_ms } => {
emulator.import_state(state).expect("the booted state should import");
agent_config.warmup_ms = *warmup_ms; 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");
}
}
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); 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 { match start {
Start::Fresh { .. } => { Start::Fresh { state, warmup_ms: _ } => {
agent.warmup(Some(emulator.framebuffer())).expect("warm-up"); emulator.import_state(state).expect("the booted state should import");
} frame.frame_buffer.copy_from_slice(emulator.framebuffer());
Start::Live { checkpoint } => { agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
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.
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 // 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 began_ms = agent.network.ms;
let until = began_ms + hours * HOUR_MS; let until = began_ms + hours * HOUR_MS;
let mut frame = emulator.framebuffer().to_vec();
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
let mut location = adapter.location();
let mut blocked_since_ms = began_ms;
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
let mut rank = adapter.progress().rank; let mut rank = adapter.progress().rank;
let tiles_at_start = adapter.progress().unique_locations; let tiles_at_start = adapter.progress().unique_locations;
arm.rungs.push((rank, adapter.progress().rank_label, 0.0)); 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, // One observation before the first frame, as the sim loop takes after a restore, so frame one
// which presses nothing. That is one frame, and it is the honest starting state. // is decided on a real palette.
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
} }
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: macros.as_mut(),
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; };
let mut owned = MacroOwned(false);
// The blocked-direction cooldown's input, as `simloop.rs` computes it. let transition = frame.transition(&mut parts, &mut owned).expect("a frame");
let ms = agent.network.ms; let ms = transition.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms) let executed = &transition.executed;
.then(|| agent.decoder.current().map(str::to_string)) if let Some(layer) = parts.macros.as_deref() {
.flatten(); if let Some(silence) = executed.silence {
// 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 {
*arm.silence.entry(silence.label()).or_insert(0) += 1; *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; *arm.by_rank.entry(event.slot).or_insert(0) += 1;
} }
if layer.running().is_some() { if owned.0 {
arm.macro_frames += 1; 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; arm.idle_frames += 1;
} }
emulator.set_buttons(mask as u8);
emulator.run_frame().expect("a frame should complete");
arm.frames += 1; arm.frames += 1;
frame.copy_from_slice(emulator.framebuffer()); for event in &transition.evaluated.rewards {
// Steps 7 to 9, then the scene.
payouts = adapter.sample(&mut emulator, ms);
for event in &payouts {
arm.reward += event.value; arm.reward += event.value;
*arm.payouts.entry(event.kind).or_insert(0) += 1; *arm.payouts.entry(event.kind).or_insert(0) += 1;
} }
if let Some(layer) = macros.as_mut() { arm.digest = hash(arm.digest, u64::from(executed.mask));
let ledger = AdapterLedger(&adapter); if let Some((map, x, y)) = frame.location {
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(map) << 32 | u64::from(x) << 16 | u64::from(y)); 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 = transition.evaluated.progress;
let progress = adapter.progress();
if progress.rank != rank { if progress.rank != rank {
rank = progress.rank; rank = progress.rank;
arm.rungs.push((rank, progress.rank_label, ms - began_ms)); arm.rungs.push((rank, progress.rank_label, ms - began_ms));
} }
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
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);
arm.recoveries += 1; 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);
}
} }
} }

View file

@ -77,7 +77,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use flybrain_core::agent::{ 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::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask}; 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::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; 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. /// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house.
const REDS_HOUSE_2F: u32 = 0x26; 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 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. /// Where one brain run starts.
enum Start<'a> { enum Start<'a> {
/// A save state and the map it stands in: a fresh fly, warmed up here. /// 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) (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<flybrain_core::decoder::DecoderState>,
hold_start: (Option<(u32, u32, u32)>, f64),
run_winner: Option<String>,
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 /// 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. /// live checkpoint -- the real reward ledger and the real ratchet.
/// ///
@ -697,174 +766,81 @@ fn brain_trace(
let mut emulator = emulator(rom); let mut emulator = emulator(rom);
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
let mut agent_config = AgentConfig::with_decoder(config.clone()); let mut agent_config = AgentConfig::with_decoder(config.clone());
let (hold_ms, blocked_ms) = { let hold_ms =
let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group"); config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms;
(group.hold_ms, group.blocked_ms)
};
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut trace = Trace::default(); let mut trace = Trace::default();
if let Start::Fresh { warmup_ms, .. } = &start {
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; 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))
}
};
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
match &start { // The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer.
Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"), 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 } => { Start::Live { checkpoint, rng } => {
let mut state = checkpoint.agent.clone(); let mut checkpoint = (*checkpoint).clone();
state.network.rng = *rng; checkpoint.agent.network.rng = *rng;
agent.import_state(&state).expect("the checkpoint's agent state should import"); frame
let (width, height) = (agent.frame.width, agent.frame.height); .restore(
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); &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); trace.maps.push(start_map);
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + minutes * 60_000.0; 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: let mut escape = Escape {
// the frame and the payouts handed to a tick are the ones the previous tick's buttons produced. before: None,
let mut frame = emulator.framebuffer().to_vec(); hold_start: (frame.location, began_ms),
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new(); run_winner: None,
let mut location = adapter.location(); run_length: 0.0,
// The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly start_map,
// as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet. exits,
let mut blocked_since_ms = began_ms; hold_ms,
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string); trace,
let mut hold_start = (location, began_ms); };
let mut run_winner: Option<String> = None;
let mut run_length = 0.0f64;
// Reported on its own: the longest stretch with no movement at all, whatever was held. // Reported on its own: the longest stretch with no movement at all, whatever was held.
let mut still_since_ms = began_ms; let mut still_since_ms = began_ms;
let tiles_at_start = adapter.progress().unique_locations; let tiles_at_start = adapter.progress().unique_locations;
trace.tiles = tiles_at_start; escape.trace.tiles = tiles_at_start;
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: None,
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
// 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]; let location_before = frame.location;
for channel in DIRECTIONS.iter().skip(1) { let transition = frame.transition(&mut parts, &mut escape).expect("a frame");
if adjusted(channel) > adjusted(argmax) { let ms = transition.ms;
argmax = channel; let trace = &mut escape.trace;
} trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::<f64>();
} trace.tiles = transition.evaluated.progress.unique_locations;
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 let location = frame.location;
// the previous decision, for the direction that was held in between. if location != location_before {
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::<f64>();
trace.tiles = adapter.progress().unique_locations;
let now = adapter.location();
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
still_since_ms = ms; still_since_ms = ms;
} }
trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_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; *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. // The ratchet, with the adapter's own policy and the checkpoint's own budget.
let progress = adapter.progress(); let progress = transition.evaluated.progress;
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { escape.trace.recoveries += 1;
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;
still_since_ms = ms; 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 }; let Some(map) = adapter.map_id() else { continue };
if trace.maps.last() != Some(&map) { 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 { if run_length > 0.0 {
trace.runs.push(run_length); trace.runs.push(run_length);
} }

View file

@ -808,6 +808,8 @@ fn accept_survey(
// [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor // [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. // bytes say "the move list" -- which is the whole of what the seam read before row 50.
let mut readings = [[0usize; 2]; 2]; 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!("\n## Row 50: every battle frame, pressed at\n");
println!("```"); println!("```");
@ -823,18 +825,9 @@ fn accept_survey(
} }
let bound = layer.bound_channels(); let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound)); let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound));
let mask = { legacy.execute(Some(&mut *layer), &active, 0, *ms, gb, &*adapter);
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");
*ms += MS_PER_FRAME; *ms += MS_PER_FRAME;
adapter.sample(gb, *ms); legacy.stub_advance(Some(&mut *layer), gb, adapter, *ms).expect("a frame should complete");
{
let ledger = AdapterLedger(adapter);
let _ = layer.observe(gb, &ledger, *ms);
}
let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue }; let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue };
let geom = move_cursor_geometry(gb); let geom = move_cursor_geometry(gb);
@ -1801,6 +1794,8 @@ fn main() {
let mut noattack = 0usize; let mut noattack = 0usize;
let mut before = (0u8, 0u8, 0u8); let mut before = (0u8, 0u8, 0u8);
let mut surveyed = 0usize; 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 { for frame in 0..budget {
let bursting = ms < next_burst + BURST_MS; let bursting = ms < next_burst + BURST_MS;
let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]); let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]);
@ -1810,18 +1805,11 @@ fn main() {
} }
let bound = layer.bound_channels(); let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound)); let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound));
let mask = { legacy.execute(Some(&mut layer), &active, 0, ms, &mut gb, &adapter);
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");
ms += MS_PER_FRAME; ms += MS_PER_FRAME;
adapter.sample(&mut gb, ms); legacy
{ .stub_advance(Some(&mut layer), &mut gb, &mut adapter, ms)
let ledger = AdapterLedger(&adapter); .expect("a frame should complete");
let _ = layer.observe(&mut gb, &ledger, ms);
}
if catch_script if catch_script
&& gb.read8(ram::wSimulatedJoypadStatesIndex) != 0 && gb.read8(ram::wSimulatedJoypadStatesIndex) != 0
&& gb.read8(ram::wCurMap) == 1 && gb.read8(ram::wCurMap) == 1

View file

@ -36,9 +36,11 @@
//! | `FLY_TRAP_THREADS` | 4 | sweep threads | //! | `FLY_TRAP_THREADS` | 4 | sweep threads |
//! | `FLY_TRAP_SEED` | 20260917 | seeds the palette | //! | `FLY_TRAP_SEED` | 20260917 | seeds the palette |
//! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) | //! | `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 //! The frame is `flysim::frame::LegacyFrame`, the one the stream runs, restored the way the stream
//! measures is the loop that ships rather than a second implementation of it. Without a //! 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 //! checkpoint it refuses rather than booting the intro: a trap hunt is about a state the stream
//! was actually in. //! was actually in.
@ -46,18 +48,19 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; 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::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_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter; use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator}; use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator};
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
#[path = "support/ledgers.rs"] #[path = "support/ledgers.rs"]
mod ledgers; 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) 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. /// 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. /// A refused macro and the `(map, x, y)` it was refused on.
type RefusedAt = (&'static str, Option<(u32, u32, u32)>); type RefusedAt = (&'static str, Option<(u32, u32, u32)>);
@ -312,6 +293,161 @@ struct Trap {
macros: usize, macros: usize,
} }
/// The hunt's look inside the stream's frame (`flysim::frame`): what it reads before and after
/// the executor decides, and the stub readout. Everything else is the frame's own order.
struct Hunt {
stub: bool,
stub_hold: usize,
stub_next_ms: f64,
hold_ms: f64,
running: Option<Running>,
scene_run: (&'static str, u64, f64),
/// Read before `decide`, because `decide` is what starts the macro whose scene this is.
dialog_map: Option<u8>,
battle_sub: Option<&'static str>,
trace: Trace,
}
impl FrameObserver for Hunt {
/// The brain is still ticked -- the frame order, the plasticity and the cost are the run's --
/// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses and
/// in nothing else.
fn readout(&mut self, ms: f64, bound: Option<&[String]>, active: &mut Vec<String>) {
if !self.stub {
return;
}
let hot = STUB_CHANNELS[(self.stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
if ms >= self.stub_next_ms {
self.stub_next_ms = ms + self.hold_ms;
self.stub_hold += 1;
}
*active = bound.unwrap_or_default().iter().filter(|channel| *channel == hot).cloned().collect();
}
fn before_execute(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
let layer_scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
self.dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(parts.emulator).map(|p| p.map))
.flatten();
self.battle_sub = battle_sub_state(parts.emulator);
if let Some(sub) = self.battle_sub {
*self.trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = self.trace.battle_pads.entry(sub).or_default();
for channel in parts.macros.as_deref().map(MacroLayer::bound_channels).unwrap_or_default() {
pad.insert(channel);
}
}
}
fn executed(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, executed: &Executed) {
let ms = parts.agent.network.ms;
let location = frame.location;
let trace = &mut self.trace;
for event in &executed.events {
match event.outcome {
None => {
trace.starts.push((ms, event.name));
// Which press answered a box, and on which map: 991 `YES` in twenty brain
// minutes is a fact about one conversation, and this is what says which.
if let Some(map) = self.dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = self.battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
if let Some(battle) = trace.battle_now.as_mut() {
battle.1 += 1;
}
if let Some(slot) = event.name.strip_prefix("MOVE ") {
trace.move_starts.0 += 1;
let id = flybrain_gb::pokemon_red::state::battle(parts.emulator)
.and_then(|battle| battle.own)
.zip(slot.parse::<usize>().ok())
.and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten())
.map(|entry| entry.id);
if id.is_some_and(|id| {
flybrain_gb::pokemon_red::state::move_without_effect(parts.emulator, id)
== Some(true)
}) {
trace.move_starts.1 += 1;
}
}
self.running = Some(Running {
name: event.name,
from: location,
tiles: location.into_iter().collect(),
frames: 0,
reach: 0,
});
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = self.running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
ax.abs_diff(x) + ay.abs_diff(y)
}
_ => 0,
};
trace.episodes.push(Episode {
name: run.name,
outcome: outcome.as_str(),
frames: run.frames,
tiles: run.tiles.len(),
net,
reach: run.reach,
});
}
}
}
}
{
let text = flybrain_gb::pokemon_red::state::text_box(parts.emulator);
let (corners, border) = flybrain_gb::pokemon_red::state::dialog_border(parts.emulator);
match (text.open, corners, border) {
(true, true, true) => trace.font_corners_border += 1,
(true, true, false) => trace.font_corners_no_border += 1,
(true, false, _) => trace.font_no_corners += 1,
(false, true, _) => trace.corners_no_font += 1,
(false, false, _) => {}
}
}
if let Some(layer) = parts.macros.as_deref() {
let name = layer.scene_name();
*trace.scenes.entry(name).or_insert(0) += 1;
if name == self.scene_run.0 {
self.scene_run.1 += 1;
} else {
self.scene_run = (name, 1, ms);
}
let longest = trace.longest_scene.entry(name).or_insert((0, 0.0));
if self.scene_run.1 > longest.0 {
*longest = (self.scene_run.1, self.scene_run.2 - trace.began_ms);
}
// Where the text box is, which is the half the scene histogram could not say.
if name == "dialog" || name == "unknown" {
let where_ = flybrain_gb::pokemon_red::state::player(parts.emulator)
.map(|player| (player.map, player.x, player.y));
if let Some(key) = where_ {
*trace.dialog_frames.entry(key).or_insert(0) += 1;
}
}
}
}
}
/// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where /// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where
/// the fly stood and what it started. /// the fly stood and what it started.
fn run( fn run(
@ -325,8 +461,6 @@ fn run(
) -> Trace { ) -> Trace {
let began_wall = std::time::Instant::now(); let began_wall = std::time::Instant::now();
let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1"); 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) let mut emulator = 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();
@ -339,34 +473,28 @@ fn run(
.or(preset.exclusive.as_ref()) .or(preset.exclusive.as_ref())
.expect("the preset has a group") .expect("the preset has a group")
.hold_ms; .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 agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); 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"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import"); // The stream's own restore, into the stream's own frame: a fresh process's readout transient
let (width, height) = (agent.frame.width, agent.frame.height); // (`restore: legacy-transient-reset`), which is what the fly has after the service restarts.
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); 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(); let mut config = Config::default();
config.loop_.game = "pokemon-red".to_string(); config.loop_.game = "pokemon-red".to_string();
@ -391,21 +519,23 @@ fn run(
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + minutes * MINUTE_MS; let until = began_ms + minutes * MINUTE_MS;
let mut frame = emulator.framebuffer().to_vec(); let rank = adapter.progress().rank;
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
let mut location = adapter.location();
let mut blocked_since_ms = began_ms;
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
let mut rank = adapter.progress().rank;
let mut running: Option<Running> = None;
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it // 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 // 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. // diagnostic and the tables above are the report.
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0; let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
let mut next_trace = began_ms; let mut next_trace = began_ms;
let mut hunt = Hunt {
stub,
stub_hold: 0,
stub_next_ms: f64::NEG_INFINITY,
hold_ms,
running: None,
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one". // 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); scene_run: ("", 0, began_ms),
let mut trace = Trace { dialog_map: None,
battle_sub: None,
trace: Trace {
steps: Vec::new(), steps: Vec::new(),
starts: Vec::new(), starts: Vec::new(),
episodes: Vec::new(), episodes: Vec::new(),
@ -438,192 +568,41 @@ fn run(
refusals: BTreeMap::new(), refusals: BTreeMap::new(),
refusal_run: (None, 0), refusal_run: (None, 0),
longest_refusal_run: (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() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
} }
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: macros.as_mut(),
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<String> = 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 held = agent.decoder.current().map(str::to_string); let transition = frame.transition(&mut parts, &mut hunt).expect("a frame");
if held != held_channel { let ms = transition.ms;
held_channel = held; hunt.trace.frames += 1;
blocked_since_ms = ms; for payout in &transition.evaluated.rewards {
} let entry = hunt.trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
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::<usize>().ok())
.and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten())
.map(|entry| entry.id);
if id.is_some_and(|id| {
flybrain_gb::pokemon_red::state::move_without_effect(&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));
*entry = (entry.0 + 1, entry.1 + payout.value); *entry = (entry.0 + 1, entry.1 + payout.value);
} }
{ {
use flybrain_gb::MemoryReader; use flybrain_gb::MemoryReader;
let trace = &mut hunt.trace;
let fighting = let fighting =
emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0; parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
let won = payouts.iter().any(|payout| matches!(payout.kind, "battle" | "trainer")); let won = transition
.evaluated
.rewards
.iter()
.any(|payout| matches!(payout.kind, "battle" | "trainer"));
match (fighting, trace.battle_now.as_mut()) { match (fighting, trace.battle_now.as_mut()) {
(true, Some(battle)) => { (true, Some(battle)) => {
battle.0 += 1; battle.0 += 1;
@ -638,19 +617,15 @@ fn run(
(false, None) => {} (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 { if trace_every_ms > 0.0 && ms >= next_trace {
next_trace = ms + trace_every_ms; 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}; use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
// Read before the state borrows the emulator: this is the same call the state makes, // 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. // 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 refusal = flybrain_gb::pokemon_red::state::map_grid(parts.emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(parts.emulator);
let state: &mut dyn MacroState = &mut state; let state: &mut dyn MacroState = &mut state;
let player = state.player(); let player = state.player();
let ahead = player.and_then(|player| { let ahead = player.and_then(|player| {
@ -658,22 +633,18 @@ fn run(
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead) flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
}); });
let ground = grid_line(state, player, refusal); 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!( println!(
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}", "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
(ms - began_ms) / MINUTE_MS (ms - began_ms) / MINUTE_MS
); );
} }
let now = adapter.location(); let location = frame.location;
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
}
if let Some((map, x, y)) = 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; run.frames += 1;
if let Some(at) = location { if let Some(at) = location {
run.tiles.insert(at); run.tiles.insert(at);
@ -685,47 +656,18 @@ fn run(
} }
} }
let progress = adapter.progress(); let progress = transition.evaluated.progress;
if progress.rank != rank { if progress.rank != rank {
rank = progress.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 boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { hunt.trace.recoveries += 1;
game: emulator.export_state().expect("state export"), hunt.running = None;
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 mut trace = hunt.trace;
trace.ended_in = ( trace.ended_in = (
macros.as_ref().map_or("", MacroLayer::scene_name), macros.as_ref().map_or("", MacroLayer::scene_name),
adapter.mode().to_string(), adapter.mode().to_string(),

View file

@ -0,0 +1,705 @@
//! The legacy frame: one Game Boy frame of the live loop, in its phases, in the one order.
//!
//! This is the order `simloop.rs` runs on the stream, and the order every harness that claims to
//! measure the stream runs: the trap hunt, the palette bench, the room-escape bench, and the
//! stub-readout drivers of the ROM tests and the scene probe. It used to be written out in each of
//! them, and the copies had drifted: the benches ticked the brain through `NeuralAgent::tick`, which
//! installs the previous frame and its rewards *after* the next ticks, so every bench ran the
//! brain one frame behind the stream. There is one copy now, and the parity oracle is this file.
//!
//! The phases are named for the lockstep transaction they become in the session framework
//! (`docs/design/session-framework/legacy-gameboy-v1.md` section 4):
//!
//! | phase | what it does | lockstep |
//! | --- | --- | --- |
//! | (host) | drains commands: sugar and operator pulses are applied here, before the ticks | admission at `Ready(k)` |
//! | [`LegacyFrame::prepare`] | 16 or 17 brain ticks, the remainder carried; decode with the scene's bound channels and the blocked direction | A: `Agent.Prepare` |
//! | [`LegacyFrame::execute`] | the raw mask, then the macro layer decides the mask | B: the executor |
//! | [`LegacyFrame::advance`] | the joypad, one emulator frame, the framebuffer, the audio | B: `Environment.Advance` |
//! | [`LegacyFrame::evaluate`] | reward events, the macro layer's observation, the location, the rank | C: the task |
//! | [`LegacyFrame::commit`] | install the frame, one stimulation per event, one reinforcement | D: `Agent.Commit` |
//! | (host) | the milestone archive | a capture at `Ready(k+1)` |
//! | [`LegacyFrame::boundary`] | the ratchet's capture and decision, and the rollback when it fires | C decides; slot save and rollback at `Ready(k+1)` |
//!
//! Two moves from the order `simloop.rs` used to spell out, both between operations that touch
//! disjoint state, so neither changes a byte: the visual frame is installed in `commit` rather
//! than straight after the emulator frame (nothing reads the network in between), and the
//! stimulation and reinforcement come after the macro layer's observation and the location
//! (which read the emulator and the adapter, never the network). The milestone archive stays
//! where the stream has it -- after the reinforcement, before the ratchet captures -- which is
//! why the ratchet is its own call after `transition`: the host takes its archive between the
//! two. `FLY_TRACE` (`crate::trace`) records every phase, and a trace of the stream from one
//! checkpoint is byte-identical before and after this extraction.
use anyhow::{Result, anyhow};
use flybrain_core::agent::NeuralAgent;
use flybrain_core::decoder::gameboy::to_button_mask;
use flybrain_gb::RewardEvent;
use flybrain_gb::adapter::{GameAdapter, ProgressSnapshot};
use flybrain_gb::emulator::{Emulator, FRAMEBUFFER_LEN};
use flybrain_gb::macros::AdapterLedger;
use flybrain_gb::ratchet::{Ratchet, Snapshot};
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use crate::macros::{MacroEvent, MacroLayer, Silence};
use crate::trace::FrameTrace;
/// Everything one frame reads and writes besides the frame's own state: the parts the loop owns.
pub struct Parts<'a> {
pub agent: &'a mut NeuralAgent,
pub emulator: &'a mut Emulator,
pub adapter: &'a mut dyn GameAdapter,
pub ratchet: &'a mut Ratchet,
/// `None` in raw mode, where not one line of the macro layer runs.
pub macros: Option<&'a mut MacroLayer>,
}
/// Where a host may look in, or time a phase. Every method defaults to nothing.
///
/// The stream's loop uses [`FrameObserver::after`] for its per-phase profile and nothing else. A
/// harness may read the emulator between phases to measure the run, and a stub-readout harness may
/// replace the decision in [`FrameObserver::readout`]; nothing else about the frame is open.
pub trait FrameObserver {
/// A phase has finished. `agent` is lent for the profiler's kernel timings.
fn after(&mut self, _phase: FramePhase, _agent: &mut NeuralAgent) {}
/// The decoded decision, before the executor sees it. The stream never replaces it; the trap
/// hunt's `FLY_TRAP_STUB` does, and the brain still ticks exactly as it would.
fn readout(&mut self, _ms: f64, _bound: Option<&[String]>, _active: &mut Vec<String>) {}
/// Just before the executor decides: O[k] is on the emulator, the palette is the one dealt
/// for it.
fn before_execute(&mut self, _frame: &LegacyFrame, _parts: &mut Parts<'_>, _active: &[String]) {
}
/// The executor has decided and the mask is not yet on the joypad.
fn executed(&mut self, _frame: &LegacyFrame, _parts: &mut Parts<'_>, _executed: &Executed) {}
}
/// The observer that observes nothing.
impl FrameObserver for () {}
/// The points [`FrameObserver::after`] is called at, in order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FramePhase {
/// The brain ticks are done.
Ticked,
/// The decode and the executor's decision are done.
Executed,
/// The emulator frame has run.
Emulated,
/// The framebuffer and the audio are taken.
Advanced,
/// Rewards are sampled, the scene observed and the transition committed to the brain.
Committed,
}
/// Phase B's result.
#[derive(Debug, Default)]
pub struct Executed {
/// The mask the emulator is given.
pub mask: u32,
/// The macro layer's start and finish events, in order.
pub events: Vec<MacroEvent>,
/// Why nothing was pressed, when nothing was (`crate::macros::Decision::silence`).
pub silence: Option<Silence>,
}
/// Phase C's result.
#[derive(Debug)]
pub struct Evaluated {
/// Reward events from the frame just produced, in adapter order.
pub rewards: Vec<RewardEvent>,
/// The macro layer's own events from observing that frame (at most one abandonment).
pub abandoned: Vec<MacroEvent>,
pub progress: ProgressSnapshot,
}
/// One transition `k -> k+1`, up to and including its commit.
#[derive(Debug)]
pub struct Transition {
/// Brain ticks this frame advanced.
pub ticks: u64,
/// The brain clock after them, which is the clock of every phase that follows.
pub ms: f64,
/// The scene's bound macro channels the decode was masked to; `None` in raw mode.
pub bound: Option<Vec<String>>,
/// The decision the executor was given.
pub active: Vec<String>,
pub executed: Executed,
/// The frame's audio, binjgb's unsigned 8-bit interleaved stereo.
pub audio: Vec<u8>,
pub evaluated: Evaluated,
}
/// Why the ratchet rolled the game back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollbackTrigger {
GameOver,
Stall,
}
/// A rollback at the boundary.
#[derive(Debug)]
pub struct Rollback {
pub trigger: RollbackTrigger,
/// The running macro's abandonment and the restored scene's observation, in order.
pub events: Vec<MacroEvent>,
}
/// What happened at `Ready(k+1)`.
#[derive(Debug, Default)]
pub struct Boundary {
/// The ratchet captured a slot this boundary.
pub captured: bool,
pub rollback: Option<Rollback>,
}
/// The neural half of a ratchet recovery, wired to `flybrain-core`.
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);
}
}
/// The frame's own state: the clock remainder, the frame counter, the frame on screen, the mask,
/// and the readout's blocked-direction window.
///
/// The window (`docs/readout.md`) is the player's area and tile as of the last frame the adapter
/// reported one, the channel the group is holding, and the brain clock at which *either* of those
/// last changed. A direction is only blamed once it has been held for a whole `blocked_ms` with no
/// movement, so a direction that has just won is never blamed for a wall the previous one hit.
/// All three are transient and never checkpointed: one hold of a wall after a restart is cheaper
/// than a stale position surviving a restore (`restore: legacy-transient-reset`).
pub struct LegacyFrame {
/// Fractional millisecond carried into the next frame; checkpointed.
pub remainder: f64,
/// Frames the emulator has run in this fly's life; checkpointed.
pub frame_counter: u64,
/// The frame on screen: the last one produced, or a restored slot's.
pub frame_buffer: Vec<u8>,
/// The mask on the joypad; checkpointed.
pub buttons: u32,
pub location: Option<(u32, u32, u32)>,
pub held_channel: Option<String>,
pub blocked_since_ms: f64,
trace: Option<FrameTrace>,
}
impl Default for LegacyFrame {
fn default() -> Self {
Self::new()
}
}
impl LegacyFrame {
/// The state of a fresh process: nothing held, no location, the blocked window starting at
/// brain time 0 (legacy-gameboy-v1 section 14), and a black frame.
pub fn new() -> Self {
Self {
remainder: 0.0,
frame_counter: 0,
frame_buffer: vec![0u8; FRAMEBUFFER_LEN],
buttons: 0,
location: None,
held_channel: None,
blocked_since_ms: 0.0,
trace: None,
}
}
/// Record every phase into `trace` (`FLY_TRACE`).
pub fn with_trace(mut self, trace: Option<FrameTrace>) -> Self {
self.trace = trace;
self
}
/// The trace, when one is on: a host records its admissions and captures through it.
pub fn trace_mut(&mut self) -> Option<&mut FrameTrace> {
self.trace.as_mut()
}
// -- setup -------------------------------------------------------------------------------
/// A fresh start: one frame with no button down, then the brain's warm-up on it
/// (`Environment.Initialize`, legacy-gameboy-v1 section 9). Returns that frame's audio.
pub fn initialize(
&mut self,
emulator: &mut Emulator,
agent: &mut NeuralAgent,
) -> Result<Vec<u8>> {
emulator
.run_frame()
.map_err(|error| anyhow!("running the first frame: {error}"))?;
self.frame_buffer.copy_from_slice(emulator.framebuffer());
self.frame_counter = 1;
let audio = emulator.take_audio_u8();
agent
.warmup(Some(&self.frame_buffer))
.map_err(|error| anyhow!("{error}"))?;
Ok(audio)
}
/// Everything a `FLYSIM01` checkpoint restores into the parts and the frame, in the order the
/// stream restores it; the host checks the cartridge and the compatibility string first.
///
/// The readout transient is left as a fresh process has it (`legacy-transient-reset`), which
/// is what a restart of the service gives the fly. `import_state` of the agent is
/// self-validating, so a refused checkpoint leaves the agent as it was.
pub fn restore(
&mut self,
parts: &mut Parts<'_>,
checkpoint: &crate::store::Checkpoint,
) -> Result<()> {
let runtime = &checkpoint.runtime;
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
anyhow::bail!(
"checkpoint framebuffer is {} bytes",
runtime.framebuffer.len()
);
}
parts
.agent
.import_state(&checkpoint.agent)
.map_err(|error| anyhow!("{error}"))?;
parts
.emulator
.import_state(&runtime.emulator)
.map_err(|error| anyhow!("{error}"))?;
if !runtime.reward.is_null() {
parts
.adapter
.import_state(&runtime.reward)
.map_err(|error| anyhow!("{error}"))?;
}
let snapshot = if runtime.ratchet_game.is_empty() {
None
} else {
Some(Snapshot {
game: runtime.ratchet_game.clone(),
frame: runtime.ratchet_frame.clone(),
})
};
parts
.ratchet
.import(
Some(runtime.ratchet),
snapshot,
parts.adapter.rank_ladder().len(),
)
.map_err(|error| anyhow!("{error}"))?;
self.remainder = checkpoint.agent.remainder;
self.frame_counter = runtime.emulator_frame;
self.buttons = runtime.buttons;
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
let (width, height) = (parts.agent.frame.width, parts.agent.frame.height);
parts
.agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
parts.emulator.set_buttons(self.buttons as u8);
Ok(())
}
// -- the transition ----------------------------------------------------------------------
/// Transition `k -> k+1`, prepare through commit. The host takes its milestone archive after
/// this and then calls [`LegacyFrame::boundary`].
pub fn transition(
&mut self,
parts: &mut Parts<'_>,
observer: &mut dyn FrameObserver,
) -> Result<Transition> {
let ticks = self.tick(parts.agent);
observer.after(FramePhase::Ticked, parts.agent);
// Not the mode string: the adapter decides what counts as boot, because a platformer
// needs the permissive Start variant in four of its five modes (`GameAdapter::boot`).
let boot = parts.adapter.boot();
// The scene's own macro buttons, for the macro group's per-decision mask
// (`docs/design/macros.md` section 12: "unbound channels are masked from the decision").
// They are the bindings the previous frame's `observe` dealt, which is the palette the
// page is showing, so the fly is choosing among exactly the buttons the audience can see.
let bound = parts.macros.as_deref().map(MacroLayer::bound_channels);
let mut active = self.decode(parts.agent, boot, bound.as_deref());
let ms = parts.agent.network.ms;
observer.readout(ms, bound.as_deref(), &mut active);
observer.before_execute(self, parts, &active);
let raw = to_button_mask(&active);
let executed = self.execute(
parts.macros.as_deref_mut(),
&active,
raw,
ms,
parts.emulator,
&*parts.adapter,
);
observer.executed(self, parts, &executed);
observer.after(FramePhase::Executed, parts.agent);
let audio = self.advance(parts.emulator, parts.agent, observer)?;
observer.after(FramePhase::Advanced, parts.agent);
let evaluated = self.evaluate(
parts.emulator,
parts.adapter,
parts.macros.as_deref_mut(),
ms,
);
self.commit(parts.agent, &evaluated.rewards, ms);
observer.after(FramePhase::Committed, parts.agent);
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<Evaluated> {
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,
agent: &mut NeuralAgent,
boot: bool,
bound: Option<&[String]>,
) -> (u64, Vec<String>) {
let ticks = self.tick(agent);
(ticks, self.decode(agent, boot, bound))
}
/// Phase A, the ticks: 16 or 17 whole milliseconds, the fraction carried to the next frame.
pub fn tick(&mut self, agent: &mut NeuralAgent) -> u64 {
if let Some(trace) = self.trace.as_mut() {
trace.begin(self.frame_counter, agent.network.ms);
}
self.remainder += agent.ms_per_frame;
let steps = self.remainder.floor();
self.remainder -= steps;
agent.network.step(steps as u64);
if let Some(trace) = self.trace.as_mut() {
trace.ticked(steps as u64, self.remainder, &agent.network);
}
steps as u64
}
/// Phase A, the readout: decode the rates with the blocked direction and the bound channels,
/// and restart the blocked window when the held channel changes.
pub fn decode(
&mut self,
agent: &mut NeuralAgent,
boot: bool,
bound: Option<&[String]>,
) -> Vec<String> {
let ms = agent.network.ms;
let rates = agent.network.rates.clone();
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group
// is holding, once the adapter's position has stood still for a whole `blocked_ms`. The
// loop owns the clock and the position; the decoder only learns *which* channel did
// nothing. `blocked_ms == 0` -- the platformer preset, and the Game Boy preset before
// v0.1.1 -- switches the rule off here, before the decoder is asked.
let blocked_ms = agent.decoder.blocked_ms();
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current())
.flatten()
.map(str::to_string);
let active = agent
.decoder
.decode_bound(&rates, ms, boot, blocked.as_deref(), bound);
// A new winner starts its own window: it has not had a hold to move in yet.
let held = agent.decoder.current().map(str::to_string);
if held != self.held_channel {
self.held_channel = held;
self.blocked_since_ms = ms;
}
active
}
/// Phase B: the mask. `raw_mask` is the decision's own buttons (`to_button_mask`); in macros
/// mode the mask that reaches the emulator is the running macro's, or nothing, or -- on the
/// title screen alone -- the raw mask (`docs/design/macros.md` sections 4 and 12). In raw mode
/// it is the raw mask.
pub fn execute(
&mut self,
macros: Option<&mut MacroLayer>,
active: &[String],
raw_mask: u32,
ms: f64,
emulator: &mut Emulator,
adapter: &dyn GameAdapter,
) -> Executed {
self.buttons = raw_mask;
let executed = match macros {
// The adapter's exploration ledger answers the ways out' "unvisited" -- read-only, by
// `&dyn`, and the only thing the palette is told about the reward side.
Some(layer) => {
let ledger = AdapterLedger(adapter);
let decision = layer.decide(active, self.buttons, ms, emulator, &ledger);
self.buttons = decision.mask;
Executed {
mask: decision.mask,
events: decision.events,
silence: decision.silence,
}
}
None => Executed {
mask: self.buttons,
..Executed::default()
},
};
if let Some(trace) = self.trace.as_mut() {
trace.decided(active);
trace.executed(self.buttons, &executed.events);
}
executed
}
/// Phase B, the environment: the joypad, one emulator frame, the frame it drew and its audio.
pub fn advance(
&mut self,
emulator: &mut Emulator,
agent: &mut NeuralAgent,
observer: &mut dyn FrameObserver,
) -> Result<Vec<u8>> {
self.run(emulator)?;
observer.after(FramePhase::Emulated, agent);
Ok(self.take_frame(emulator))
}
fn run(&mut self, emulator: &mut Emulator) -> Result<()> {
emulator.set_buttons(self.buttons as u8);
emulator
.run_frame()
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
self.frame_counter += 1;
Ok(())
}
fn take_frame(&mut self, emulator: &mut Emulator) -> Vec<u8> {
self.frame_buffer.copy_from_slice(emulator.framebuffer());
if let Some(trace) = self.trace.as_mut() {
trace.advanced(&self.frame_buffer, emulator);
}
emulator.take_audio_u8()
}
/// Phase C: rewards from the frame just produced, then the scene, then the location.
///
/// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the
/// frame, so the palette the fly is offered on the next frame is the one for the frame it can
/// actually see.
pub fn evaluate(
&mut self,
emulator: &mut Emulator,
adapter: &mut dyn GameAdapter,
macros: Option<&mut MacroLayer>,
ms: f64,
) -> Evaluated {
let rewards = adapter.sample(emulator, ms);
// At most one: a macro that has run into a scene with no palette.
let abandoned = match macros {
Some(layer) => {
let ledger = AdapterLedger(&*adapter);
layer.observe(emulator, &ledger, ms)
}
None => Vec::new(),
};
// The cooldown's other reset: the player actually moved. `None` -- a battle, a script, a
// map transition -- is no information rather than "still", so the rule cannot fire while
// the fly has no control anyway.
let location = adapter.location();
if location.is_some() && location != self.location {
self.location = location;
self.blocked_since_ms = ms;
}
let progress = adapter.progress();
if let Some(trace) = self.trace.as_mut() {
trace.evaluated(&rewards, &abandoned, progress.rank);
}
Evaluated {
rewards,
abandoned,
progress,
}
}
/// Phase D: the frame just produced becomes the next ticks' visual drive, each reward event
/// stimulates once, and the summed value reinforces once.
pub fn commit(&mut self, agent: &mut NeuralAgent, rewards: &[RewardEvent], ms: f64) {
let (width, height) = (agent.frame.width, agent.frame.height);
agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
let mut total = 0.0;
for event in rewards {
agent.network.stimulate(f64::from(event.stimulation_ms));
total += event.value;
}
if agent.network.plasticity.enabled {
agent.network.plasticity.reinforce(total, ms);
}
}
// -- the boundary ------------------------------------------------------------------------
/// `Ready(k+1)`: the ratchet captures on a safe frame above its best, observes, and rolls the
/// game back when it says so.
pub fn boundary(
&mut self,
parts: &mut Parts<'_>,
progress: &ProgressSnapshot,
ms: f64,
) -> Result<Boundary> {
let safe = parts.adapter.safe_for_snapshot();
let capture_due = safe && u64::from(progress.rank) > parts.ratchet.state.best;
let captured = if capture_due {
Some(Snapshot {
game: parts
.emulator
.export_state()
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?,
frame: self.frame_buffer.clone(),
})
} else {
None
};
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
// rule as amended 2026-09-22): the macro layer answers "nearer the objective" with the map
// graph it already walks (`docs/design/macros.md` section 12.15); in raw mode there is no
// layer and no objective, and the answer is false.
let nearer = parts
.macros
.as_deref()
.is_some_and(MacroLayer::nearer_the_objective);
let trace = &mut self.trace;
let mut saved = false;
let recover = parts.ratchet.observe_with_progress(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
parts.adapter.game_over(),
nearer,
|| {
let snapshot =
captured.expect("the ratchet only captures when a snapshot was prepared");
if let Some(trace) = trace.as_mut() {
trace.slot_saved(&snapshot.game);
}
saved = true;
snapshot
},
);
let rollback = if recover {
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
let trigger = if parts.adapter.game_over() {
RollbackTrigger::GameOver
} else {
RollbackTrigger::Stall
};
let events = self.rollback(parts)?;
Some(Rollback { trigger, events })
} else {
None
};
Ok(Boundary {
captured: saved,
rollback,
})
}
/// The ratchet's game-only rollback (`legacy-ratchet-rollback-v1`): the slot is restored, the
/// brain's holds and eligibility are cleared and it is shown the slot's frame, the buttons are
/// released, the blocked window restarts, and a running macro is abandoned and the restored
/// scene observed. The brain clock, its learning and the ratchet's ledger carry on.
pub fn rollback(&mut self, parts: &mut Parts<'_>) -> Result<Vec<MacroEvent>> {
let snapshot = Snapshot {
game: parts
.ratchet
.game()
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))?
.to_vec(),
frame: parts
.ratchet
.frame()
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
.to_vec(),
};
let frame = {
let mut neural = AgentRecovery { agent: parts.agent };
recover_game(parts.emulator, parts.adapter, &mut neural, &snapshot)
.map_err(|error| anyhow!("recovering the game: {error}"))?
};
self.frame_buffer.copy_from_slice(&frame);
self.buttons = 0;
parts.emulator.set_buttons(0);
let ms = parts.agent.network.ms;
self.location = parts.adapter.location();
self.held_channel = None;
self.blocked_since_ms = ms;
// A rollback restores a game the running macro's plan was never made for, so the macro is
// abandoned rather than carried over a map change it cannot see. The frame's `observe` ran
// before the ratchet decided, so the scene describes the run just thrown away: re-detect
// on the restored game rather than decide the next frame against a map the fly is no
// longer standing on.
let events = match parts.macros.as_deref_mut() {
Some(layer) => {
let mut events = layer.cancel(ms);
let ledger = AdapterLedger(&*parts.adapter);
events.extend(layer.observe(parts.emulator, &ledger, ms));
events
}
None => Vec::new(),
};
if let Some(trace) = self.trace.as_mut() {
trace.rolled_back(&events);
}
Ok(events)
}
/// Write the open transition of the trace, if one is on.
pub fn finish_trace(&mut self) {
if let Some(trace) = self.trace.as_mut() {
trace.finish();
}
}
}

View file

@ -0,0 +1,176 @@
//! The sugar journal: every admitted audience input, stamped with the frame it was applied before.
//!
//! `sugar-journal.jsonl` in `[paths] hot_dir`, one JSON object per line, append-only. It is not
//! part of a checkpoint and nothing reads it back into the fly: it is the record a shadow run
//! (the session framework's CUT-01, `docs/design/session-framework/legacy-gameboy-v1.md` section
//! 15) replays audience input from. The legacy loop applies an admitted sugar at once, in the
//! command drain at the top of a frame, so an input is fully placed by the transition it precedes:
//!
//! ```json
//! {"frame":"6465126","brainMs":108246189,"kind":"sugar","durationMs":400,"by":"viewer","source":"twitch","eventId":81234,"wallMs":1790000000000}
//! ```
//!
//! - `frame` is the frame counter when the input was applied, which is the `step` of the next
//! transition in the frame trace (`crate::trace`): replay applies it before that transition's
//! ticks. A restore carries the frame counter, so stamps continue across restarts.
//! - `kind` is `sugar` (a `reward-pulse` stimulation of `durationMs`, after the admission rules
//! and the clamp) or `reward` (an operator's `POST /reward`, one reinforcement of `value`).
//! - `brainMs` cross-checks the stamp; `eventId` joins the event log; `wallMs` is for people.
//!
//! Refused requests are not journalled: admission is wall-clock policy, and a replay applies what
//! was admitted. A write that fails is a warning, never a refusal: the input has already reached
//! the fly.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
/// The journal's file name inside the hot directory.
pub const FILE_NAME: &str = "sugar-journal.jsonl";
/// What was applied.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Input {
Sugar { duration_ms: f64 },
Reward { value: f64 },
}
/// One journal line.
#[derive(Debug, Clone, PartialEq)]
pub struct Entry<'a> {
pub frame: u64,
pub brain_ms: f64,
pub input: Input,
pub by: &'a str,
pub source: &'a str,
pub event_id: u64,
pub wall_ms: u64,
}
impl Entry<'_> {
pub fn to_json(&self) -> Value {
let mut line = json!({ "frame": self.frame.to_string(), "brainMs": self.brain_ms });
let map = line.as_object_mut().expect("an object");
match self.input {
Input::Sugar { duration_ms } => {
map.insert("kind".into(), "sugar".into());
map.insert("durationMs".into(), json!(duration_ms));
}
Input::Reward { value } => {
map.insert("kind".into(), "reward".into());
map.insert("value".into(), json!(value));
}
}
map.insert("by".into(), self.by.into());
map.insert("source".into(), self.source.into());
map.insert("eventId".into(), self.event_id.into());
map.insert("wallMs".into(), self.wall_ms.into());
line
}
}
/// The append-only journal. Opened on the first input, so a run nobody feeds writes no file.
pub struct SugarJournal {
path: PathBuf,
file: Option<File>,
}
impl SugarJournal {
pub fn new(hot_dir: &Path) -> Self {
Self {
path: hot_dir.join(FILE_NAME),
file: None,
}
}
pub fn path(&self) -> &Path {
&self.path
}
/// Append one line. Each line is one `write` of the whole line, so a crash leaves at most a
/// torn last line, which a reader skips.
pub fn record(&mut self, entry: &Entry<'_>) {
if self.file.is_none() {
match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(file) => self.file = Some(file),
Err(error) => {
tracing::warn!(%error, path = %self.path.display(), "could not open the sugar journal");
return;
}
}
}
let mut line = entry.to_json().to_string();
line.push('\n');
if let Some(file) = self.file.as_mut()
&& let Err(error) = file.write_all(line.as_bytes())
{
tracing::warn!(%error, path = %self.path.display(), "could not append to the sugar journal");
// Reopen on the next input rather than write through a broken handle.
self.file = None;
}
}
}
/// Read a journal back: every whole line, in order. A torn or unreadable line is skipped.
pub fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|value| value.get("frame").is_some())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inputs_are_appended_with_their_frame_and_survive_a_reopen() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut journal = SugarJournal::new(dir.path());
assert!(
!journal.path().exists(),
"nothing is written before an input"
);
let sugar = Entry {
frame: 42,
brain_ms: 703.0,
input: Input::Sugar { duration_ms: 400.0 },
by: "viewer",
source: "test",
event_id: 7,
wall_ms: 1,
};
journal.record(&sugar);
drop(journal);
let mut journal = SugarJournal::new(dir.path());
journal.record(&Entry {
frame: 43,
input: Input::Reward { value: 0.5 },
event_id: 8,
..sugar
});
// A torn tail from a crash is skipped, not fatal.
std::fs::OpenOptions::new()
.append(true)
.open(journal.path())
.and_then(|mut file| file.write_all(b"{\"frame\":\"44\",\"kin"))
.expect("appending a torn line");
let lines = read(journal.path()).expect("the journal reads back");
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["frame"], "42");
assert_eq!(lines[0]["kind"], "sugar");
assert_eq!(lines[0]["durationMs"], 400.0);
assert_eq!(lines[1]["frame"], "43");
assert_eq!(lines[1]["kind"], "reward");
assert_eq!(lines[1]["value"], 0.5);
assert_eq!(lines[1]["eventId"], 8);
}
}

View file

@ -27,6 +27,8 @@ pub mod config;
pub mod eventlog; pub mod eventlog;
pub mod feed; pub mod feed;
pub mod feedbus; pub mod feedbus;
pub mod frame;
pub mod journal;
pub mod macros; pub mod macros;
pub mod metrics; pub mod metrics;
pub mod pacing; pub mod pacing;
@ -37,6 +39,7 @@ pub mod sdnotify;
pub mod simloop; pub mod simloop;
pub mod snapshot; pub mod snapshot;
pub mod store; pub mod store;
pub mod trace;
use std::sync::Arc; use std::sync::Arc;

View file

@ -8,7 +8,8 @@
//! //!
//! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`, //! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`,
//! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward //! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward
//! inside the same frame it produced: //! inside the same frame it produced. It lives in `crate::frame::LegacyFrame`, the one copy every
//! harness runs too; in outline:
//! //!
//! 1. drain commands //! 1. drain commands
//! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed) //! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed)
@ -30,14 +31,13 @@ use anyhow::{Context, Result, anyhow, bail};
use flybrain_core::agent::{AgentConfig, NeuralAgent}; use flybrain_core::agent::{AgentConfig, NeuralAgent};
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::DecoderConfig; use flybrain_core::decoder::DecoderConfig;
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::decoder::platformer::platformer_decoder_config; use flybrain_core::decoder::platformer::platformer_decoder_config;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot}; use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot};
use flybrain_gb::macros::AdapterLedger; use flybrain_gb::macros::AdapterLedger;
use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator, FRAMEBUFFER_LEN}; use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator};
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use serde::Serialize; use serde::Serialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
@ -45,6 +45,8 @@ use tokio::sync::{mpsc, oneshot, watch};
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason}; use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
use crate::config::Config; use crate::config::Config;
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day}; use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
use crate::journal::{Input, SugarJournal};
use crate::frame::{FrameObserver, FramePhase, LegacyFrame, Parts, RollbackTrigger};
use crate::macros::{MacroEvent, MacroLayer, macro_layer}; use crate::macros::{MacroEvent, MacroLayer, macro_layer};
use crate::metrics::Metrics; use crate::metrics::Metrics;
use crate::pacing::{Pacer, RealtimeWindow}; use crate::pacing::{Pacer, RealtimeWindow};
@ -56,6 +58,7 @@ use crate::snapshot::{
RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset, RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset,
}; };
use crate::store::{self, RuntimeState, Store}; use crate::store::{self, RuntimeState, Store};
use crate::trace::FrameTrace;
/// Commands the control API queues for the sim thread. /// Commands the control API queues for the sim thread.
#[derive(Debug)] #[derive(Debug)]
@ -273,25 +276,26 @@ pub fn booting_snapshot(seq: u64, wall_ms: u64, mode: MacroMode) -> Snapshot {
} }
} }
/// The neural half of a ratchet recovery, wired to `flybrain-core`. /// The stream's only look inside the frame: the per-phase profile (`crate::profile`).
struct AgentRecovery<'a> { struct Laps<'a> {
agent: &'a mut NeuralAgent, profiler: &'a mut Profiler,
} }
impl NeuralRecovery for AgentRecovery<'_> { impl FrameObserver for Laps<'_> {
fn clear_decoder_holds(&mut self) { fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
let ms = self.agent.network.ms; match phase {
self.agent.decoder.clear_holds(ms); FramePhase::Ticked => {
self.profiler.lap(Phase::Step);
if self.profiler.enabled() {
self.profiler.absorb_brain(agent.network.timings());
agent.network.reset_timings();
} }
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
} }
FramePhase::Executed => self.profiler.lap(Phase::Decode),
fn set_visual_frame(&mut self, frame: &[u8]) { FramePhase::Emulated => self.profiler.lap(Phase::Emulate),
let (width, height) = (self.agent.frame.width, self.agent.frame.height); FramePhase::Advanced => self.profiler.lap(Phase::Retina),
self.agent.network.set_visual_frame(frame, width, height); FramePhase::Committed => self.profiler.lap(Phase::Rewards),
}
} }
} }
@ -336,24 +340,11 @@ pub struct Sim {
next_generation: u64, next_generation: u64,
best_archived_rank: Option<u32>, best_archived_rank: Option<u32>,
/// Fractional millisecond carried into the next frame, exactly as `NeuralAgent` keeps it. /// The frame order and its state: the remainder, the frame counter, the frame on screen, the
remainder: f64, /// mask and the readout's blocked-direction window (`crate::frame`).
frame_counter: u64, frame: LegacyFrame,
frame_buffer: Vec<u8>,
pending_audio: Vec<f32>, pending_audio: Vec<f32>,
dc_blocker: DcBlocker, dc_blocker: DcBlocker,
buttons: u32,
/// The readout's blocked-direction cooldown (`docs/readout.md`), as the loop computes it: the
/// player's area and tile as of the last frame the adapter reported one, the channel the group is
/// holding, and the brain clock at which *either* of those last changed. A direction is only
/// blamed once it has been held for a whole `blocked_ms` with no movement, so a direction that
/// has just won is never blamed for a wall the previous one hit.
///
/// All three are transient and deliberately not checkpointed: one hold of a wall after a
/// restart is cheaper than a stale position surviving a restore.
location: Option<(u32, u32, u32)>,
held_channel: Option<String>,
blocked_since_ms: f64,
/// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and /// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and
/// which is byte for byte the behaviour that predates it: every call site below is inside an /// which is byte for byte the behaviour that predates it: every call site below is inside an
@ -385,6 +376,9 @@ pub struct Sim {
sugar_last_by: Option<String>, sugar_last_by: Option<String>,
sugar_today: u64, sugar_today: u64,
sugar_day: String, sugar_day: String,
/// Every admitted sugar and operator pulse, frame-stamped, in the hot directory
/// (`crate::journal`): what a shadow run replays. Not checkpointed.
journal: SugarJournal,
/// The chat path. None of it is wired to the agent, the emulator or plasticity. /// The chat path. None of it is wired to the agent, the emulator or plasticity.
chat_ring: ChatRing, chat_ring: ChatRing,
@ -566,16 +560,14 @@ impl Sim {
writer_thread: None, writer_thread: None,
next_generation: 1, next_generation: 1,
best_archived_rank: None, best_archived_rank: None,
remainder: 0.0, // The per-frame trace, off unless `FLY_TRACE` names a file (`crate::trace`).
frame_counter: 0, frame: LegacyFrame::new().with_trace(
frame_buffer: vec![0u8; FRAMEBUFFER_LEN], FrameTrace::from_env()
.with_context(|| format!("creating the {} file", crate::trace::ENV))?,
),
pending_audio: Vec::new(), pending_audio: Vec::new(),
dc_blocker: DcBlocker::default(), dc_blocker: DcBlocker::default(),
buttons: 0,
status: FeedStatus::Booting, status: FeedStatus::Booting,
location: None,
held_channel: None,
blocked_since_ms: 0.0,
pending_recovery: false, pending_recovery: false,
seq: 0, seq: 0,
started, started,
@ -589,6 +581,7 @@ impl Sim {
sugar_last_by: None, sugar_last_by: None,
sugar_today: 0, sugar_today: 0,
sugar_day: utc_day(now_wall_ms()), sugar_day: utc_day(now_wall_ms()),
journal: SugarJournal::new(&config.paths.hot_dir),
chat_ring: ChatRing::new(config.chat.ring), chat_ring: ChatRing::new(config.chat.ring),
chat_limits: ChatLimiter::default(), chat_limits: ChatLimiter::default(),
deny_list: if config.chat.enabled { deny_list: if config.chat.enabled {
@ -686,7 +679,7 @@ impl Sim {
} }
tracing::info!( tracing::info!(
origin = %candidate.origin, origin = %candidate.origin,
frame = self.frame_counter, frame = self.frame.frame_counter,
brain_ms = self.agent.network.ms, brain_ms = self.agent.network.ms,
rank = self.rank, rank = self.rank,
"restored" "restored"
@ -749,46 +742,21 @@ impl Sim {
self.compatibility self.compatibility
), ),
} }
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());
}
// `import_state` is self-validating and a no-op on failure, so a refused checkpoint // `import_state` is self-validating and a no-op on failure, so a refused checkpoint
// leaves the agent exactly as it was and the next candidate starts clean. // leaves the agent exactly as it was and the next candidate starts clean.
self.agent {
.import_state(&checkpoint.agent) let mut parts = Parts {
.map_err(|error| anyhow!("{error}"))?; agent: &mut self.agent,
self.emulator emulator: &mut self.emulator,
.import_state(&runtime.emulator) adapter: self.adapter.as_mut(),
.map_err(|error| anyhow!("{error}"))?; ratchet: &mut self.ratchet,
if !runtime.reward.is_null() { macros: self.macros.as_mut(),
self.adapter
.import_state(&runtime.reward)
.map_err(|error| anyhow!("{error}"))?;
}
let snapshot = if runtime.ratchet_game.is_empty() {
None
} else {
Some(flybrain_gb::ratchet::Snapshot {
game: runtime.ratchet_game.clone(),
frame: runtime.ratchet_frame.clone(),
})
}; };
self.ratchet self.frame.restore(&mut parts, &checkpoint)?;
.import(Some(runtime.ratchet), snapshot, self.adapter.rank_ladder().len()) }
.map_err(|error| anyhow!("{error}"))?;
self.remainder = checkpoint.agent.remainder;
self.frame_counter = runtime.emulator_frame;
self.buttons = runtime.buttons;
self.rank_since_ms = runtime.rank_since_ms; self.rank_since_ms = runtime.rank_since_ms;
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
self.rank = self.adapter.progress().rank; self.rank = self.adapter.progress().rank;
self.log.resume_from(runtime.last_event_id); self.log.resume_from(runtime.last_event_id);
self.emulator.set_buttons(self.buttons as u8);
Ok(()) Ok(())
} }
@ -796,16 +764,8 @@ impl Sim {
/// readout on the settled rates. Never after a restore, which already carries a settled /// readout on the settled rates. Never after a restore, which already carries a settled
/// network and a calibrated decoder. /// network and a calibrated decoder.
fn fresh_start(&mut self) -> Result<()> { fn fresh_start(&mut self) -> Result<()> {
self.emulator let raw = self.frame.initialize(&mut self.emulator, &mut self.agent)?;
.run_frame()
.map_err(|error| anyhow!("running the first frame: {error}"))?;
self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
self.frame_counter = 1;
let raw = self.emulator.take_audio_u8();
self.dc_blocker.process_into(&raw, &mut self.pending_audio); self.dc_blocker.process_into(&raw, &mut self.pending_audio);
self.agent
.warmup(Some(&self.frame_buffer))
.map_err(|error| anyhow!("{error}"))?;
self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up")); self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up"));
Ok(()) Ok(())
} }
@ -927,117 +887,27 @@ impl Sim {
self.deny_list.maybe_reload(Instant::now(), forced); self.deny_list.maybe_reload(Instant::now(), forced);
} }
/// One frame, in the prototype's order. /// One frame, in the prototype's order (`crate::frame`).
fn step_frame(&mut self) -> Result<()> { fn step_frame(&mut self) -> Result<()> {
// 2. Step the brain: 16 or 17 integer ticks, the remainder carried and checkpointed. // Prepare through commit: ticks, decode, the executor's mask, one emulator frame, rewards,
self.remainder += self.agent.ms_per_frame; // the scene and the location, then the stimulations and the reinforcement.
let steps = self.remainder.floor(); let transition = {
self.remainder -= steps; let mut parts = Parts {
self.agent.network.step(steps as u64); agent: &mut self.agent,
self.profiler.lap(Phase::Step); emulator: &mut self.emulator,
if self.profiler.enabled() { adapter: self.adapter.as_mut(),
self.profiler.absorb_brain(self.agent.network.timings()); ratchet: &mut self.ratchet,
self.agent.network.reset_timings(); macros: self.macros.as_mut(),
} };
let mut laps = Laps { profiler: &mut self.profiler };
// 3. Decode, 4. apply the buttons. self.frame.transition(&mut parts, &mut laps)?
// Not the mode string: the adapter decides what counts as boot, because a platformer needs
// the permissive Start variant in four of its five modes (`GameAdapter::boot`).
let boot = self.adapter.boot();
let ms = self.agent.network.ms;
let rates = self.agent.network.rates.clone();
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group is
// holding, once the adapter's position has stood still for a whole `blocked_ms`. The loop
// owns the clock and the position; the decoder only learns *which* channel did nothing.
// `blocked_ms == 0` -- the platformer preset, and the Game Boy preset before v0.1.1 --
// switches the rule off here, before the decoder is asked.
let blocked_ms = self.agent.decoder.blocked_ms();
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
.then(|| self.agent.decoder.current())
.flatten()
.map(str::to_string);
// The scene's own macro buttons, for the macro group's per-decision mask
// (`docs/design/macros.md` section 12: "unbound channels are masked from the decision").
// They are the bindings the previous frame's `observe` dealt, which is the palette the
// page is showing, so the fly is choosing among exactly the buttons the audience can see.
// `None` in raw mode, where the group has no channels to mask anyway.
let bound = self.macros.as_ref().map(MacroLayer::bound_channels);
let active = self.agent.decoder.decode_bound(
&rates,
ms,
boot,
blocked.as_deref(),
bound.as_deref(),
);
// A new winner starts its own window: it has not had a hold to move in yet.
let held = self.agent.decoder.current().map(str::to_string);
if held != self.held_channel {
self.held_channel = held;
self.blocked_since_ms = ms;
}
self.buttons = to_button_mask(&active);
// Macros mode (`docs/design/macros.md` sections 4 and 12): the same decode, plus the
// macro group whose winner is in `active` alongside the buttons. The mask that reaches
// the emulator is the running macro's, or nothing, or -- on the title screen alone -- the
// raw mask above. In raw mode `self.macros` is `None` and not one line of this runs.
let started_or_finished = match self.macros.as_mut() {
// Two disjoint fields of the same struct, so the layer can read the emulator while
// the loop still owns both. Taking the layer out and putting it back would leave
// raw mode running silently if anything in between ever panicked.
Some(layer) => {
// Three disjoint fields: the layer decides, the emulator is read, and the
// adapter's exploration ledger answers the ways out' "unvisited" — read-only, by
// `&dyn`, and the only thing the palette is told about the reward side.
let ledger = AdapterLedger(self.adapter.as_ref());
let decision = layer.decide(
&active,
self.buttons,
ms,
&mut self.emulator,
&ledger,
);
self.buttons = decision.mask;
decision.events
}
None => Vec::new(),
}; };
self.emit_macro_events(&started_or_finished);
self.emulator.set_buttons(self.buttons as u8);
self.profiler.lap(Phase::Decode);
// 5. Run one emulator frame, 6. set the visual frame from it.
self.emulator
.run_frame()
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
self.frame_counter += 1;
Metrics::incr(&self.shared.metrics.sim_frames); Metrics::incr(&self.shared.metrics.sim_frames);
self.profiler.lap(Phase::Emulate); self.dc_blocker.process_into(&transition.audio, &mut self.pending_audio);
self.frame_buffer.copy_from_slice(self.emulator.framebuffer()); // The feed events, in the order the phases produced them: the executor's starts and
let (width, height) = (self.agent.frame.width, self.agent.frame.height); // finishes, the rewards, then the observation's abandonment.
self.agent self.emit_macro_events(&transition.executed.events);
.network for event in &transition.evaluated.rewards {
.set_visual_frame(&self.frame_buffer, width, height);
let raw = self.emulator.take_audio_u8();
self.dc_blocker.process_into(&raw, &mut self.pending_audio);
self.profiler.lap(Phase::Retina);
// 7. Sample rewards from the frame just produced.
let ms = self.agent.network.ms;
let events = {
let (adapter, emulator) = (&mut self.adapter, &mut self.emulator);
adapter.sample(emulator, ms)
};
// 8. Stimulate once per event, 9. reinforce with the sum.
let mut total = 0.0;
for event in &events {
self.agent.network.stimulate(f64::from(event.stimulation_ms));
total += event.value;
}
if self.agent.network.plasticity.enabled {
self.agent.network.plasticity.reinforce(total, ms);
}
for event in &events {
let kind = RewardKind::from_adapter(event.kind); let kind = RewardKind::from_adapter(event.kind);
let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone()) let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone())
.value(event.value); .value(event.value);
@ -1046,68 +916,27 @@ impl Sim {
} }
self.emit(new); self.emit(new);
} }
self.emit_macro_events(&transition.evaluated.abandoned);
self.profiler.lap(Phase::Rewards); // The milestone archive sits here, after the commit and before the ratchet captures,
// which is the legacy order legacy-gameboy-v1 section 4 declares.
// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the let ms = transition.ms;
// frame. So the palette the fly is offered on the next frame is the one for the frame it let progress = transition.evaluated.progress;
// can actually see, and the feed's `game.scene` is never a frame ahead of the screen.
let abandoned = match self.macros.as_mut() {
Some(layer) => {
let ledger = AdapterLedger(self.adapter.as_ref());
layer.observe(&mut self.emulator, &ledger, ms)
}
None => Vec::new(),
};
// At most one: a macro that has run into a scene with no palette, abandoned before the
// header this frame publishes can show it beside that scene.
self.emit_macro_events(&abandoned);
// The cooldown's other reset: the player actually moved. `None` -- a battle, a script, a
// map transition -- is no information rather than "still", so the rule cannot fire while
// the fly has no control anyway.
let location = self.adapter.location();
if location.is_some() && location != self.location {
self.location = location;
self.blocked_since_ms = ms;
}
// 10. Ratchet: observe, and recover if it says so.
let progress = self.adapter.progress();
self.track_rank(&progress, ms); self.track_rank(&progress, ms);
let safe = self.adapter.safe_for_snapshot();
let capture_due = safe && u64::from(progress.rank) > self.ratchet.state.best; // `Ready(k+1)`: the ratchet captures, observes, and rolls the game back if it says so.
let captured = if capture_due { let boundary = {
Some(flybrain_gb::ratchet::Snapshot { let mut parts = Parts {
game: self agent: &mut self.agent,
.emulator emulator: &mut self.emulator,
.export_state() adapter: self.adapter.as_mut(),
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?, ratchet: &mut self.ratchet,
frame: self.frame_buffer.clone(), macros: self.macros.as_mut(),
})
} else {
None
}; };
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17 self.frame.boundary(&mut parts, &progress, ms)?
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a };
// town it has already covered to reach the rung's own door earns none of it while it is if let Some(rollback) = boundary.rollback {
// plainly getting somewhere. The macro layer answers with the map graph it already walks self.recovered(rollback.trigger, &rollback.events);
// (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no
// objective, and the answer is false.
let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective);
let recover = self.ratchet.observe_with_progress(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
self.adapter.game_over(),
nearer,
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
let reason = if self.adapter.game_over() { "Game over" } else { "Stuck" };
self.recover(reason)?;
} }
self.profiler.lap(Phase::Ratchet); self.profiler.lap(Phase::Ratchet);
Ok(()) Ok(())
@ -1140,32 +969,14 @@ impl Sim {
} }
} }
fn recover(&mut self, reason: &str) -> Result<()> { /// The host's half of a rollback the frame has already applied: the ticker, the metric, the
let snapshot = flybrain_gb::ratchet::Snapshot { /// `recovering` status and a durable checkpoint.
game: self fn recovered(&mut self, trigger: RollbackTrigger, events: &[crate::macros::MacroEvent]) {
.ratchet // Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
.game() let reason = match trigger {
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))? RollbackTrigger::GameOver => "Game over",
.to_vec(), RollbackTrigger::Stall => "Stuck",
frame: self
.ratchet
.frame()
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
.to_vec(),
}; };
let frame = {
let mut neural = AgentRecovery { agent: &mut self.agent };
recover_game(
&mut self.emulator,
self.adapter.as_mut(),
&mut neural,
&snapshot,
)
.map_err(|error| anyhow!("recovering the game: {error}"))?
};
self.frame_buffer.copy_from_slice(&frame);
self.buttons = 0;
self.emulator.set_buttons(0);
Metrics::incr(&self.shared.metrics.recoveries_total); Metrics::incr(&self.shared.metrics.recoveries_total);
let attempts = self.ratchet.state.attempts; let attempts = self.ratchet.state.attempts;
self.emit( self.emit(
@ -1178,31 +989,11 @@ impl Sim {
) )
.value(f64::from(self.rank)), .value(f64::from(self.rank)),
); );
self.location = self.adapter.location(); self.emit_macro_events(events);
self.held_channel = None;
self.blocked_since_ms = self.agent.network.ms;
// A rollback restores a game the running macro's plan was never made for, so the macro is
// abandoned here rather than carried over a map change it cannot see.
let abandoned = match self.macros.as_mut() {
Some(layer) => {
let events = layer.cancel(self.agent.network.ms);
// The frame's `observe` ran before the ratchet decided to roll back, so the scene
// and the palette describe the run that was just thrown away. The restored game
// is in WRAM now, so re-detect here rather than let the next frame's decision be
// made against a map the fly is no longer standing on.
let ledger = AdapterLedger(self.adapter.as_ref());
let mut events = events;
events.extend(layer.observe(&mut self.emulator, &ledger, self.agent.network.ms));
events
}
None => Vec::new(),
};
self.emit_macro_events(&abandoned);
self.pending_recovery = true; self.pending_recovery = true;
if let Err(error) = self.checkpoint(true, None) { if let Err(error) = self.checkpoint(true, None) {
tracing::error!(%error, "could not checkpoint after a recovery"); tracing::error!(%error, "could not checkpoint after a recovery");
} }
Ok(())
} }
// -- commands ------------------------------------------------------------------------ // -- commands ------------------------------------------------------------------------
@ -1292,6 +1083,9 @@ impl Sim {
.clamp(1.0, self.shared.config.control.sugar_max_ms) .clamp(1.0, self.shared.config.control.sugar_max_ms)
.min(self.shared.config.control.sugar_max_ms); .min(self.shared.config.control.sugar_max_ms);
self.agent.network.stimulate(duration); self.agent.network.stimulate(duration);
if let Some(trace) = self.frame.trace_mut() {
trace.sugar(duration);
}
let day = utc_day(now_ms); let day = utc_day(now_ms);
if day != self.sugar_day { if day != self.sugar_day {
@ -1304,6 +1098,15 @@ impl Sim {
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by)) let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
.by(by) .by(by)
.value(duration)); .value(duration));
self.journal.record(&crate::journal::Entry {
frame: self.frame.frame_counter,
brain_ms: self.agent.network.ms,
input: Input::Sugar { duration_ms: duration },
by,
source,
event_id: event.id,
wall_ms: event.wall_ms,
});
tracing::info!(by, source, duration_ms = duration, "sugar accepted"); tracing::info!(by, source, duration_ms = duration, "sugar accepted");
Ok(event.id) Ok(event.id)
} }
@ -1313,13 +1116,25 @@ impl Sim {
fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 { fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 {
let ms = self.agent.network.ms; let ms = self.agent.network.ms;
self.agent.network.plasticity.reinforce(value, ms); self.agent.network.plasticity.reinforce(value, ms);
if let Some(trace) = self.frame.trace_mut() {
trace.reward_pulse(value);
}
tracing::info!(by, source, value, "reward pulse applied"); tracing::info!(by, source, value, "reward pulse applied");
self.emit( let event = self.emit(
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})")) NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
.by(by) .by(by)
.value(value), .value(value),
) );
.id self.journal.record(&crate::journal::Entry {
frame: self.frame.frame_counter,
brain_ms: ms,
input: Input::Reward { value },
by,
source,
event_id: event.id,
wall_ms: event.wall_ms,
});
event.id
} }
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge. /// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
@ -1390,6 +1205,7 @@ impl Sim {
if let Err(error) = self.checkpoint_blocking(true, None) { if let Err(error) = self.checkpoint_blocking(true, None) {
tracing::error!(%error, "the final checkpoint failed"); tracing::error!(%error, "the final checkpoint failed");
} }
self.frame.finish_trace();
if let Err(error) = self.log.flush() { if let Err(error) = self.log.flush() {
tracing::error!(%error, "the final event log flush failed"); tracing::error!(%error, "the final event log flush failed");
} }
@ -1499,6 +1315,10 @@ impl Sim {
durable && self.best_archived_rank.is_none_or(|best| *rank > best) durable && self.best_archived_rank.is_none_or(|best| *rank > best)
}); });
let (generation, agent, runtime) = self.snapshot_state()?; let (generation, agent, runtime) = self.snapshot_state()?;
let step = self.frame.frame_counter;
if let Some(trace) = self.frame.trace_mut() {
trace.capture(generation, step);
}
if let Some(rank) = archive_rank { if let Some(rank) = archive_rank {
self.best_archived_rank = Some(rank); self.best_archived_rank = Some(rank);
} }
@ -1547,15 +1367,15 @@ impl Sim {
let mut agent_state = self.agent.export_state(); let mut agent_state = self.agent.export_state();
// The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state // The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state
// carries the loop's value. // carries the loop's value.
agent_state.remainder = self.remainder; agent_state.remainder = self.frame.remainder;
let runtime = RuntimeState { let runtime = RuntimeState {
generation, generation,
wall_ms: now_wall_ms(), wall_ms: now_wall_ms(),
rom_sha256: self.rom_sha256.clone(), rom_sha256: self.rom_sha256.clone(),
emulator_frame: self.frame_counter, emulator_frame: self.frame.frame_counter,
compatibility: self.compatibility.clone(), compatibility: self.compatibility.clone(),
speed: self.shared.config.loop_.speed, speed: self.shared.config.loop_.speed,
buttons: self.buttons, buttons: self.frame.buttons,
rank_since_ms: self.rank_since_ms, rank_since_ms: self.rank_since_ms,
last_event_id: self.log.next_id().saturating_sub(1), last_event_id: self.log.next_id().saturating_sub(1),
reward: self.adapter.export_state(), reward: self.adapter.export_state(),
@ -1564,7 +1384,7 @@ impl Sim {
.emulator .emulator
.export_state() .export_state()
.map_err(|error| anyhow!("exporting the Game Boy: {error}"))?, .map_err(|error| anyhow!("exporting the Game Boy: {error}"))?,
framebuffer: self.frame_buffer.clone(), framebuffer: self.frame.frame_buffer.clone(),
ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(), ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(),
ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(), ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(),
}; };
@ -1637,7 +1457,7 @@ impl Sim {
let audio = f32_bytes(&self.pending_audio); let audio = f32_bytes(&self.pending_audio);
self.pending_audio.clear(); self.pending_audio.clear();
( (
Arc::new(self.frame_buffer.clone()), Arc::new(self.frame.frame_buffer.clone()),
Arc::new(audio), Arc::new(audio),
Arc::new(bitset), Arc::new(bitset),
count, count,
@ -1663,8 +1483,8 @@ impl Sim {
uptime_seconds: self.started.elapsed().as_secs_f64(), uptime_seconds: self.started.elapsed().as_secs_f64(),
run_seconds: finite(ms / 1000.0).max(0.0), run_seconds: finite(ms / 1000.0).max(0.0),
brain_ms: finite(ms).max(0.0), brain_ms: finite(ms).max(0.0),
frame: self.frame_counter, frame: self.frame.frame_counter,
buttons: self.buttons & 0xff, buttons: self.frame.buttons & 0xff,
rates, rates,
population_rate: finite(self.agent.network.population_rate).max(0.0), population_rate: finite(self.agent.network.population_rate).max(0.0),
spike_count, spike_count,

View file

@ -0,0 +1,385 @@
//! `FLY_TRACE=<path>`: a per-frame record of the legacy loop, for parity and for the port.
//!
//! One JSON object per line. The first line names the format; every other line is either one
//! transition `k -> k+1` of the legacy frame order (`crate::frame`) together with what happened at
//! the boundary it reached, or -- once, before the first transition -- the captures taken at the
//! boundary the run started on.
//!
//! The field names follow the step trace of the session framework
//! (`docs/design/session-framework/step-v1.md` section 8 and its 2026-09-23 amendment, Rust
//! `fly_session_types::trace`) wherever a legacy field is the same thing:
//!
//! - `behaviour` is what two runs of one build pair must agree on, byte for byte:
//! `step`, `ticksAdvanced`, `brainTicks` and `remainder` (a `RationalNs`, exact, because every
//! legacy remainder is a multiple of 2^-15 ms: legacy-gameboy-v1 section 3), the decision, the
//! controller mask, the macro and reward events in the order they happened, the digests of the
//! rates, of the spikes of this transition, of the frame and of work RAM, the rank, and
//! `boundaryActions` -- a ratchet capture is a `save-slot` of slot `best` with the digest of the
//! saved emulator state, a ratchet recovery is a `rollback` to `best` -- in the shape of
//! `TraceBehaviour.boundaryActions`;
//! - `operational.captures` is every checkpoint taken at the boundary, in order, each with the
//! number of boundary actions applied before it, in the shape of `TraceOperational.captures`.
//! The legacy loop archives a milestone *before* the ratchet captures (legacy-gameboy-v1 section
//! 4), so a climb records `afterActions: 0` ahead of a `save-slot`: the declared difference, as
//! it happens, which the ported loop's validator refuses on purpose.
//!
//! `admissions` is sugar and operator reward pulses applied at the top of the frame, before the
//! brain ticks: the admission cut of legacy-gameboy-v1 section 15.
//!
//! Wall time is never written, so a run with the periodic checkpoint intervals pushed out of the
//! way produces the same file twice. Off by default; when off, nothing here is constructed and the
//! loop pays one `Option` test per hook.
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use flybrain_core::lif::LifNetwork;
use flybrain_gb::RewardEvent;
use flybrain_gb::emulator::Emulator;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::macros::MacroEvent;
/// The format name on the first line.
pub const FORMAT: &str = "flysim-legacy-frame-trace-v1";
/// The environment variable that turns the trace on.
pub const ENV: &str = "FLY_TRACE";
/// The ratchet's one slot, as the legacy composition declares it (legacy-gameboy-v1 section 9).
pub const SLOT: &str = "best";
/// Work RAM, `$C000..=$DFFF`, as the CPU sees it.
const WRAM: std::ops::RangeInclusive<u16> = 0xc000..=0xdfff;
/// Lowercase hex SHA-256.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
/// A legacy remainder in milliseconds as exact nanoseconds, `{numerator, denominator}` in lowest
/// terms. The remainder is always `m / 32768` ms for an integer `m` (legacy-gameboy-v1 section
/// 3), so this never rounds; a value that is not is written with its bits instead.
pub fn remainder_ns(remainder_ms: f64) -> Value {
let scaled = remainder_ms * 32_768.0;
if scaled.fract() != 0.0 || !(0.0..32_768.0 * 1_000.0).contains(&scaled) {
return json!({ "inexactBits": format!("{:016x}", remainder_ms.to_bits()) });
}
// ns = m * 1e6 / 32768 = m * 15625 / 512.
let mut numerator = scaled as u64 * 15_625;
let mut denominator = 512u64;
let (mut a, mut b) = (numerator, denominator);
while b != 0 {
(a, b) = (b, a % b);
}
if a > 1 {
numerator /= a;
denominator /= a;
}
if numerator == 0 {
denominator = 1;
}
json!({ "numerator": numerator.to_string(), "denominator": denominator.to_string() })
}
fn macro_json(phase: &str, event: &MacroEvent) -> Value {
json!({
"phase": phase,
"slot": event.slot,
"name": event.name,
"outcome": event.outcome.map(|outcome| outcome.as_str()),
})
}
/// One transition and the boundary it reached, while it is being recorded.
#[derive(Default)]
struct Record {
step: u64,
admissions: Vec<Value>,
ticks: u64,
brain_ticks: u64,
remainder: Value,
rates: String,
spikes: String,
spike_count: u64,
decision: Vec<String>,
mask: u32,
macros: Vec<Value>,
framebuffer: String,
wram: String,
rewards: Vec<Value>,
rank: u32,
boundary_actions: Vec<Value>,
captures: Vec<Value>,
}
/// The recorder. The loop calls it at fixed points of the frame order; it holds the transition
/// open until the next one starts, so the captures and admissions a host takes between two frames
/// land on the boundary they belong to.
pub struct FrameTrace {
out: BufWriter<File>,
/// Captures at the boundary the run started on, before any transition.
initial: Vec<Value>,
initial_step: Option<u64>,
open: Option<Record>,
/// Admissions since the last transition started: they belong to the next one.
admissions: Vec<Value>,
/// Brain clock before this transition's ticks, the lower edge of its spike window.
ms_before: f64,
}
impl FrameTrace {
/// `FLY_TRACE`, if it is set and non-empty. A path that cannot be created is an error rather
/// than a silent run without the trace that was asked for.
pub fn from_env() -> std::io::Result<Option<Self>> {
match std::env::var_os(ENV) {
Some(path) if !path.is_empty() => Self::create(Path::new(&path)).map(Some),
_ => Ok(None),
}
}
pub fn create(path: &Path) -> std::io::Result<Self> {
let mut out = BufWriter::with_capacity(1 << 20, File::create(path)?);
writeln!(out, "{}", json!({ "format": FORMAT }))?;
Ok(Self {
out,
initial: Vec::new(),
initial_step: None,
open: None,
admissions: Vec::new(),
ms_before: 0.0,
})
}
fn write(&mut self, value: &Value) {
if let Err(error) = writeln!(self.out, "{value}") {
tracing::warn!(%error, "could not write the frame trace");
}
}
/// Sugar admitted at the top of the frame, before the brain ticks.
pub fn sugar(&mut self, duration_ms: f64) {
self.admissions
.push(json!({ "kind": "sugar", "durationMs": duration_ms }));
}
/// An operator reward pulse, applied at the top of the frame.
pub fn reward_pulse(&mut self, value: f64) {
self.admissions
.push(json!({ "kind": "reward", "value": value }));
}
/// A checkpoint capture at the current boundary: the open transition's, or the start's.
pub fn capture(&mut self, generation: u64, step: u64) {
let (captures, after) = match self.open.as_mut() {
Some(record) => {
let after = record.boundary_actions.len();
(&mut record.captures, after)
}
None => {
self.initial_step.get_or_insert(step);
(&mut self.initial, 0)
}
};
captures.push(json!({ "checkpointId": format!("g{generation}"), "afterActions": after }));
}
/// Transition `step -> step + 1` begins: the previous one is complete.
pub fn begin(&mut self, step: u64, ms_before: f64) {
self.flush_open();
if let Some(start) = self.initial_step.take() {
let initial = std::mem::take(&mut self.initial);
self.write(&json!({
"boundary": start.to_string(),
"operational": { "captures": initial },
}));
}
self.ms_before = ms_before;
self.open = Some(Record {
step,
admissions: std::mem::take(&mut self.admissions),
..Record::default()
});
}
/// Phase A's ticks, and the network they left behind.
pub fn ticked(&mut self, ticks: u64, remainder_ms: f64, network: &LifNetwork) {
let Some(record) = self.open.as_mut() else {
return;
};
record.ticks = ticks;
record.brain_ticks = network.ms as u64;
record.remainder = remainder_ns(remainder_ms);
let mut hasher = Sha256::new();
for (role, rate) in network.rates.iter() {
hasher.update((role.len() as u32).to_le_bytes());
hasher.update(role.as_bytes());
hasher.update(rate.to_bits().to_le_bytes());
}
record.rates = hex(&hasher.finalize());
let (bits, count) =
crate::snapshot::spike_bitset(&network.last_spike_ms, self.ms_before, network.ms);
record.spikes = sha256_hex(&bits);
record.spike_count = count;
}
/// The readout's decision, as decoded.
pub fn decided(&mut self, active: &[String]) {
if let Some(record) = self.open.as_mut() {
record.decision = active.to_vec();
}
}
/// Phase B: the mask the emulator is given, and the macro events deciding it produced.
pub fn executed(&mut self, mask: u32, events: &[MacroEvent]) {
if let Some(record) = self.open.as_mut() {
record.mask = mask;
record
.macros
.extend(events.iter().map(|event| macro_json("execute", event)));
}
}
/// The frame the emulator produced, and work RAM after it. Read uncached, so the trace never
/// fills the per-frame read cache the adapter and the macros share.
pub fn advanced(&mut self, framebuffer: &[u8], emulator: &Emulator) {
if let Some(record) = self.open.as_mut() {
record.framebuffer = sha256_hex(framebuffer);
let wram: Vec<u8> = WRAM
.map(|address| emulator.read_uncached(address))
.collect();
record.wram = sha256_hex(&wram);
}
}
/// Phase C: the reward events in adapter order, the scene's own macro events, and the rank.
pub fn evaluated(&mut self, rewards: &[RewardEvent], abandoned: &[MacroEvent], rank: u32) {
if let Some(record) = self.open.as_mut() {
record.rewards.extend(rewards.iter().map(|event| {
json!({
"kind": event.kind,
"value": event.value,
"stimulationMs": event.stimulation_ms,
})
}));
record
.macros
.extend(abandoned.iter().map(|event| macro_json("evaluate", event)));
record.rank = rank;
}
}
/// The ratchet captured: a slot save at the boundary.
pub fn slot_saved(&mut self, state: &[u8]) {
if let Some(record) = self.open.as_mut() {
record.boundary_actions.push(json!({
"kind": "save-slot",
"slotId": SLOT,
"stateDigest": sha256_hex(state),
}));
}
}
/// The ratchet rolled back, and the macro events the rollback produced.
pub fn rolled_back(&mut self, events: &[MacroEvent]) {
if let Some(record) = self.open.as_mut() {
record.boundary_actions.push(json!({
"kind": "rollback",
"slotId": SLOT,
"stateDigest": null,
}));
record
.macros
.extend(events.iter().map(|event| macro_json("rollback", event)));
}
}
fn flush_open(&mut self) {
let Some(record) = self.open.take() else {
return;
};
let line = json!({
"behaviour": {
"step": record.step.to_string(),
"admissions": record.admissions,
"ticksAdvanced": record.ticks.to_string(),
"brainTicks": record.brain_ticks.to_string(),
"remainder": record.remainder,
"ratesDigest": record.rates,
"spikesDigest": record.spikes,
"spikeCount": record.spike_count,
"decision": record.decision,
"mask": record.mask,
"macroEvents": record.macros,
"framebufferDigest": record.framebuffer,
"wramDigest": record.wram,
"rewards": record.rewards,
"rank": record.rank,
"acknowledgedBoundary": (record.step + 1).to_string(),
"boundaryActions": record.boundary_actions,
},
"operational": { "captures": record.captures },
});
self.write(&line);
}
/// Write the open transition and flush the file: on shutdown, and on drop.
pub fn finish(&mut self) {
self.flush_open();
if let Err(error) = self.out.flush() {
tracing::warn!(%error, "could not flush the frame trace");
}
}
}
impl Drop for FrameTrace {
fn drop(&mut self) {
self.finish();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_legacy_remainder_is_exact_nanoseconds() {
// The first twelve legacy frames' remainders, from the clock the fixture records.
let per_frame = 1000.0 / (4_194_304.0 / 70_224.0);
let mut remainder = 0.0f64;
for _ in 0..100_000 {
remainder += per_frame;
remainder -= remainder.floor();
let value = remainder_ns(remainder);
assert!(
value.get("numerator").is_some(),
"{remainder} was not exact: {value}"
);
}
assert_eq!(
remainder_ns(0.0),
json!({ "numerator": "0", "denominator": "1" })
);
// 0.5 ms is 500000 ns.
assert_eq!(
remainder_ns(0.5),
json!({ "numerator": "500000", "denominator": "1" })
);
// One 2^-15 ms step is 15625/512 ns.
assert_eq!(
remainder_ns(1.0 / 32_768.0),
json!({ "numerator": "15625", "denominator": "512" })
);
}
}

View file

@ -0,0 +1,93 @@
//! `FLY_TRACE`'s boundary half is the session framework's, field for field.
//!
//! The recorder writes `behaviour.boundaryActions` and `operational.captures` in the shapes of
//! `TraceBehaviour.boundaryActions` and `TraceOperational.captures` (step-v1 section 8, amended
//! 2026-09-23). This test records the two boundaries the legacy loop produces and reads them with
//! `fly-session-types` itself, by grafting them onto the shared baseline trace:
//!
//! - a rollback followed by the post-recovery checkpoint is a valid transition trace;
//! - a rung climb -- the milestone archive, *then* the ratchet's slot save -- is refused with the
//! capture-before-save error. That is the legacy order legacy-gameboy-v1 section 4 declares, and
//! the trace has to show it as it happens rather than tidy it away.
use fly_session_types::fixtures;
use fly_session_types::scalar::DomainType;
use fly_session_types::trace::TransitionTrace;
use flysim::trace::FrameTrace;
use serde_json::Value;
fn lines(path: &std::path::Path) -> Vec<Value> {
std::fs::read_to_string(path)
.expect("the trace")
.lines()
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
.collect()
}
/// The shared baseline transition with this boundary's actions and captures in place of its own.
fn grafted(record: &Value) -> Result<TransitionTrace, String> {
let file = fixtures::load("traces.json").expect("traces.json");
let mut baseline = file.get("baseline").expect("baseline").clone();
baseline["behaviour"]["boundaryActions"] = record["behaviour"]["boundaryActions"].clone();
baseline["operational"]["captures"] = record["operational"]["captures"].clone();
TransitionTrace::from_json(&baseline).map_err(|error| error.to_string())
}
#[test]
fn the_boundary_half_of_the_trace_is_the_session_frameworks() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("trace.jsonl");
let mut trace = FrameTrace::create(&path).expect("the trace file");
// The start: the boot checkpoint, before any transition.
trace.capture(1, 100);
// Transition 100 -> 101 ends in a stall rollback, then the post-recovery checkpoint.
trace.sugar(400.0);
trace.begin(100, 1_000.0);
trace.rolled_back(&[]);
trace.capture(2, 101);
// Transition 101 -> 102 climbs a rung: the archive first, then the ratchet captures.
trace.begin(101, 1_017.0);
trace.capture(3, 102);
trace.slot_saved(b"emulator state");
trace.finish();
drop(trace);
let lines = lines(&path);
assert_eq!(lines.len(), 4, "format, start, two transitions: {lines:?}");
assert_eq!(lines[0]["format"], flysim::trace::FORMAT);
assert_eq!(lines[1]["boundary"], "100");
assert_eq!(lines[1]["operational"]["captures"][0]["checkpointId"], "g1");
let rollback = &lines[2];
assert_eq!(rollback["behaviour"]["step"], "100");
assert_eq!(rollback["behaviour"]["admissions"][0]["kind"], "sugar");
assert_eq!(
rollback["behaviour"]["boundaryActions"][0]["kind"],
"rollback"
);
assert_eq!(rollback["operational"]["captures"][0]["afterActions"], 1);
let parsed = grafted(rollback).expect("a rollback and then its checkpoint is a valid trace");
assert_eq!(parsed.behaviour.boundary_actions.len(), 1);
assert_eq!(parsed.operational.captures.len(), 1);
let climb = &lines[3];
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["kind"],
"save-slot"
);
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["slotId"],
flysim::trace::SLOT
);
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["stateDigest"],
flysim::trace::sha256_hex(b"emulator state")
);
assert_eq!(climb["operational"]["captures"][0]["afterActions"], 0);
let refused = grafted(climb).expect_err("the legacy archive precedes the slot save");
assert!(
refused.contains("before this boundary's slot saves"),
"refused for the declared reason: {refused}"
);
}

View file

@ -437,6 +437,17 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
assert_eq!(logged[0]["by"], json!("integration-test")); assert_eq!(logged[0]["by"], json!("integration-test"));
let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap(); let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap();
assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}"); assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}");
// And in the hot directory's journal, stamped with the frame it was applied before, which
// is what a shadow run replays (`flysim::journal`).
let journal_path = dir.path().join("hot").join(flysim::journal::FILE_NAME);
let journal = flysim::journal::read(&journal_path).expect("the sugar journal");
assert_eq!(journal.len(), 1, "{journal:?}");
assert_eq!(journal[0]["kind"], json!("sugar"));
assert_eq!(journal[0]["eventId"], json!(event_id));
assert_eq!(journal[0]["durationMs"], json!(400.0));
let stamped: u64 =
journal[0]["frame"].as_str().and_then(|frame| frame.parse().ok()).expect("a frame");
assert!(stamped >= 1, "stamped with a frame the emulator has run: {stamped}");
// A second pulse while the first is still being applied is refused, not stacked. // A second pulse while the first is still being applied is refused, not stacked.
let (status, body) = service.post( let (status, body) = service.post(

View file

@ -43,6 +43,7 @@ use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter,
}; };
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::LegacyFrame;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
@ -92,6 +93,8 @@ struct Run {
gb: Emulator, gb: Emulator,
adapter: PokemonRedReward, adapter: PokemonRedReward,
layer: MacroLayer, layer: MacroLayer,
/// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
legacy: LegacyFrame,
decoder: PopulationDecoder, decoder: PopulationDecoder,
channels: Vec<&'static str>, channels: Vec<&'static str>,
ms: f64, ms: f64,
@ -124,6 +127,7 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
legacy: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms: 0.0, ms: 0.0,
@ -161,18 +165,14 @@ impl Run {
}; };
let bound = self.layer.bound_channels(); let bound = self.layer.bound_channels();
let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let mask = { self.legacy.execute(Some(&mut self.layer), &active, 0, self.ms, &mut self.gb, &self.adapter);
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.ms += MS_PER_FRAME; self.ms += MS_PER_FRAME;
self.frame += 1; self.frame += 1;
let ms = self.ms; let evaluated = self
self.payouts.extend(self.adapter.sample(&mut self.gb, ms)); .legacy
let ledger = AdapterLedger(&self.adapter); .stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms)
let _ = self.layer.observe(&mut self.gb, &ledger, ms); .expect("a frame should complete");
self.payouts.extend(evaluated.rewards);
} }
fn catches(&self) -> Vec<&RewardEvent> { fn catches(&self) -> Vec<&RewardEvent> {

View file

@ -42,6 +42,7 @@ use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons, AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons,
}; };
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::LegacyFrame;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
@ -147,6 +148,8 @@ struct Run {
gb: Emulator, gb: Emulator,
adapter: PokemonRedReward, adapter: PokemonRedReward,
layer: MacroLayer, 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. /// The readout under test: the shipping decoder, fed by hand.
decoder: PopulationDecoder, decoder: PopulationDecoder,
/// The macro channels, in the decoder's own order, for the rotation. /// The macro channels, in the decoder's own order, for the rotation.
@ -402,6 +405,7 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
frame: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms, ms,
@ -520,6 +524,7 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
frame: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms, ms,
@ -815,9 +820,15 @@ impl Run {
self.talk_on_pad = talk_bound; self.talk_on_pad = talk_bound;
let active = let active =
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let (mask, started, blocked, done) = { let (started, blocked, done) = {
let ledger = AdapterLedger(&self.adapter); let decision = self.frame.execute(
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger); Some(&mut self.layer),
&active,
0,
self.ms,
&mut self.gb,
&self.adapter,
);
let started: Vec<&'static str> = decision let started: Vec<&'static str> = decision
.events .events
.iter() .iter()
@ -840,7 +851,7 @@ impl Run {
}) })
.map(|event| event.name) .map(|event| event.name)
.collect(); .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 // 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. // another that starts on the same frame are not confused for one another.
@ -963,15 +974,10 @@ impl Run {
let (x, y) = self.tile(); let (x, y) = self.tile();
self.started_at = Some((self.map(), x, y)); 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; self.ms += MS_PER_FRAME;
let ms = self.ms; self.frame
self.adapter.sample(&mut self.gb, ms); .stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms)
{ .expect("a frame should complete");
let ledger = AdapterLedger(&self.adapter);
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
}
// Battle boundaries, after the frame: what a battle cost in macros, and whether it ended. // Battle boundaries, after the frame: what a battle cost in macros, and whether it ended.
let now_in_battle = self.in_battle() != 0; let now_in_battle = self.in_battle() != 0;
match (self.was_in_battle, now_in_battle) { match (self.was_in_battle, now_in_battle) {