flybrain/services/flysim/crates/flysim/tests/rom_catch.rs
alex 23a4d7379b rewards: a catch reward, adapter v6 with a v5 migration, and a rung restart
The operator's decision of 2026-09-22: pay the fly for keeping a wild Pokemon,
bump the adapter properly, and restart the live run from an early checkpoint
rather than from scratch.

The rule. `catch` is the catalog's ninth kind, appended so the key order
`counts` serializes in does not move. 0.30 for a species this run had never
owned, 0.10 for a repeat, three payouts per species for the lifetime of the
ledger; the `species` rule is untouched, so a first catch of a new species pays
0.80 across two kinds. The catch is read from `wCapturedMonSpecies` ($d11c),
whose comment in ram/wram.asm is "0 if no mon was captured": ItemUseBall zeroes
it before every throw and writes wEnemyMonSpecies into it only on the branch
that keeps the Pokemon, and UseBagItem's `.returnAfterCapturingMon` zeroes it
again and sets wBattleResult to 2 -- a value written on exactly two paths in
the game, that one and a link battle whose opponent ran. Both are required, so
a byte read out of a half-initialised battle cannot pay. Not wPartyCount: a
catch with a full party raises wBoxCount instead, and wPartyCount also rises
for a gift, a trade and a PC withdrawal.

"Never owned this run" is the `species` payout inside the same battle, because
nothing else can set a Pokedex bit during one. It is not read off the captured
species byte: that is the cartridge's internal index while the owned bitset is
by Pokedex number, and nothing in WRAM converts between them.

The address was resolved by tools/resolve_wram.py, not written by hand. The
tool needed NUM_TMS and NUM_HMS, which the decomp defines through its `const`
enumeration, so it now counts them from the file's own add_tm/add_hm
definitions and cross-checks NUM_TMS against the literal the same file
declares.

The feed's kinds are closed, so `catch` publishes on `wildwin` and nothing in
packages/feed or apps/stage changed. Deliberately not `pokedex`: the `species`
rule already pays for the bit the same catch sets. The stage's ticker copy is
keyed on the feed kind, so a catch row reads "wild win" -- stated in
docs/rewards-learning.md rather than left to be discovered.

v5 -> v6. STATE_VERSION stays 4: the rule adds one counter, `catchCounts`, and
changes nothing else, so a v5 state restores with it empty. That migration is
opt-in and needs all three of: the adapter segment being the only difference
between the two compatibility strings, the running adapter listing the
checkpoint's adapter in `migrates_from()`, and the deploy naming it in
FLY_ACCEPT_ADAPTERS. flysim applies the rule at restore and 05-deploy's gate
applies the same rule before it flips the symlink, writing the variable into
fly.env so the two cannot disagree.

The restart. infra/bin/fly-reset-to-milestone <N> archives both stores to a
dated directory, rewrites milestone-<N>.checkpoint with the ratchet's attempts
and recoveries at zero, installs it as the newest generation of both stores,
clears the milestone archives above N and the event log, and prints what it
did. It refuses while flysim is running and refuses a rung the run never
reached. The envelope work is in flysim::reset (`flysim
--reset-to-milestone N`); the shell script is the operator's wrapper.

Tests: catalog values and order; a synthetic WRAM trace of a catch (new,
repeat, cap, already-owned species, trainer/Safari/old-man/missed-ball
negatives, rollback replay); a v5 state restoring with the counter at zero; a
v5 checkpoint fixture accepted with the opt-in and refused without it; the
reset tool against copies of a state dir in temp directories; and a ROM-gated
catch from a rung-9 forest checkpoint, driven by the shipping THROW BALL macro.

The compatibility string differs from main's in exactly one segment, checked by
splitting both on `/`: pokered-unique8-v5 -> pokered-unique8-v6.
2026-09-22 17:30:00 +00:00

257 lines
10 KiB
Rust

//! The catch reward against the real cartridge.
//!
//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and
//! skips cleanly without either — the cartridge never enters this repository and a checkpoint is
//! not a fixture, it is the state the release box was really in:
//!
//! ```sh
//! FLY_ROM="$HOME/roms/pokemon-red.gb" \
//! FLY_CATCH_CHECKPOINT=.local/checkpoints/<a rung-9 forest checkpoint> \
//! cargo test --release -p flysim --test rom_catch -- --nocapture
//! ```
//!
//! ## What only the cartridge can answer
//!
//! The synthetic trace in `pokemon_red/tests.rs` writes `wCapturedMonSpecies`, `wBattleResult`
//! and the Pokédex bit itself, from the disassembly. It cannot say that those are the bytes
//! *this* cartridge writes when a ball keeps a Pokémon, in that order, on frames an adapter
//! sampling once a frame actually sees. That is this test, and it is the "survey" half of
//! `docs/design/macros-wram.md`'s evidence for the row: a real battle, real button presses, and
//! the byte read out of the running game rather than written into a fake one.
//!
//! ## How the catch is produced
//!
//! No steering and no scripted button sequence: the shipping macro palette, the shipping macro
//! layer and the shipping decoder, with a stub readout that leans on one macro population at a
//! time — the same driver `tests/rom_macros_mode.rs` uses and for the same reason. The one thing
//! this harness does that the rotation does not is lean on `THROW BALL`'s channel while a wild
//! battle is up, because the question here is what the adapter reads from a catch, not whether a
//! game-blind readout finds its way to one.
//!
//! The checkpoint must hold at least one ball in the bag. The macro palette can buy one
//! (`BUY BALL`, `MB·PBALL`, inside a mart), but that is a walk across a city and back and it is a
//! different test's question; this one says out loud that it skipped.
use flybrain_core::decoder::PopulationDecoder;
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::ordered::NumberMap;
use flybrain_gb::adapter::RewardEvent;
use flybrain_gb::pokemon_red::state;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, catalog};
use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter,
};
use flysim::config::Config;
use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode;
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
const SEED: u32 = 20_260_922;
/// The hot population's rate against every other one's, which is also the stub's calibration
/// rate — so a channel that is not the hot one scores exactly 1.0.
const HOT: f64 = 16.0;
const REST: f64 = 10.0;
/// `THROW BALL`'s channel (`pokemon_red::macros::palette`).
const BALL: &str = "MB·BALL";
/// Frames the stub leans on one channel before the rotation moves on, the shape of the real
/// group's hysteresis-then-fatigue rotation.
const BURST_FRAMES: u32 = 24;
fn rates(hot: Option<&str>) -> NumberMap {
let mut rates = NumberMap::new();
for channel in flybrain_gb::macro_channels("pokemon-red") {
rates.set(channel, REST);
}
for bucket in 0..8 {
rates.set(&format!("command_{bucket}"), REST);
}
if let Some(channel) = hot {
rates.set(channel, HOT);
}
rates
}
fn rom() -> Option<Vec<u8>> {
let path = std::env::var_os("FLY_ROM")?;
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
}
}
fn checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_CATCH_CHECKPOINT")?;
Some(
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope"),
)
}
struct Run {
gb: Emulator,
adapter: PokemonRedReward,
layer: MacroLayer,
decoder: PopulationDecoder,
channels: Vec<&'static str>,
ms: f64,
frame: u32,
/// Every payout the adapter has made since the run started.
payouts: Vec<RewardEvent>,
}
impl Run {
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge");
let mut adapter = PokemonRedReward::new();
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
// A checkpoint written by an earlier adapter rebaselines rather than failing, which is
// exactly the `v5` -> `v6` case this rule ships with.
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
let channels = flybrain_gb::macro_channels("pokemon-red");
let preset = gameboy_decoder_config_with_macros(&channels);
let hold_ms = preset.macros.as_ref().expect("the preset has a macro group").hold_ms;
let mut decoder = PopulationDecoder::new(preset).expect("the preset is well formed");
decoder.calibrate(&rates(None));
let mut config = Config::default();
config.loop_.game = "pokemon-red".to_string();
config.macros.mode = MacroMode::Macros;
config.validate().expect("pokemon-red has a palette in macros mode");
let mut layer = macro_layer(&config, hold_ms, SEED).expect("a layer in macros mode");
let _ = layer.observe(&mut gb, &AdapterLedger(&adapter), 0.0);
Self {
gb,
adapter,
layer,
decoder,
channels,
ms: 0.0,
frame: 0,
payouts: Vec::new(),
}
}
fn byte(&mut self, address: u16) -> u8 {
self.gb.read_wram(address)
}
fn in_wild_battle(&mut self) -> bool {
self.byte(ram::wIsInBattle) == 1
}
/// Balls in the bag, of any kind (`constants/item_constants.asm`: MASTER_BALL 1,
/// ULTRA_BALL 2, GREAT_BALL 3, POKE_BALL 4 — the same four `THROW BALL` looks for).
fn balls(&mut self) -> usize {
state::bag(&mut self.gb)
.iter()
.filter(|item| (0x01..=0x04).contains(&item.id) && item.count > 0)
.map(|item| usize::from(item.count))
.sum()
}
fn step(&mut self) {
// Lean on `THROW BALL` while a wild battle is up; otherwise rotate, which is what gets
// the fly into the grass in the first place.
let hot = if self.in_wild_battle() {
Some(BALL)
} else {
let slot = (self.frame / BURST_FRAMES) as usize % self.channels.len();
Some(self.channels[slot])
};
let bound = self.layer.bound_channels();
let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let mask = {
let ledger = AdapterLedger(&self.adapter);
self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger).mask
};
self.gb.set_buttons(mask as u8);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME;
self.frame += 1;
let ms = self.ms;
self.payouts.extend(self.adapter.sample(&mut self.gb, ms));
let ledger = AdapterLedger(&self.adapter);
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
}
fn catches(&self) -> Vec<&RewardEvent> {
self.payouts.iter().filter(|event| event.kind == catalog::kind::CATCH).collect()
}
}
#[test]
fn a_catch_on_the_cartridge_pays_the_catch_rule_once_with_the_species_in_its_label() {
let Some(rom) = rom() else {
eprintln!("skipped: FLY_ROM is not set");
return;
};
let Some(checkpoint) = checkpoint() else {
eprintln!("skipped: no FLY_CATCH_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, &checkpoint);
let balls = run.balls();
if balls == 0 {
eprintln!(
"skipped: the checkpoint's bag holds no ball (map {:#04x}). `BUY BALL` can buy one \
inside a mart; point FLY_CATCH_CHECKPOINT at a state that already has one.",
run.adapter.map_id().unwrap_or(u32::MAX)
);
return;
}
eprintln!("bag holds {balls} balls; map {:#04x}", run.adapter.map_id().unwrap_or(u32::MAX));
// Twenty brain minutes is generous for a forest checkpoint: the live run threw 28 balls in
// its first Viridian Forest session (`pokemon_red::macros::palette`).
let budget = 20 * 60 * 60;
let mut battles = 0u32;
let mut was_in_battle = false;
for _ in 0..budget {
run.step();
let now = run.in_wild_battle();
if now && !was_in_battle {
battles += 1;
}
was_in_battle = now;
if !run.catches().is_empty() {
break;
}
}
let catches = run.catches();
assert!(
!catches.is_empty(),
"no catch in {:.1} brain minutes: {battles} wild battles, {} balls left, map {:#04x}, \
macros {:?}",
run.ms / 60_000.0,
run.balls(),
run.adapter.map_id().unwrap_or(u32::MAX),
run.layer.counts()
);
let caught = catches[0];
eprintln!(
"caught after {:.1} brain minutes and {battles} wild battles: {} for {}",
run.ms / 60_000.0,
caught.label,
caught.value
);
assert!(caught.label.starts_with("CAUGHT #"), "{}", caught.label);
assert!(
(caught.value - 0.30).abs() < 1e-12 || caught.value == catalog::CATCH_REPEAT_VALUE,
"a catch pays one of the rule's two amounts, not {}",
caught.value
);
// The cartridge's own flag is clear again by the time the payout lands, which is what makes
// the payout a battle-exit event rather than a per-frame one.
assert_eq!(run.byte(ram::wCapturedMonSpecies), 0);
assert_eq!(
run.adapter.progress().counts[catalog::kind::CATCH],
1,
"one battle, one payout"
);
// And a species the run is paid for catching is a species the Pokédex knows: the same event
// sets the bit the `species` rule reads, whether or not it was new to this run.
assert!(run.balls() < balls, "a ball was spent");
}