flybrain/services/flysim/crates/flybrain-gb/src/compatibility.rs
acamilo 2e7eed0a78 rewards: talk and item, boundary indoors pays nothing, adapter v7 with a v6 migration
The operator's decision of 2026-09-23: pay the fly for engaging with what is
inside a building, and stop paying it for walking back out of one. Chosen over
a pad rule and over weighting the choice, and like v0.5.0's catch reward it is
a catalog change, not a loop-review fix.

Indoors. engage::indoor is two of the cartridge's own tables: not outside by
CheckIfInOutsideMap (tileset OVERWORLD or PLATEAU; WarpFound2 labels the other
branch .indoorMaps) and not a place BikeRidingTilesets lets the bike be ridden
(OVERWORLD, FOREST, UNDERGROUND, SHIP_PORT, CAVERN). That is every house, mart,
center, gym, gate, lab, museum, ship, tower, mansion and hideout, and not
Viridian Forest, a cave, the Underground Path or the dock.

`talk`, +0.10, 100 ms, the catalog's tenth kind. Paid when the text box closes
on a conversation that (1) opened on the sample after one where the fly had the
joypad (state::controllable) and was standing still (wWalkCounter zero, the only
state the overworld reads A in) on the same tile; (2) is with the thing in
front: DisplayTextID copies its argument into wSpriteIndex, a sprite slot up to
wNumSprites whose sprite stands on the tile the player faces (or one further
across a counter, on a tileset with counter tiles), or a text id matching the
sign on that tile; an item ball is not a person; (3) opened indoors; (4) closed
on the same map. Keyed talk:<map>:sprite:<slot> / talk:<map>:sign:<id> in the
lifetime `seen` ledger, which is checkpointed and survives a rollback -- not the
macros' session `talked` ledger.

`item`, +0.15, 120 ms, the eleventh. An item ball is a toggleable sprite of this
map (wToggleableObjectList) whose wMapSpriteExtraData is (item id, 0), the shape
LoadMapHeader writes for an ITEM object_event and nothing else; PickUpItem sets
its global bit in wToggleableObjectFlags after GiveItem succeeds. A hidden item
is a bit of wObtainedHiddenItemsFlags, set by FoundHiddenItemText after GiveItem
and by nothing else. Either pays when its bit rises between two playable
samples, once per item (item:<global> / hidden:<index>) for the ledger's life.

`boundary` still writes every key indoors, so exit_visited answers what it did
and the macros see no change, but emits nothing on an indoor map. Outdoor,
forest and cave exits pay as before.

v6 -> v7. STATE_VERSION stays 4 and no field is added: the new ledgers are keys
in `seen`. A v6 state restores with no talk: keys, and the first playable sample
that finds `items:seeded` absent writes one key per item the game already shows
as taken, pays for none, and marks the seed -- so a rollback that un-takes a v6
pickup cannot pay for it. The two item balls a script reveals (the Rocket
Hideout's Silph Scope and Lift Key, toggles $87 and $88, the only ITEM entries
toggleable_objects.asm starts OFF) are left out of the seed. MIGRATES_FROM is
["pokered-unique8-v6"]; v5 is no longer migrated.

Feed kinds: both publish on `explore`, the family of new ground and a door found,
at the same quiet scale; not `area` (maps, notable), `story` or `wildwin`. No
feed-protocol change.

The compatibility string differs from main's in exactly one segment:
pokered-unique8-v6 -> pokered-unique8-v7.

Tests: catalog values and order; indoor over all 24 tilesets; a talk pays once
per (map, object), not while the box is open, not re-talked, not outdoors/in the
forest/in a cave, not for text opened with the joypad taken, simulated, scripted,
mid-step, about someone not in front, or the start menu; not across a warp or a
rollback; counter reach only with counter tiles; item balls, people, trainers,
hidden items, the seed and the script-shown balls; boundary indoors records and
pays nothing; a v6 state and a v6 FLYSIM01 envelope migrate with the new ledgers
empty and the items seeded. rom.rs's bedroom walk now proves the stairs are
recorded and unpaid on the cartridge.
2026-09-23 14:34:10 +00:00

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-v7`.
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-v6`.
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-v7/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-v7");
assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact);
}
#[test]
fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() {
let old = with_adapter("pokered-unique8-v6");
let new = with_adapter("pokered-unique8-v7");
let migrates = ["pokered-unique8-v6"];
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
assert_eq!(
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
);
// And only for a pair the running adapter says it can migrate.
assert!(matches!(
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::Refuse(_)
));
}
#[test]
fn nothing_but_the_adapter_segment_may_move() {
let migrates = ["pokered-unique8-v6"];
let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let new = with_adapter("pokered-unique8-v7");
// A different dataset, with the same adapter bump, is not a migration.
let other_dataset = Compatibility {
adapter: "pokered-unique8-v6",
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-v6", 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-v6, pokered-unique8-v5")),
vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".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()));
}
}