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:
commit
4d82f7db74
18 changed files with 2033 additions and 1037 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
1
services/flysim/Cargo.lock
generated
1
services/flysim/Cargo.lock
generated
|
|
@ -508,6 +508,7 @@ dependencies = [
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
705
services/flysim/crates/flysim/src/frame.rs
Normal file
705
services/flysim/crates/flysim/src/frame.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
services/flysim/crates/flysim/src/journal.rs
Normal file
176
services/flysim/crates/flysim/src/journal.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
385
services/flysim/crates/flysim/src/trace.rs
Normal file
385
services/flysim/crates/flysim/src/trace.rs
Normal 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" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
services/flysim/crates/flysim/tests/frame_trace.rs
Normal file
93
services/flysim/crates/flysim/tests/frame_trace.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue