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.
265 lines
11 KiB
Rust
265 lines
11 KiB
Rust
//! The checkpoint compatibility string.
|
|
//!
|
|
//! A port of the prototype's `src/runtime/compatibility.ts`, which produced
|
|
//!
|
|
//! ```text
|
|
//! {kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}
|
|
//! ```
|
|
//!
|
|
//! with one segment appended. All of these are build constants; nothing here
|
|
//! performs a runtime lookup. ROM identity is checked separately, against
|
|
//! [`crate::pokemon_red::SUPPORTED_ROM`].
|
|
//!
|
|
//! The kernel and plasticity version strings belong to the neural library, so
|
|
//! they are parameters: this crate must not depend on `flybrain-core`. The
|
|
//! prototype's values were `lif-1ms-f64-v2` and `fly-kc-mbon-rstdp-v2`.
|
|
|
|
use crate::emulator::Emulator;
|
|
|
|
/// binjgb revision vendored under `services/flysim/vendor/binjgb`.
|
|
pub const BINJGB_REVISION: &str = "c60e138da5a795ebb55e56b11b7e90024e41112c";
|
|
|
|
/// The prototype's neural kernel version, for reference and for the tests that
|
|
/// compare a Rust string against a TypeScript checkpoint.
|
|
pub const PROTOTYPE_NEURAL_KERNEL_VERSION: &str = "lif-1ms-f64-v2";
|
|
/// The prototype's plasticity version, same purpose.
|
|
pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
|
|
|
|
/// Everything that must match exactly before a checkpoint may be restored.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct Compatibility<'a> {
|
|
/// `kernelVersion(config)` from the neural library.
|
|
pub neural_kernel_version: &'a str,
|
|
/// The adapter's version string, e.g. `pokered-unique8-v6`.
|
|
pub adapter: &'a str,
|
|
/// The dataset's seven SHA-256 digests joined with `:`.
|
|
pub dataset_fingerprint: &'a str,
|
|
/// `plasticityVersion(config)` from the neural library.
|
|
pub plasticity_version: &'a str,
|
|
/// The game's symbol provenance, e.g. the pokered commit.
|
|
pub pokered_commit: &'a str,
|
|
}
|
|
|
|
impl Compatibility<'_> {
|
|
/// The prototype's string, byte for byte. Useful for reading a checkpoint
|
|
/// the TypeScript build wrote.
|
|
pub fn prototype_string(&self) -> String {
|
|
format!(
|
|
"{}/{}/{}/{}/binjgb:{}/pokered:{}",
|
|
self.neural_kernel_version,
|
|
self.adapter,
|
|
self.dataset_fingerprint,
|
|
self.plasticity_version,
|
|
BINJGB_REVISION,
|
|
self.pokered_commit,
|
|
)
|
|
}
|
|
|
|
/// The prototype's string plus a `statefmt:` segment.
|
|
///
|
|
/// `emulator_write_state` is a `memcpy` of binjgb's `EmulatorState`, so its
|
|
/// bytes depend on the compiler's layout for that struct: the WASM build's
|
|
/// state is a different size from a native x86-64 build's. The extra
|
|
/// segment makes that a compatibility mismatch instead of a silent
|
|
/// misparse. See the crate README, "State format".
|
|
pub fn string(&self) -> String {
|
|
format!("{}/statefmt:{}", self.prototype_string(), state_format_id())
|
|
}
|
|
}
|
|
|
|
/// Position of the adapter's version string in [`Compatibility::string`].
|
|
///
|
|
/// `{kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id}`,
|
|
/// so the adapter is segment one. Nothing else in the string may move for a migration to be
|
|
/// considered: a different kernel, dataset, plasticity, emulator revision, symbol provenance or
|
|
/// state format is a different *fly*, not a different reward rule.
|
|
const ADAPTER_SEGMENT: usize = 1;
|
|
|
|
/// The environment variable that opts a deploy into the adapter migration.
|
|
///
|
|
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
|
|
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`.
|
|
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
|
|
|
|
/// What a build may do with a checkpoint whose compatibility string is not its own.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum RestoreDecision {
|
|
/// Byte-identical. Restore it, as every build always has.
|
|
Exact,
|
|
/// Every segment but the adapter's is identical, this build's adapter says it can migrate
|
|
/// from that one, and the operator named it in [`ACCEPT_ADAPTERS_ENV`]. Restore it.
|
|
MigrateAdapter { from: String },
|
|
/// Refuse, and say which of the three conditions failed.
|
|
Refuse(&'static str),
|
|
}
|
|
|
|
/// Decide whether `checkpoint`'s compatibility string may be restored under `current`.
|
|
///
|
|
/// Three conditions, all required, in the order they are cheapest to explain:
|
|
///
|
|
/// 1. the two strings differ in the adapter segment and **nowhere else**;
|
|
/// 2. `migrates_from` -- the running adapter's own list -- contains the checkpoint's adapter, so
|
|
/// the code that will read that state says out loud that it can;
|
|
/// 3. `accepted` -- [`ACCEPT_ADAPTERS_ENV`] as the operator set it for this deploy -- contains it
|
|
/// too, so no build ever migrates a run by itself.
|
|
///
|
|
/// Condition 2 without condition 3 would make the migration silent; condition 3 without condition
|
|
/// 2 would let an operator wave through a pair nobody wrote a migration for. Neither alone is
|
|
/// enough, which is why both are here.
|
|
pub fn decide(
|
|
checkpoint: &str,
|
|
current: &str,
|
|
migrates_from: &[&str],
|
|
accepted: &[String],
|
|
) -> RestoreDecision {
|
|
if checkpoint == current {
|
|
return RestoreDecision::Exact;
|
|
}
|
|
let old: Vec<&str> = checkpoint.split('/').collect();
|
|
let new: Vec<&str> = current.split('/').collect();
|
|
if old.len() != new.len() {
|
|
return RestoreDecision::Refuse("the two compatibility strings do not have the same shape");
|
|
}
|
|
let differing: Vec<usize> = (0..old.len()).filter(|&index| old[index] != new[index]).collect();
|
|
if differing != [ADAPTER_SEGMENT] {
|
|
return RestoreDecision::Refuse(
|
|
"more than the adapter version differs; nothing but a reward-rule change can migrate",
|
|
);
|
|
}
|
|
let from = old[ADAPTER_SEGMENT];
|
|
if !migrates_from.contains(&from) {
|
|
return RestoreDecision::Refuse("this build's adapter has no migration from that adapter");
|
|
}
|
|
if !accepted.iter().any(|name| name == from) {
|
|
return RestoreDecision::Refuse(
|
|
"the checkpoint's adapter is not in FLY_ACCEPT_ADAPTERS, so the migration was not \
|
|
asked for",
|
|
);
|
|
}
|
|
RestoreDecision::MigrateAdapter { from: from.to_string() }
|
|
}
|
|
|
|
/// Parse [`ACCEPT_ADAPTERS_ENV`]: comma- or whitespace-separated, empty entries dropped.
|
|
///
|
|
/// An unset variable and an empty one are the same thing -- no migration -- so that clearing the
|
|
/// opt-in is one edit rather than two.
|
|
pub fn accepted_adapters(value: Option<&str>) -> Vec<String> {
|
|
value
|
|
.unwrap_or_default()
|
|
.split([',', ' ', '\t', '\n'])
|
|
.map(str::trim)
|
|
.filter(|entry| !entry.is_empty())
|
|
.map(str::to_string)
|
|
.collect()
|
|
}
|
|
|
|
/// `<state size>-<target triple>`: the two things that decide whether a
|
|
/// binjgb save state written elsewhere can be memcpy'd back in here.
|
|
pub fn state_format_id() -> String {
|
|
format!("{}-{}", Emulator::state_size(), env!("FLY_GB_TARGET"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const FINGERPRINT: &str = "aa:bb:cc:dd:ee:ff:00";
|
|
|
|
fn fixture() -> Compatibility<'static> {
|
|
Compatibility {
|
|
neural_kernel_version: PROTOTYPE_NEURAL_KERNEL_VERSION,
|
|
adapter: crate::pokemon_red::REWARD_ADAPTER,
|
|
dataset_fingerprint: FINGERPRINT,
|
|
plasticity_version: PROTOTYPE_PLASTICITY_VERSION,
|
|
pokered_commit: crate::pokemon_red::symbols::POKERED_COMMIT,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_prototype_segment_order_is_preserved() {
|
|
assert_eq!(
|
|
fixture().prototype_string(),
|
|
concat!(
|
|
"lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/",
|
|
"fly-kc-mbon-rstdp-v2/",
|
|
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
|
|
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
)
|
|
);
|
|
}
|
|
|
|
fn with_adapter(adapter: &'static str) -> String {
|
|
Compatibility { adapter, ..fixture() }.string()
|
|
}
|
|
|
|
#[test]
|
|
fn an_identical_string_restores_without_any_opt_in() {
|
|
let current = with_adapter("pokered-unique8-v6");
|
|
assert_eq!(decide(¤t, ¤t, &[], &[]), RestoreDecision::Exact);
|
|
}
|
|
|
|
#[test]
|
|
fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() {
|
|
let old = with_adapter("pokered-unique8-v5");
|
|
let new = with_adapter("pokered-unique8-v6");
|
|
let migrates = ["pokered-unique8-v5"];
|
|
|
|
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
|
|
assert_eq!(
|
|
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))),
|
|
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() }
|
|
);
|
|
// And only for a pair the running adapter says it can migrate.
|
|
assert!(matches!(
|
|
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))),
|
|
RestoreDecision::Refuse(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_but_the_adapter_segment_may_move() {
|
|
let migrates = ["pokered-unique8-v5"];
|
|
let accepted = accepted_adapters(Some("pokered-unique8-v5"));
|
|
let new = with_adapter("pokered-unique8-v6");
|
|
|
|
// A different dataset, with the same adapter bump, is not a migration.
|
|
let other_dataset = Compatibility {
|
|
adapter: "pokered-unique8-v5",
|
|
dataset_fingerprint: "00:11:22:33:44:55:66",
|
|
..fixture()
|
|
}
|
|
.string();
|
|
assert!(matches!(
|
|
decide(&other_dataset, &new, &migrates, &accepted),
|
|
RestoreDecision::Refuse(_)
|
|
));
|
|
|
|
// Neither is a different kernel, and neither is a string of another shape.
|
|
let other_kernel =
|
|
Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
|
|
.string();
|
|
assert!(matches!(
|
|
decide(&other_kernel, &new, &migrates, &accepted),
|
|
RestoreDecision::Refuse(_)
|
|
));
|
|
assert!(matches!(decide("a/b", &new, &migrates, &accepted), RestoreDecision::Refuse(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn the_opt_in_list_is_separated_by_commas_or_spaces() {
|
|
assert!(accepted_adapters(None).is_empty());
|
|
assert!(accepted_adapters(Some(" ")).is_empty());
|
|
assert_eq!(
|
|
accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")),
|
|
vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_state_format_segment_is_appended_not_interleaved() {
|
|
let full = fixture().string();
|
|
assert!(full.starts_with(&fixture().prototype_string()));
|
|
assert!(full.ends_with(&format!("/statefmt:{}", state_format_id())));
|
|
assert!(state_format_id().contains(&Emulator::state_size().to_string()));
|
|
}
|
|
}
|