trap_hunt, palette_bench and room_escape ran the frame through NeuralAgent::tick, which installs a frame and its rewards after the next ticks: one frame behind the stream, with the ratchet observed without the objective signal and a rollback that never re-observed the scene. They now restore the way the stream restores and run LegacyFrame::transition and ::boundary, looking in through FrameObserver; FLY_TRACE works in each of them. The stub-readout drivers of rom_macros_mode, rom_catch and scene_probe run LegacyFrame::execute and ::stub_advance, which are the same calls they made, in the same order.
1403 lines
57 KiB
Rust
1403 lines
57 KiB
Rust
//! How long does the readout take to walk out of a room?
|
|
//!
|
|
//! `docs/design/room-escape.md` §1, "Straighter runs": the Game Boy preset commits to a direction
|
|
//! for 400 ms, one overworld step is about 270 ms, so a run is one to two steps and the walker
|
|
//! jitters in place. This example measures the fix before it is baked in: the *real* decoder, the
|
|
//! *real* Pokémon adapter and the real cartridge, with the exclusive group's direction hold,
|
|
//! decision period and fatigue gain overridden per run.
|
|
//!
|
|
//! ```sh
|
|
//! FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
//! cargo run --release -p flysim --example room_escape
|
|
//! ```
|
|
//!
|
|
//! Without the ROM it prints how to run it and exits 0. The cartridge is never copied into this
|
|
//! repository; it is read from the path in `FLY_ROM` and nothing else.
|
|
//!
|
|
//! ## What it reports
|
|
//!
|
|
//! For each `(hold, fatigue gain)` candidate and each of two starting rooms — Red's bedroom
|
|
//! (`REDS_HOUSE_2F`) and the ground floor (`REDS_HOUSE_1F`) — `FLY_ESCAPE_SEEDS` seeded runs of
|
|
//! `FLY_ESCAPE_MINUTES` brain minutes each, unthrottled, reporting the fraction of runs that leave
|
|
//! the starting map at all and the median brain time to leave over the runs that did. Runs are
|
|
//! independent, so they are spread across `FLY_ESCAPE_JOBS` threads.
|
|
//!
|
|
//! Rates are a seeded uniform random source, not a brain: this measures the *readout*, and a
|
|
//! random walker is the only way to measure it without 139,255 neurons of confound. That is also
|
|
//! why the chosen value has to be confirmed against the real brain, which `FLY_ESCAPE_BRAIN`
|
|
//! does: `FLY_ESCAPE_BRAIN_RUNS` runs through `NeuralAgent` (warm-up included) on the dataset at
|
|
//! `FLY_DATASET`, with the adapter's payouts reinforcing as they would on the stream.
|
|
//!
|
|
//! None of this is evidence about the fly. A random walker that leaves a room faster tells you the
|
|
//! readout stopped cancelling itself out, and nothing more.
|
|
//!
|
|
//! ## Starting states
|
|
//!
|
|
//! `FLY_ESCAPE_STATE` optionally points at a binjgb save state to start every bedroom run from.
|
|
//! Without it the example boots the cartridge itself: Start and A on a slow alternation through
|
|
//! the intro and the naming screens, then B until the adapter reports a stable, unscripted,
|
|
//! dialogue-free overworld sample in the bedroom. That state is then shared by every run, so the
|
|
//! intro is emulated once rather than 160 times.
|
|
//!
|
|
//! The ground-floor state is produced the same way and cached: one run walks down the stairs and
|
|
//! its state is written to `FLY_ESCAPE_STATE_1F` (default: `room-escape-1f.state` beside the
|
|
//! working directory), which later invocations reuse.
|
|
//!
|
|
//! ## v0.1.1: starting from the live fly
|
|
//!
|
|
//! `FLY_ESCAPE_CHECKPOINT` points at a `FLYSIM01` checkpoint -- the release box's own durable
|
|
//! state, pulled read-only -- and takes over the whole run: the random-walker sweep is skipped and
|
|
//! every candidate in [`CANDIDATES`] is measured forward from that exact state instead, with
|
|
//! `FLY_ESCAPE_BRAIN` supplying the connectome. It needs the real thing because the question
|
|
//! v0.1.1 asks is not "can a walker leave a room" (v0.1.0 measured that: always, at every hold
|
|
//! value) but "why did *this* fly, 43 brain minutes into a run with a full reward ledger and a
|
|
//! settled network, stay on the ground floor for forty more".
|
|
//!
|
|
//! What it reports per run, on top of the leave time: the direction-hold histogram, the mean
|
|
//! same-direction run length in decisions, the share of decisions the incumbent won by hysteresis
|
|
//! rather than by score, the holds over which the player never moved at all, and every frame and
|
|
//! decision spent standing on a tile one press from the exit. Those tiles are not hardcoded:
|
|
//! [`survey`] walks the map with real presses first and finds them.
|
|
//!
|
|
//! ```sh
|
|
//! FLY_ROM=... FLY_ESCAPE_CHECKPOINT=/path/to/525.checkpoint \
|
|
//! FLY_ESCAPE_BRAIN=data/fafb-v783 FLY_ESCAPE_BRAIN_RUNS=5 FLY_ESCAPE_MINUTES=10 \
|
|
//! FLY_ESCAPE_BRAIN_THREADS=3 FLY_ESCAPE_BRAIN_PARALLEL=5 \
|
|
//! cargo run --release -p flysim --example room_escape
|
|
//! ```
|
|
//!
|
|
//! `FLY_ESCAPE_CANDIDATES` selects a subset by name, `FLY_ESCAPE_TARGET_MINUTES` sets the bar the
|
|
//! summary line scores against (5, from `docs/design/room-escape.md` section 3),
|
|
//! `FLY_ESCAPE_RUN_ON` spends the whole budget instead of stopping at the exit, and
|
|
//! `FLY_ESCAPE_SKIP_SWEEP` skips the random-walker tables on a fresh-boot run.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use flybrain_core::agent::{
|
|
AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent,
|
|
};
|
|
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
|
use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask};
|
|
use flybrain_core::decoder::{DecoderConfig, PopulationDecoder};
|
|
use flybrain_core::lif::SweepPlan;
|
|
use flybrain_core::ordered::NumberMap;
|
|
use flybrain_gb::adapter::{GameAdapter, MemoryReader};
|
|
use flybrain_gb::pokemon_red::symbols::ram;
|
|
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
|
|
use flybrain_gb::ratchet::Ratchet;
|
|
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
|
|
use flysim::frame::{FrameObserver, FramePhase, LegacyFrame, Parts};
|
|
|
|
/// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house.
|
|
const REDS_HOUSE_2F: u32 = 0x26;
|
|
const REDS_HOUSE_1F: u32 = 0x25;
|
|
|
|
const ROLES: [&str; 8] = [
|
|
"command_0",
|
|
"command_1",
|
|
"command_2",
|
|
"command_3",
|
|
"command_4",
|
|
"command_5",
|
|
"command_6",
|
|
"command_7",
|
|
];
|
|
/// A flat calibration point in the middle of the range below, so scores straddle 1.
|
|
const BASELINE_HZ: f64 = 30.0;
|
|
const MAX_HZ: f64 = 60.0;
|
|
|
|
/// Sixty seeds, the first twenty of which are the design's twenty.
|
|
///
|
|
/// Twenty is what `docs/design/room-escape.md` section 1 asks for and it is the default. It turned
|
|
/// out not to be enough to separate the candidates: every one of them leaves both rooms in every
|
|
/// one of twenty runs, so the leave fraction saturates and the median time to leave moves by more
|
|
/// between neighbouring hold values than it does across the whole range. `FLY_ESCAPE_SEEDS=60`
|
|
/// triples the power at no cost but wall time; the report carries both.
|
|
const SEEDS: [i32; 60] = [
|
|
20260916, 4242, 7, 31337, 99991, 1, 2718281, 57721, 16180, 14142, 22360, 26457, 30000, 8675309,
|
|
525600, 112358, 999, 4177, 60103, 271828, 13, 97, 1009, 2047, 3301, 4099, 5041, 6007, 7919,
|
|
8191, 9001, 10007, 11111, 12289, 13331, 14407, 15551, 16661, 17389, 18433, 19553, 20611, 21701,
|
|
22801, 23917, 24989, 26041, 27109, 28201, 29333, 30403, 31511, 32609, 33713, 34819, 35911,
|
|
37013, 38119, 39217, 40321,
|
|
];
|
|
|
|
/// The candidates from `docs/design/room-escape.md` §1: hold = decision, fatigue gain 0.08 (today)
|
|
/// against 0.04 (the design's proposal).
|
|
const HOLDS: [f64; 4] = [400.0, 600.0, 800.0, 1000.0];
|
|
const FATIGUES: [f64; 2] = [0.08, 0.04];
|
|
|
|
fn env_f64(name: &str, default: f64) -> f64 {
|
|
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
|
|
}
|
|
|
|
fn env_usize(name: &str, default: usize) -> usize {
|
|
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
|
|
}
|
|
|
|
fn env_list(name: &str, default: &[f64]) -> Vec<f64> {
|
|
match std::env::var(name) {
|
|
Ok(text) => text.split(',').filter_map(|piece| piece.trim().parse().ok()).collect(),
|
|
Err(_) => default.to_vec(),
|
|
}
|
|
}
|
|
|
|
/// The Game Boy preset with the exclusive group's timings overridden.
|
|
///
|
|
/// Every other channel — A, B, Start, Select, the hysteresis, the fatigue decay and the clear
|
|
/// lockout — is the preset's own, because those are not what is being measured.
|
|
fn tuned(
|
|
hold_ms: f64,
|
|
decision_ms: f64,
|
|
fatigue_gain: f64,
|
|
blocked_fatigue: f64,
|
|
hysteresis: f64,
|
|
) -> DecoderConfig {
|
|
let mut config = gameboy_decoder_config();
|
|
let group = config.exclusive.as_mut().expect("the Game Boy preset has an exclusive group");
|
|
group.hold_ms = hold_ms;
|
|
group.decision_ms = decision_ms;
|
|
group.fatigue_gain = fatigue_gain;
|
|
group.hysteresis = hysteresis;
|
|
group.blocked_fatigue = blocked_fatigue;
|
|
group.blocked_ms = if blocked_fatigue > 0.0 { hold_ms } else { 0.0 };
|
|
config
|
|
}
|
|
|
|
/// The `xorshift` generator the workspace's fixtures use.
|
|
fn xorshift(seed: i32) -> impl FnMut() -> f64 {
|
|
let mut state = if seed == 0 { 1 } else { seed };
|
|
move || {
|
|
state ^= state << 13;
|
|
state ^= ((state as u32) >> 17) as i32;
|
|
state ^= state << 5;
|
|
f64::from(state as u32) / 4_294_967_296.0
|
|
}
|
|
}
|
|
|
|
fn emulator(rom: &[u8]) -> Emulator {
|
|
Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
|
.expect("binjgb should accept the cartridge")
|
|
}
|
|
|
|
/// A calibrated decoder: the flat baseline every run scores against.
|
|
fn calibrated(config: DecoderConfig) -> PopulationDecoder {
|
|
let mut decoder = PopulationDecoder::new(config).expect("a valid preset");
|
|
decoder.calibrate(&NumberMap::from_pairs(ROLES.iter().map(|role| (*role, BASELINE_HZ))));
|
|
decoder
|
|
}
|
|
|
|
/// Boot the cartridge to a playable bedroom and return the save state.
|
|
///
|
|
/// The button pattern is `tests/rom.rs`'s: Start and A on a slow alternation, because a held
|
|
/// button is ignored and each press has to be released, then B alone — the intro's A-mashing
|
|
/// leaves a text box open and until it is dismissed the player cannot move at all, and B advances
|
|
/// text without starting a conversation with whatever the player is facing.
|
|
fn boot_to_bedroom(rom: &[u8]) -> (Vec<u8>, f64) {
|
|
let mut emulator = emulator(rom);
|
|
let mut adapter = PokemonRedReward::new();
|
|
let mut ms = 0.0;
|
|
|
|
for frame in 0..6_000u32 {
|
|
let mask = match frame % 32 {
|
|
0..=7 => buttons::START,
|
|
16..=23 => buttons::A,
|
|
_ => buttons::NONE,
|
|
};
|
|
emulator.set_buttons(mask);
|
|
emulator.run_frame().expect("a frame should complete");
|
|
ms += GAMEBOY_MS_PER_FRAME;
|
|
adapter.sample(&mut emulator, ms);
|
|
if adapter.mode() == "OVERWORLD" && frame > 3_000 {
|
|
break;
|
|
}
|
|
}
|
|
for frame in 0..12_000u32 {
|
|
let mask = if frame % 24 < 8 { buttons::B } else { buttons::NONE };
|
|
emulator.set_buttons(mask);
|
|
emulator.run_frame().expect("a frame should complete");
|
|
ms += GAMEBOY_MS_PER_FRAME;
|
|
adapter.sample(&mut emulator, ms);
|
|
if adapter.safe_for_snapshot() {
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
adapter.safe_for_snapshot(),
|
|
"the intro never reached a stable dialogue-free overworld sample (mode {})",
|
|
adapter.mode()
|
|
);
|
|
assert_eq!(
|
|
adapter.map_id(),
|
|
Some(REDS_HOUSE_2F),
|
|
"a cold boot should end in Red's bedroom"
|
|
);
|
|
(emulator.export_state().expect("state export"), ms)
|
|
}
|
|
|
|
/// One measured run.
|
|
struct Run {
|
|
/// Brain milliseconds at which the starting map was first left, or `None`.
|
|
left_ms: Option<f64>,
|
|
/// Brain milliseconds at which a map outside Red's house was first reached, or `None`.
|
|
///
|
|
/// v0.1.0's tables reported `left_ms` only, which on the ground floor is the staircase: one
|
|
/// step, and it lands in the other room of the same house. This column is the one the v0.1.1
|
|
/// question is about, and it is here so that the walker says what the *best case* is for any
|
|
/// policy that chooses directions without a plan.
|
|
out_ms: Option<f64>,
|
|
/// Maps visited, in order, starting with the room the run started in.
|
|
maps: Vec<u32>,
|
|
/// Distinct player coordinates the adapter saw over the whole budget, which for a fresh
|
|
/// adapter is how much ground this run covered. Uncensored: unlike the time to leave, it is
|
|
/// measured over the same brain minutes for every run, so it is the metric that discriminates
|
|
/// once every candidate escapes every room.
|
|
tiles: usize,
|
|
/// A save state captured on the map named by [`Capture`].
|
|
captured: Option<Vec<u8>>,
|
|
}
|
|
|
|
/// Where and when to export a starting state for a later sweep.
|
|
///
|
|
/// `after_ms` exists because the first safe sample after a warp is *on* the destination warp tile:
|
|
/// a ground-floor state captured there would sit on the staircase, and "leave the map" from the
|
|
/// staircase means one step back up. Waiting a few seconds puts the capture somewhere in the room
|
|
/// instead, so the sweep measures finding an exit rather than standing on one.
|
|
#[derive(Clone, Copy)]
|
|
struct Capture {
|
|
map: u32,
|
|
after_ms: f64,
|
|
}
|
|
|
|
/// Drive `config` from a seeded uniform random rate source and watch for a map change.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn walk(
|
|
rom: &[u8],
|
|
start: &[u8],
|
|
start_map: u32,
|
|
seed: i32,
|
|
config: DecoderConfig,
|
|
minutes: f64,
|
|
capture: Option<Capture>,
|
|
stop_on_leave: bool,
|
|
) -> Run {
|
|
let mut emulator = emulator(rom);
|
|
emulator.import_state(start).expect("the starting state should import");
|
|
let mut adapter = PokemonRedReward::new();
|
|
let mut decoder = calibrated(config);
|
|
let mut random = xorshift(seed);
|
|
|
|
let mut run =
|
|
Run { left_ms: None, out_ms: None, maps: vec![start_map], tiles: 0, captured: None };
|
|
let until = minutes * 60_000.0;
|
|
let mut ms = 0.0;
|
|
let mut arrived_ms = None;
|
|
while ms < until {
|
|
ms += GAMEBOY_MS_PER_FRAME;
|
|
let mut rates = NumberMap::new();
|
|
for role in ROLES {
|
|
rates.set(role, random() * MAX_HZ);
|
|
}
|
|
let active = decoder.decode(&rates, ms, adapter.boot());
|
|
emulator.set_buttons(to_button_mask(&active) as u8);
|
|
emulator.run_frame().expect("a frame should complete");
|
|
adapter.sample(&mut emulator, ms);
|
|
run.tiles = adapter.progress().unique_locations;
|
|
|
|
let Some(map) = adapter.map_id() else { continue };
|
|
if run.maps.last() != Some(&map) {
|
|
run.maps.push(map);
|
|
}
|
|
if map != start_map && run.left_ms.is_none() {
|
|
run.left_ms = Some(ms);
|
|
}
|
|
if map != REDS_HOUSE_1F && map != REDS_HOUSE_2F && run.out_ms.is_none() {
|
|
run.out_ms = Some(ms);
|
|
}
|
|
if let Some(capture) = capture
|
|
&& capture.map == map
|
|
&& run.captured.is_none()
|
|
{
|
|
let arrived = *arrived_ms.get_or_insert(ms);
|
|
if ms - arrived >= capture.after_ms && adapter.safe_for_snapshot() {
|
|
run.captured = Some(emulator.export_state().expect("state export"));
|
|
}
|
|
}
|
|
if stop_on_leave && run.left_ms.is_some() && (capture.is_none() || run.captured.is_some())
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
run
|
|
}
|
|
|
|
/// Arithmetic mean, or `None` for no samples.
|
|
fn mean(values: &[f64]) -> Option<f64> {
|
|
(!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
|
|
}
|
|
|
|
/// Quartile-free median: the mean of the two central values for an even count, as a spreadsheet
|
|
/// would compute it. Empty input has no median.
|
|
fn median(values: &mut [f64]) -> Option<f64> {
|
|
if values.is_empty() {
|
|
return None;
|
|
}
|
|
values.sort_by(|a, b| a.partial_cmp(b).expect("finite times"));
|
|
let middle = values.len() / 2;
|
|
Some(if values.len().is_multiple_of(2) {
|
|
(values[middle - 1] + values[middle]) / 2.0
|
|
} else {
|
|
values[middle]
|
|
})
|
|
}
|
|
|
|
struct Job {
|
|
room: &'static str,
|
|
start_map: u32,
|
|
hold: f64,
|
|
fatigue: f64,
|
|
hysteresis: f64,
|
|
seed: i32,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Cell {
|
|
left: Vec<f64>,
|
|
out: Vec<f64>,
|
|
runs: usize,
|
|
tiles: Vec<f64>,
|
|
/// Distinct maps each run reached beyond the one it started in.
|
|
reached: Vec<f64>,
|
|
maps: BTreeMap<u32, usize>,
|
|
}
|
|
|
|
/// Run every job across `threads` worker threads, each with its own emulator.
|
|
fn sweep(
|
|
rom: &[u8],
|
|
states: &BTreeMap<&'static str, Vec<u8>>,
|
|
jobs: &[Job],
|
|
minutes: f64,
|
|
threads: usize,
|
|
) -> BTreeMap<(String, u64, u64, u64), Cell> {
|
|
let next = AtomicUsize::new(0);
|
|
let mut results: Vec<Vec<(usize, Run)>> = Vec::new();
|
|
std::thread::scope(|scope| {
|
|
let mut handles = Vec::new();
|
|
for _ in 0..threads.max(1) {
|
|
handles.push(scope.spawn(|| {
|
|
let mut mine = Vec::new();
|
|
loop {
|
|
let index = next.fetch_add(1, Ordering::Relaxed);
|
|
let Some(job) = jobs.get(index) else { break };
|
|
let run = walk(
|
|
rom,
|
|
&states[job.room],
|
|
job.start_map,
|
|
job.seed,
|
|
tuned(job.hold, job.hold, job.fatigue, 0.0, job.hysteresis),
|
|
minutes,
|
|
None,
|
|
false,
|
|
);
|
|
mine.push((index, run));
|
|
}
|
|
mine
|
|
}));
|
|
}
|
|
for handle in handles {
|
|
results.push(handle.join().expect("a worker thread"));
|
|
}
|
|
});
|
|
|
|
let mut cells: BTreeMap<(String, u64, u64, u64), Cell> = BTreeMap::new();
|
|
for (index, run) in results.into_iter().flatten() {
|
|
let job = &jobs[index];
|
|
let key = (
|
|
job.room.to_string(),
|
|
job.hold as u64,
|
|
(job.fatigue * 1000.0) as u64,
|
|
(job.hysteresis * 1000.0) as u64,
|
|
);
|
|
let cell = cells.entry(key).or_default();
|
|
cell.runs += 1;
|
|
cell.tiles.push(run.tiles as f64);
|
|
let distinct: std::collections::BTreeSet<u32> =
|
|
run.maps.iter().skip(1).copied().collect();
|
|
cell.reached.push(distinct.len() as f64);
|
|
if let Some(ms) = run.left_ms {
|
|
cell.left.push(ms);
|
|
}
|
|
if let Some(ms) = run.out_ms {
|
|
cell.out.push(ms);
|
|
}
|
|
for map in distinct {
|
|
*cell.maps.entry(map).or_insert(0) += 1;
|
|
}
|
|
}
|
|
cells
|
|
}
|
|
|
|
fn print_table(title: &str, cells: &BTreeMap<(String, u64, u64, u64), Cell>, room: &str) {
|
|
println!("\n### {title}\n");
|
|
println!(
|
|
"| hold = decision | fatigue gain | left the map | median to leave | mean to leave | \
|
|
out of the house | median to leave it | median tiles | mean tiles | median maps |"
|
|
);
|
|
println!(
|
|
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"
|
|
);
|
|
for ((cell_room, hold, fatigue, hysteresis), cell) in cells {
|
|
if cell_room != room {
|
|
continue;
|
|
}
|
|
let mut left = cell.left.clone();
|
|
let median_ms = median(&mut left)
|
|
.map(|ms| format!("{:.1} s", ms / 1000.0))
|
|
.unwrap_or_else(|| "—".to_string());
|
|
let mut out = cell.out.clone();
|
|
let median_out = median(&mut out)
|
|
.map(|ms| format!("{:.1} s", ms / 1000.0))
|
|
.unwrap_or_else(|| "—".to_string());
|
|
let mut tiles = cell.tiles.clone();
|
|
let median_tiles =
|
|
median(&mut tiles).map(|count| format!("{count:.1}")).unwrap_or_else(|| "—".to_string());
|
|
let mut reached = cell.reached.clone();
|
|
let median_maps = median(&mut reached)
|
|
.map(|count| format!("{count:.1}"))
|
|
.unwrap_or_else(|| "—".to_string());
|
|
println!(
|
|
"| {hold} ms | {:.2} | {:.2} | {}/{} | {median_ms} | {} | {}/{} | {median_out} | \
|
|
{median_tiles} | {} | {median_maps} |",
|
|
*fatigue as f64 / 1000.0,
|
|
*hysteresis as f64 / 1000.0,
|
|
cell.left.len(),
|
|
cell.runs,
|
|
mean(&cell.left).map(|ms| format!("{:.1} s", ms / 1000.0)).unwrap_or_else(|| "—".to_string()),
|
|
cell.out.len(),
|
|
cell.runs,
|
|
mean(&cell.tiles).map(|count| format!("{count:.1}")).unwrap_or_else(|| "—".to_string()),
|
|
);
|
|
}
|
|
let maps: BTreeMap<u32, usize> = cells
|
|
.iter()
|
|
.filter(|((cell_room, _, _, _), _)| cell_room == room)
|
|
.flat_map(|(_, cell)| cell.maps.iter())
|
|
.fold(BTreeMap::new(), |mut all, (map, count)| {
|
|
*all.entry(*map).or_insert(0) += count;
|
|
all
|
|
});
|
|
let maps: Vec<String> = maps.iter().map(|(map, count)| format!("{map} in {count}")).collect();
|
|
println!("\nMaps reached across every run in this table: {}.", maps.join(", "));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// The real brain
|
|
// -------------------------------------------------------------------------------------------
|
|
|
|
/// Where one brain run starts.
|
|
enum Start<'a> {
|
|
/// A save state and the map it stands in: a fresh fly, warmed up here.
|
|
Fresh { state: &'a [u8], map: u32, warmup_ms: u64 },
|
|
/// A `FLYSIM01` checkpoint: the live fly, its emulator, its reward ledger and its ratchet,
|
|
/// with the network's noise stream displaced by `rng` so the runs are independent.
|
|
Live { checkpoint: &'a flysim::store::Checkpoint, rng: i32 },
|
|
}
|
|
|
|
/// The four exclusive channels, in preset order. The index is the `command_N` role.
|
|
const DIRECTIONS: [&str; 4] = ["up", "down", "left", "right"];
|
|
|
|
/// Everything one instrumented brain run reports.
|
|
///
|
|
/// The list is the v0.1.1 diagnosis list in `docs/design/room-escape.md` section 3: leave or no
|
|
/// leave, time to leave, coverage, the direction-hold histogram, how often hysteresis rather than
|
|
/// the argmax decided the winner, and what the fly did while it stood on a tile one press from the
|
|
/// exit.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct Trace {
|
|
/// Brain ms after the run's own start at which the starting map was first left.
|
|
left_ms: Option<f64>,
|
|
/// Brain ms at which a map outside the starting *building* was first reached. On the ground
|
|
/// floor, leaving the map by the staircase is not leaving the house; this column is.
|
|
out_ms: Option<f64>,
|
|
/// Maps visited in order, starting with the one the run started in.
|
|
maps: Vec<u32>,
|
|
/// The adapter's lifetime unique-location count at the end of the run.
|
|
tiles: usize,
|
|
/// New unique locations this run added to that count.
|
|
new_tiles: usize,
|
|
/// Exclusive-group decisions taken.
|
|
decisions: u64,
|
|
/// Decisions won, per direction.
|
|
wins: BTreeMap<&'static str, u64>,
|
|
/// Decisions at which the fatigue-adjusted argmax lost to the incumbent under hysteresis.
|
|
hysteresis_holds: u64,
|
|
/// Length, in consecutive decisions, of every completed same-direction run.
|
|
runs: Vec<f64>,
|
|
/// Holds over which the player's position never changed, per direction: a wall bump.
|
|
blocked_holds: BTreeMap<&'static str, u64>,
|
|
/// Longest single stretch with no change of position, in brain ms.
|
|
longest_still_ms: f64,
|
|
/// Frames spent standing on a tile from which one press leaves the map, per tile.
|
|
exit_tile_frames: BTreeMap<(u32, u32), u64>,
|
|
/// Decisions taken while standing on such a tile.
|
|
exit_decisions: u64,
|
|
/// ...of which the winner was a direction that leaves from that tile.
|
|
exit_decisions_taken: u64,
|
|
/// Recoveries the ratchet fired during the run, and whether its attempt budget is now spent.
|
|
recoveries: u64,
|
|
budget_spent: bool,
|
|
/// Reward paid during the run.
|
|
reward: f64,
|
|
}
|
|
|
|
impl Trace {
|
|
fn mean_run(&self) -> Option<f64> {
|
|
mean(&self.runs)
|
|
}
|
|
|
|
fn win_share(&self, direction: &str) -> f64 {
|
|
if self.decisions == 0 {
|
|
return 0.0;
|
|
}
|
|
self.wins.get(direction).copied().unwrap_or(0) as f64 / self.decisions as f64
|
|
}
|
|
|
|
fn blocked(&self) -> u64 {
|
|
self.blocked_holds.values().sum()
|
|
}
|
|
}
|
|
|
|
/// The tiles a map's [`survey`] found reachable, and which presses leave it from each of them.
|
|
type Survey = (BTreeSet<(u32, u32)>, BTreeMap<(u32, u32), Vec<&'static str>>);
|
|
|
|
/// The starting map's reachable tiles, and which presses leave it -- measured, not assumed.
|
|
///
|
|
/// A breadth-first walk of the map with real button presses on throwaway emulators: from each
|
|
/// tile, hold each direction until the coordinates change or 48 frames pass, and record either the
|
|
/// tile reached or the map escaped to. Nothing neural is involved and the measured run's own
|
|
/// emulator is untouched, so this is instrumentation rather than a hint. It is what makes the
|
|
/// report's coverage column mean something ("32 of 48 tiles", not "71 locations") and what lets it
|
|
/// count the decisions that *could* have ended the run.
|
|
fn survey(rom: &[u8], state: &[u8]) -> Survey {
|
|
let read = |probe: &mut Emulator| {
|
|
(
|
|
u32::from(probe.read8(ram::wCurMap)),
|
|
u32::from(probe.read8(ram::wXCoord)),
|
|
u32::from(probe.read8(ram::wYCoord)),
|
|
)
|
|
};
|
|
let step = |probe: &mut Emulator, mask: u8| {
|
|
let before = read(probe);
|
|
let mut moved = false;
|
|
probe.set_buttons(mask);
|
|
for _ in 0..48 {
|
|
probe.run_frame().expect("a frame should complete");
|
|
if read(probe) != before {
|
|
moved = true;
|
|
break;
|
|
}
|
|
}
|
|
probe.set_buttons(buttons::NONE);
|
|
for _ in 0..10 {
|
|
probe.run_frame().expect("a frame should complete");
|
|
}
|
|
(read(probe), moved)
|
|
};
|
|
let restore = |state: &[u8]| {
|
|
let mut probe = emulator(rom);
|
|
probe.import_state(state).expect("a surveyed state should import");
|
|
probe
|
|
};
|
|
|
|
let mut first = restore(state);
|
|
let (start_map, x, y) = read(&mut first);
|
|
let mut reachable = BTreeSet::new();
|
|
let mut exits: BTreeMap<(u32, u32), Vec<&'static str>> = BTreeMap::new();
|
|
let mut states: BTreeMap<(u32, u32), Vec<u8>> = BTreeMap::new();
|
|
reachable.insert((x, y));
|
|
states.insert((x, y), state.to_vec());
|
|
let mut queue = vec![(x, y)];
|
|
while let Some(tile) = queue.pop() {
|
|
for (name, mask) in [
|
|
("up", buttons::UP),
|
|
("down", buttons::DOWN),
|
|
("left", buttons::LEFT),
|
|
("right", buttons::RIGHT),
|
|
] {
|
|
let mut probe = restore(&states[&tile]);
|
|
let ((map, x, y), moved) = step(&mut probe, mask);
|
|
if !moved {
|
|
continue;
|
|
}
|
|
if map != start_map {
|
|
exits.entry(tile).or_default().push(name);
|
|
continue;
|
|
}
|
|
if reachable.insert((x, y)) {
|
|
states.insert((x, y), probe.export_state().expect("state export"));
|
|
queue.push((x, y));
|
|
}
|
|
}
|
|
}
|
|
(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
|
|
/// live checkpoint -- the real reward ledger and the real ratchet.
|
|
///
|
|
/// ## How the runs are made independent
|
|
///
|
|
/// The network is deterministic and so is binjgb, so N runs from one state are one run reported N
|
|
/// times. [`Start::Fresh`] varies the warm-up length, as v0.1.0's confirmation table did: it is
|
|
/// how long the fly settles before its resting rates are calibrated, and on the stream that is
|
|
/// wherever the process happened to start. A *restore* has no warm-up at all -- `simloop.rs` skips
|
|
/// it deliberately, because a checkpoint already carries a settled network and a calibrated
|
|
/// readout -- so [`Start::Live`] instead displaces `LifState::rng`, the xorshift state behind the
|
|
/// per-tick noise kicks. That is the only thing it changes: the membranes, the learned gains, the
|
|
/// eligibility traces, the brain clock, the decoder's fatigue, holds and baseline, the emulator and
|
|
/// the reward ledger are all the live fly's to the byte. Five noise streams from one state is the
|
|
/// same kind of variation five warm-ups are, and on a restored run it is the only arbitrary
|
|
/// quantity left.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn brain_trace(
|
|
rom: &[u8],
|
|
data: &Arc<flybrain_core::dataset::BrainDataset>,
|
|
start: Start<'_>,
|
|
config: DecoderConfig,
|
|
minutes: f64,
|
|
threads: usize,
|
|
house: &[u32],
|
|
exits: &BTreeMap<(u32, u32), Vec<&'static str>>,
|
|
stop_on_exit: bool,
|
|
) -> Trace {
|
|
let mut emulator = emulator(rom);
|
|
let mut adapter = PokemonRedReward::new();
|
|
let mut agent_config = AgentConfig::with_decoder(config.clone());
|
|
let hold_ms =
|
|
config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms;
|
|
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
|
let mut trace = Trace::default();
|
|
if let Start::Fresh { warmup_ms, .. } = &start {
|
|
agent_config.warmup_ms = *warmup_ms;
|
|
}
|
|
|
|
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
|
if threads > 1 {
|
|
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
|
}
|
|
// The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer.
|
|
let mut frame = LegacyFrame::new();
|
|
let start_map = match &start {
|
|
Start::Fresh { state, map, .. } => {
|
|
emulator.import_state(state).expect("the starting state should import");
|
|
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
|
|
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
|
|
*map
|
|
}
|
|
Start::Live { checkpoint, rng } => {
|
|
let mut checkpoint = (*checkpoint).clone();
|
|
checkpoint.agent.network.rng = *rng;
|
|
frame
|
|
.restore(
|
|
&mut Parts {
|
|
agent: &mut agent,
|
|
emulator: &mut emulator,
|
|
adapter: &mut adapter,
|
|
ratchet: &mut ratchet,
|
|
macros: None,
|
|
},
|
|
&checkpoint,
|
|
)
|
|
.expect("the checkpoint should restore");
|
|
u32::from(emulator.read8(ram::wCurMap))
|
|
}
|
|
};
|
|
|
|
trace.maps.push(start_map);
|
|
let began_ms = agent.network.ms;
|
|
let until = began_ms + minutes * 60_000.0;
|
|
let mut escape = Escape {
|
|
before: None,
|
|
hold_start: (frame.location, began_ms),
|
|
run_winner: None,
|
|
run_length: 0.0,
|
|
start_map,
|
|
exits,
|
|
hold_ms,
|
|
trace,
|
|
};
|
|
// Reported on its own: the longest stretch with no movement at all, whatever was held.
|
|
let mut still_since_ms = began_ms;
|
|
let tiles_at_start = adapter.progress().unique_locations;
|
|
escape.trace.tiles = tiles_at_start;
|
|
|
|
while agent.network.ms < until {
|
|
let mut parts = Parts {
|
|
agent: &mut agent,
|
|
emulator: &mut emulator,
|
|
adapter: &mut adapter,
|
|
ratchet: &mut ratchet,
|
|
macros: None,
|
|
};
|
|
let location_before = frame.location;
|
|
let transition = frame.transition(&mut parts, &mut escape).expect("a frame");
|
|
let ms = transition.ms;
|
|
let trace = &mut escape.trace;
|
|
trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::<f64>();
|
|
trace.tiles = transition.evaluated.progress.unique_locations;
|
|
|
|
let location = frame.location;
|
|
if location != location_before {
|
|
still_since_ms = ms;
|
|
}
|
|
trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms);
|
|
if let Some(tile) = location
|
|
.filter(|(map, _, _)| *map == start_map)
|
|
.map(|(_, x, y)| (x, y))
|
|
.filter(|tile| exits.contains_key(tile))
|
|
{
|
|
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
|
|
}
|
|
|
|
// The ratchet, with the adapter's own policy and the checkpoint's own budget.
|
|
let progress = transition.evaluated.progress;
|
|
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
|
|
if boundary.rollback.is_some() {
|
|
escape.trace.recoveries += 1;
|
|
still_since_ms = ms;
|
|
escape.hold_start = (frame.location, ms);
|
|
}
|
|
let trace = &mut escape.trace;
|
|
|
|
let Some(map) = adapter.map_id() else { continue };
|
|
if trace.maps.last() != Some(&map) {
|
|
trace.maps.push(map);
|
|
}
|
|
if map != start_map && trace.left_ms.is_none() {
|
|
trace.left_ms = Some(ms - began_ms);
|
|
}
|
|
if !house.contains(&map) && trace.out_ms.is_none() {
|
|
trace.out_ms = Some(ms - began_ms);
|
|
if stop_on_exit {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let Escape { mut trace, run_length, .. } = escape;
|
|
if run_length > 0.0 {
|
|
trace.runs.push(run_length);
|
|
}
|
|
trace.new_tiles = trace.tiles.saturating_sub(tiles_at_start);
|
|
trace.budget_spent = ratchet.state.attempts >= ratchet.policy().max_attempts;
|
|
trace
|
|
}
|
|
|
|
/// One readout candidate, named for the report.
|
|
#[derive(Clone, Copy)]
|
|
struct Candidate {
|
|
name: &'static str,
|
|
hold: f64,
|
|
fatigue: f64,
|
|
/// Fatigue forced onto a blocked direction; 0 is the rule switched off.
|
|
blocked_fatigue: f64,
|
|
/// The lead a challenger needs over the incumbent. 1.0 is no commitment bonus at all.
|
|
hysteresis: f64,
|
|
}
|
|
|
|
impl Candidate {
|
|
fn config(&self) -> DecoderConfig {
|
|
tuned(self.hold, self.hold, self.fatigue, self.blocked_fatigue, self.hysteresis)
|
|
}
|
|
}
|
|
|
|
/// The candidates `docs/design/room-escape.md` section 3 lists, in its order of preference.
|
|
///
|
|
/// The last three are the hysteresis axis, which the first four do not touch: with the live fly's
|
|
/// four direction scores inside a 8.6% spread, a 15% lead is unreachable and the incumbent can only
|
|
/// ever be displaced by fatigue (`infra/docs/room-escape.md`). 1.05 is a commitment bonus the score
|
|
/// spread can actually overcome; 1.00 is no bonus at all, so the hold alone carries the commitment.
|
|
const CANDIDATES: [Candidate; 7] = [
|
|
Candidate { name: "v0.1.0", hold: 800.0, fatigue: 0.04, blocked_fatigue: 0.0, hysteresis: 1.15 },
|
|
Candidate {
|
|
name: "fatigue-0.08",
|
|
hold: 800.0,
|
|
fatigue: 0.08,
|
|
blocked_fatigue: 0.0,
|
|
hysteresis: 1.15,
|
|
},
|
|
Candidate {
|
|
name: "blocked-0.35",
|
|
hold: 800.0,
|
|
fatigue: 0.04,
|
|
blocked_fatigue: 0.35,
|
|
hysteresis: 1.15,
|
|
},
|
|
Candidate { name: "both", hold: 800.0, fatigue: 0.08, blocked_fatigue: 0.35, hysteresis: 1.15 },
|
|
Candidate {
|
|
name: "hysteresis-1.05",
|
|
hold: 800.0,
|
|
fatigue: 0.08,
|
|
blocked_fatigue: 0.0,
|
|
hysteresis: 1.05,
|
|
},
|
|
Candidate {
|
|
name: "hysteresis-1.00",
|
|
hold: 800.0,
|
|
fatigue: 0.08,
|
|
blocked_fatigue: 0.0,
|
|
hysteresis: 1.00,
|
|
},
|
|
Candidate {
|
|
name: "hold-600",
|
|
hold: 600.0,
|
|
fatigue: 0.08,
|
|
blocked_fatigue: 0.0,
|
|
hysteresis: 1.05,
|
|
},
|
|
];
|
|
|
|
/// `FLY_ESCAPE_CANDIDATES` is a comma-separated list of [`CANDIDATES`] names, or of
|
|
/// `hold/fatigue/blocked/hysteresis` tuples for a candidate the list does not already hold. The
|
|
/// tuple form is what a follow-up sweep uses without a recompile, and its name in the report is the
|
|
/// tuple itself. The hysteresis is optional and defaults to the preset's 1.15.
|
|
fn candidates_from_env(default: &[Candidate]) -> Vec<Candidate> {
|
|
let Ok(text) = std::env::var("FLY_ESCAPE_CANDIDATES") else {
|
|
return default.to_vec();
|
|
};
|
|
text.split(',')
|
|
.map(str::trim)
|
|
.map(|name| {
|
|
if let Some(candidate) = CANDIDATES.iter().find(|candidate| candidate.name == name) {
|
|
return *candidate;
|
|
}
|
|
let parts: Vec<f64> =
|
|
name.split('/').filter_map(|piece| piece.trim().parse().ok()).collect();
|
|
assert!(
|
|
parts.len() == 3 || parts.len() == 4,
|
|
"unknown candidate {name}: name one of CANDIDATES, or a \
|
|
hold/fatigue/blocked[/hysteresis] tuple"
|
|
);
|
|
Candidate {
|
|
name: String::leak(name.to_string()),
|
|
hold: parts[0],
|
|
fatigue: parts[1],
|
|
blocked_fatigue: parts[2],
|
|
hysteresis: parts.get(3).copied().unwrap_or(1.15),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
fn print_trace_table(name: &str, traces: &[Trace], minutes: f64, target_ms: f64) {
|
|
println!("\n**{name}**\n");
|
|
println!(
|
|
"| run | left the map | out of the house | new tiles | decisions | up | down | left | \
|
|
right | mean run | hysteresis | blocked holds | longest still | exit decisions | \
|
|
recoveries | reward |"
|
|
);
|
|
println!(
|
|
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | \
|
|
---: | ---: | ---: | ---: |"
|
|
);
|
|
let percent =
|
|
|part: u64, whole: u64| if whole == 0 { 0.0 } else { part as f64 / whole as f64 * 100.0 };
|
|
for (index, trace) in traces.iter().enumerate() {
|
|
let time = |value: Option<f64>| {
|
|
value.map(|ms| format!("{:.1} s", ms / 1000.0)).unwrap_or_else(|| "no".to_string())
|
|
};
|
|
println!(
|
|
"| {} | {} | {} | {} | {} | {:.0}% | {:.0}% | {:.0}% | {:.0}% | {} | {:.0}% | {} \
|
|
({:.0}%) | {:.1} s | {}/{} | {}{} | {:.2} |",
|
|
index + 1,
|
|
time(trace.left_ms),
|
|
time(trace.out_ms),
|
|
trace.new_tiles,
|
|
trace.decisions,
|
|
trace.win_share("up") * 100.0,
|
|
trace.win_share("down") * 100.0,
|
|
trace.win_share("left") * 100.0,
|
|
trace.win_share("right") * 100.0,
|
|
trace.mean_run().map(|value| format!("{value:.2}")).unwrap_or_else(|| "—".to_string()),
|
|
percent(trace.hysteresis_holds, trace.decisions),
|
|
trace.blocked(),
|
|
percent(trace.blocked(), trace.decisions),
|
|
trace.longest_still_ms / 1000.0,
|
|
trace.exit_decisions_taken,
|
|
trace.exit_decisions,
|
|
trace.recoveries,
|
|
if trace.budget_spent { "*" } else { "" },
|
|
trace.reward,
|
|
);
|
|
}
|
|
let out: Vec<f64> = traces.iter().filter_map(|trace| trace.out_ms).collect();
|
|
let within = out.iter().filter(|ms| **ms <= target_ms).count();
|
|
let mut sorted = out.clone();
|
|
println!(
|
|
"\n{}/{} runs left the house inside {minutes} brain minutes, {within}/{} inside {:.0}; \
|
|
median {}.",
|
|
out.len(),
|
|
traces.len(),
|
|
traces.len(),
|
|
target_ms / 60_000.0,
|
|
median(&mut sorted)
|
|
.map(|ms| format!("{:.1} s", ms / 1000.0))
|
|
.unwrap_or_else(|| "—".to_string())
|
|
);
|
|
let tiles: BTreeSet<&(u32, u32)> =
|
|
traces.iter().flat_map(|trace| trace.exit_tile_frames.keys()).collect();
|
|
if tiles.is_empty() {
|
|
println!("No run ever stood on a tile one press from the exit.");
|
|
} else {
|
|
let frames: Vec<String> = tiles
|
|
.iter()
|
|
.map(|tile| {
|
|
let total: u64 =
|
|
traces.iter().filter_map(|trace| trace.exit_tile_frames.get(tile)).sum();
|
|
format!("({}, {}): {total}", tile.0, tile.1)
|
|
})
|
|
.collect();
|
|
println!("Frames stood on an exit tile, summed over the runs: {}.", frames.join(", "));
|
|
}
|
|
let maps: BTreeSet<u32> =
|
|
traces.iter().flat_map(|trace| trace.maps.iter().skip(1)).copied().collect();
|
|
let maps: Vec<String> = maps.iter().map(u32::to_string).collect();
|
|
println!("Maps reached beyond the starting one: {}.", maps.join(", "));
|
|
}
|
|
|
|
/// The live-fly diagnosis: `FLY_ESCAPE_CHECKPOINT` plus `FLY_ESCAPE_BRAIN`.
|
|
fn live(rom: &[u8], checkpoint_path: &std::ffi::OsStr, minutes: f64, target_ms: f64) {
|
|
let dataset = PathBuf::from(
|
|
std::env::var_os("FLY_ESCAPE_BRAIN")
|
|
.expect("FLY_ESCAPE_CHECKPOINT needs FLY_ESCAPE_BRAIN: a checkpoint is a brain"),
|
|
);
|
|
let checkpoint = flysim::store::load(std::path::Path::new(checkpoint_path))
|
|
.expect("FLY_ESCAPE_CHECKPOINT should be a FLYSIM01 envelope");
|
|
let runs = env_usize("FLY_ESCAPE_BRAIN_RUNS", 5);
|
|
let brain_threads = env_usize("FLY_ESCAPE_BRAIN_THREADS", 4);
|
|
let parallel = env_usize("FLY_ESCAPE_BRAIN_PARALLEL", 1).max(1);
|
|
let stop_on_exit = std::env::var_os("FLY_ESCAPE_RUN_ON").is_none();
|
|
let candidates = candidates_from_env(&CANDIDATES);
|
|
|
|
let data = Arc::new(
|
|
load_brain_dataset_from_dir(&dataset).expect("FLY_ESCAPE_BRAIN should be a brain dataset"),
|
|
);
|
|
let mut probe = emulator(rom);
|
|
probe.import_state(&checkpoint.runtime.emulator).expect("the emulator state should import");
|
|
let start_map = u32::from(probe.read8(ram::wCurMap));
|
|
let (reachable, exits) = survey(rom, &checkpoint.runtime.emulator);
|
|
let house: Vec<u32> = if start_map == REDS_HOUSE_1F || start_map == REDS_HOUSE_2F {
|
|
vec![REDS_HOUSE_1F, REDS_HOUSE_2F]
|
|
} else {
|
|
vec![start_map]
|
|
};
|
|
|
|
println!("## The live fly, from {}\n", std::path::Path::new(checkpoint_path).display());
|
|
println!(
|
|
"Generation {}, brain clock {:.1} min, emulator frame {}, ratchet best rank {} \
|
|
({} recoveries, {} attempts, coverage {}).",
|
|
checkpoint.runtime.generation,
|
|
checkpoint.agent.network.ms / 60_000.0,
|
|
checkpoint.runtime.emulator_frame,
|
|
checkpoint.runtime.ratchet.best,
|
|
checkpoint.runtime.ratchet.recoveries,
|
|
checkpoint.runtime.ratchet.attempts,
|
|
checkpoint.runtime.ratchet.coverage,
|
|
);
|
|
println!(
|
|
"Standing on map {start_map} at ({}, {}); the decoder is holding {:?}.",
|
|
probe.read8(ram::wXCoord),
|
|
probe.read8(ram::wYCoord),
|
|
checkpoint.agent.decoder.current,
|
|
);
|
|
println!("\nScores as the checkpoint stands, `(rate + 1) / (baseline + 1)`:\n");
|
|
println!("| channel | rate | baseline | score | fatigue | adjusted |");
|
|
println!("| --- | ---: | ---: | ---: | ---: | ---: |");
|
|
for (index, channel) in DIRECTIONS.iter().enumerate() {
|
|
let role = ROLES[index];
|
|
let rate = checkpoint.agent.network.rates.get_or_zero(role);
|
|
let base = checkpoint.agent.decoder.baseline.get_or_zero(role);
|
|
let fatigue = checkpoint.agent.decoder.fatigue.get_or_zero(channel);
|
|
let score = (rate + 1.0) / (base + 1.0);
|
|
println!(
|
|
"| {channel} | {rate:.3} | {base:.3} | {score:.4} | {fatigue:.4} | {:.4} |",
|
|
score / (1.0 + fatigue)
|
|
);
|
|
}
|
|
println!(
|
|
"\n{} reachable tiles on map {start_map}, {} of them one press from leaving:",
|
|
reachable.len(),
|
|
exits.len()
|
|
);
|
|
for (tile, directions) in &exits {
|
|
println!("- ({}, {}) pressing {}", tile.0, tile.1, directions.join(" or "));
|
|
}
|
|
let visited: BTreeSet<(u32, u32)> = checkpoint.runtime.reward["tiles"]
|
|
.as_array()
|
|
.map(|tiles| {
|
|
tiles
|
|
.iter()
|
|
.filter_map(|value| value.as_str())
|
|
.filter_map(|key| {
|
|
let mut parts = key.split(':');
|
|
let map: u32 = parts.next()?.parse().ok()?;
|
|
if map != start_map {
|
|
return None;
|
|
}
|
|
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let never: Vec<(u32, u32)> = reachable.difference(&visited).copied().collect();
|
|
println!(
|
|
"\nThe live tile ledger holds {} of those {}. Never stood on: {never:?}",
|
|
visited.len(),
|
|
reachable.len()
|
|
);
|
|
|
|
for candidate in &candidates {
|
|
let config = candidate.config();
|
|
let started = std::time::Instant::now();
|
|
let next = AtomicUsize::new(0);
|
|
let mut collected: Vec<(usize, Trace)> = Vec::new();
|
|
std::thread::scope(|scope| {
|
|
let mut handles = Vec::new();
|
|
for _ in 0..parallel {
|
|
let config = config.clone();
|
|
let (data, checkpoint, exits, house, next) =
|
|
(&data, &checkpoint, &exits, &house, &next);
|
|
handles.push(scope.spawn(move || {
|
|
let mut mine = Vec::new();
|
|
loop {
|
|
let index = next.fetch_add(1, Ordering::Relaxed);
|
|
if index >= runs {
|
|
break;
|
|
}
|
|
// The noise stream, displaced per run: see [`brain_trace`].
|
|
let rng =
|
|
checkpoint.agent.network.rng.wrapping_add(SEEDS[index % SEEDS.len()]);
|
|
let trace = brain_trace(
|
|
rom,
|
|
data,
|
|
Start::Live { checkpoint, rng: if rng == 0 { 1 } else { rng } },
|
|
config.clone(),
|
|
minutes,
|
|
brain_threads,
|
|
house,
|
|
exits,
|
|
stop_on_exit,
|
|
);
|
|
eprintln!("{} live run {} done", candidate.name, index + 1);
|
|
mine.push((index, trace));
|
|
}
|
|
mine
|
|
}));
|
|
}
|
|
for handle in handles {
|
|
collected.extend(handle.join().expect("a worker thread"));
|
|
}
|
|
});
|
|
collected.sort_by_key(|(index, _)| *index);
|
|
let traces: Vec<Trace> = collected.into_iter().map(|(_, trace)| trace).collect();
|
|
print_trace_table(candidate.name, &traces, minutes, target_ms);
|
|
eprintln!("{} took {:.1} s wall", candidate.name, started.elapsed().as_secs_f64());
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let Some(path) = std::env::var_os("FLY_ROM") else {
|
|
println!(
|
|
"FLY_ROM is not set, so there is no room to escape.\n\
|
|
\n FLY_ROM=\"$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb\" \\\n \
|
|
cargo run --release -p flysim --example room_escape\n\n\
|
|
See docs/design/room-escape.md §1 and the module comment."
|
|
);
|
|
return;
|
|
};
|
|
let rom = std::fs::read(&path)
|
|
.unwrap_or_else(|error| panic!("FLY_ROM is {path:?} but could not be read: {error}"));
|
|
{
|
|
let check = emulator(&rom);
|
|
assert_eq!(
|
|
check.rom_sha256(),
|
|
SUPPORTED_ROM,
|
|
"FLY_ROM is not the cartridge the adapter is pinned to"
|
|
);
|
|
}
|
|
|
|
let minutes = env_f64("FLY_ESCAPE_MINUTES", 10.0);
|
|
let seeds = env_usize("FLY_ESCAPE_SEEDS", SEEDS.len()).clamp(1, SEEDS.len());
|
|
let holds = env_list("FLY_ESCAPE_HOLDS", &HOLDS);
|
|
let fatigues = env_list("FLY_ESCAPE_FATIGUES", &FATIGUES);
|
|
let hystereses = env_list("FLY_ESCAPE_HYSTERESES", &[1.15]);
|
|
let threads = env_usize("FLY_ESCAPE_JOBS", 8);
|
|
let target_ms = env_f64("FLY_ESCAPE_TARGET_MINUTES", 5.0) * 60_000.0;
|
|
// Brain milliseconds to wander the ground floor before its starting state is captured, so the
|
|
// capture is not standing on the staircase it just arrived by. See [`Capture`].
|
|
let capture_delay = env_f64("FLY_ESCAPE_CAPTURE_DELAY", 15_000.0);
|
|
let state_1f = PathBuf::from(
|
|
std::env::var_os("FLY_ESCAPE_STATE_1F").unwrap_or_else(|| "room-escape-1f.state".into()),
|
|
);
|
|
|
|
// v0.1.1: the live fly, forward from the checkpoint the release box wrote.
|
|
if let Some(checkpoint) = std::env::var_os("FLY_ESCAPE_CHECKPOINT") {
|
|
live(&rom, &checkpoint, minutes, target_ms);
|
|
return;
|
|
}
|
|
|
|
let skip_sweep = std::env::var_os("FLY_ESCAPE_SKIP_SWEEP").is_some();
|
|
if !skip_sweep {
|
|
println!(
|
|
"room escape: {minutes} brain minutes x {seeds} seeds x {} holds x {} fatigue gains \
|
|
x {} hystereses x 2 rooms, on {} threads, no brain",
|
|
holds.len(),
|
|
fatigues.len(),
|
|
hystereses.len(),
|
|
threads
|
|
);
|
|
}
|
|
println!("channels: {}", GAMEBOY_BUTTONS.join(", "));
|
|
|
|
let started = std::time::Instant::now();
|
|
let bedroom = match std::env::var_os("FLY_ESCAPE_STATE") {
|
|
Some(path) => {
|
|
println!("bedroom: restored from {path:?}");
|
|
std::fs::read(&path).expect("FLY_ESCAPE_STATE should be readable")
|
|
}
|
|
None => {
|
|
let (state, ms) = boot_to_bedroom(&rom);
|
|
println!(
|
|
"bedroom: booted the cartridge in {:.1} s wall, {:.1} s of game time",
|
|
started.elapsed().as_secs_f64(),
|
|
ms / 1000.0
|
|
);
|
|
state
|
|
}
|
|
};
|
|
|
|
// The ground floor, walked to once and cached: a starting state, not a measurement.
|
|
let ground = if state_1f.is_file() {
|
|
println!("house 1F: reusing {}", state_1f.display());
|
|
std::fs::read(&state_1f).expect("the cached 1F state should be readable")
|
|
} else {
|
|
let descent = std::time::Instant::now();
|
|
let mut found = None;
|
|
for seed in SEEDS {
|
|
let run = walk(
|
|
&rom,
|
|
&bedroom,
|
|
REDS_HOUSE_2F,
|
|
seed,
|
|
tuned(800.0, 800.0, 0.04, 0.0, 1.15),
|
|
minutes,
|
|
Some(Capture { map: REDS_HOUSE_1F, after_ms: capture_delay }),
|
|
true,
|
|
);
|
|
if let Some(state) = run.captured {
|
|
println!(
|
|
"house 1F: walked down the stairs on seed {seed} in {:.1} s wall \
|
|
(left the bedroom at {:.1} s of brain time)",
|
|
descent.elapsed().as_secs_f64(),
|
|
run.left_ms.unwrap_or(f64::NAN) / 1000.0
|
|
);
|
|
found = Some(state);
|
|
break;
|
|
}
|
|
}
|
|
let state = found.expect("no seed reached the ground floor; raise FLY_ESCAPE_MINUTES");
|
|
std::fs::write(&state_1f, &state).expect("writing the 1F state");
|
|
println!("house 1F: saved to {}", state_1f.display());
|
|
state
|
|
};
|
|
|
|
let mut states: BTreeMap<&'static str, Vec<u8>> = BTreeMap::new();
|
|
states.insert("bedroom", bedroom);
|
|
states.insert("house 1F", ground);
|
|
|
|
if !skip_sweep {
|
|
let mut jobs = Vec::new();
|
|
for room in ["bedroom", "house 1F"] {
|
|
let start_map = if room == "bedroom" { REDS_HOUSE_2F } else { REDS_HOUSE_1F };
|
|
for hold in &holds {
|
|
for fatigue in &fatigues {
|
|
for seed in SEEDS.iter().take(seeds) {
|
|
for hysteresis in &hystereses {
|
|
jobs.push(Job {
|
|
room,
|
|
start_map,
|
|
hold: *hold,
|
|
fatigue: *fatigue,
|
|
hysteresis: *hysteresis,
|
|
seed: *seed,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
println!("\nsweeping {} runs...", jobs.len());
|
|
let sweeping = std::time::Instant::now();
|
|
let cells = sweep(&rom, &states, &jobs, minutes, threads);
|
|
println!("swept {} runs in {:.1} s wall", jobs.len(), sweeping.elapsed().as_secs_f64());
|
|
|
|
print_table("From Red's bedroom (map 38)", &cells, "bedroom");
|
|
print_table("From the ground floor (map 37)", &cells, "house 1F");
|
|
println!(
|
|
"\nThis measures the readout, not the fly. A random walker is not a brain and a run \
|
|
that leaves a room faster is not learning."
|
|
);
|
|
}
|
|
|
|
// The confirmation run, opt-in because it needs the dataset and a quarter of an hour.
|
|
let Some(dataset) = std::env::var_os("FLY_ESCAPE_BRAIN") else {
|
|
println!(
|
|
"\nFLY_ESCAPE_BRAIN is not set, so the chosen value was not confirmed against the \
|
|
real brain.\n FLY_ESCAPE_BRAIN=data/fafb-v783 FLY_ESCAPE_CANDIDATES=v0.1.0 \
|
|
... --example room_escape"
|
|
);
|
|
return;
|
|
};
|
|
let dataset = PathBuf::from(dataset);
|
|
let runs = env_usize("FLY_ESCAPE_BRAIN_RUNS", 5);
|
|
let brain_threads = env_usize("FLY_ESCAPE_BRAIN_THREADS", 4);
|
|
// `FLY_ESCAPE_BRAIN_ROOM` is `bedroom` (the default, and v0.1.0's confirmation room) or `1F`.
|
|
let room = std::env::var("FLY_ESCAPE_BRAIN_ROOM").unwrap_or_else(|_| "bedroom".to_string());
|
|
let bedroom = room == "bedroom";
|
|
let start_map = if bedroom { REDS_HOUSE_2F } else { REDS_HOUSE_1F };
|
|
let state = &states[if bedroom { "bedroom" } else { "house 1F" }];
|
|
let (reachable, exits) = survey(&rom, state);
|
|
let house = [REDS_HOUSE_1F, REDS_HOUSE_2F];
|
|
let data = Arc::new(
|
|
load_brain_dataset_from_dir(&dataset).expect("FLY_ESCAPE_BRAIN should be a brain dataset"),
|
|
);
|
|
println!(
|
|
"\n## Confirmation: the real brain from a fresh boot in the {room} ({} reachable tiles, \
|
|
{} of them one press from leaving)\n",
|
|
reachable.len(),
|
|
exits.len()
|
|
);
|
|
for candidate in candidates_from_env(&CANDIDATES[..1]) {
|
|
let config = candidate.config();
|
|
let mut traces = Vec::new();
|
|
for index in 0..runs {
|
|
let brain = std::time::Instant::now();
|
|
// 2,500 ms is `DEFAULT_WARMUP_MS`; the step is deliberately not a round number so the
|
|
// runs do not land on a harmonic of anything in the kernel.
|
|
let warmup_ms = 2_500 + index as u64 * 313;
|
|
traces.push(brain_trace(
|
|
&rom,
|
|
&data,
|
|
Start::Fresh { state, map: start_map, warmup_ms },
|
|
config.clone(),
|
|
minutes,
|
|
brain_threads,
|
|
&house,
|
|
&exits,
|
|
std::env::var_os("FLY_ESCAPE_RUN_ON").is_none(),
|
|
));
|
|
eprintln!(
|
|
"{} fresh run {} took {:.1} s wall",
|
|
candidate.name,
|
|
index + 1,
|
|
brain.elapsed().as_secs_f64()
|
|
);
|
|
}
|
|
print_trace_table(candidate.name, &traces, minutes, target_ms);
|
|
}
|
|
println!(
|
|
"\nThe brain is a handful of runs, on one cartridge, from one room. It confirms the \
|
|
readout is not broken; it is not a benchmark."
|
|
);
|
|
}
|