flybrain/services/flysim/crates/flysim/tests/compat_migration.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

278 lines
11 KiB
Rust

//! A `v6` checkpoint restored under `v7`: accepted with the opt-in, refused without it.
//!
//! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state
//! migration separately. This is the two of them against one artefact: a real `FLYSIM01`
//! envelope carrying a `pokered-unique8-v6` compatibility string and a `v6` reward ledger —
//! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a
//! candidate through, and then one sample of a game in which items were already taken.
//!
//! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of
//! the question: what decides a restore is the compatibility string and `import_state`, and what
//! decides the seed is the first sample's read of the cartridge's own item bits.
//!
//! (`v5` -> `v6`, the catch rule's migration, was this same file; `v6` no longer migrates from
//! anything, so that pair is refused now like any other.)
use flybrain_gb::GameAdapter;
use flybrain_gb::MemoryReader;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide};
use flybrain_gb::pokemon_red::PokemonRedReward;
use flysim::store::{self, RuntimeState};
/// The live string's shape, with the adapter left open. The dataset fingerprint is shortened —
/// nothing here parses it, and a seven-digest one would be 455 characters of noise.
fn compatibility(adapter: &str) -> String {
format!(
"lif-1ms-f64-v2/{adapter}/aabbccddeeff00/fly-kc-mbon-rstdp-v2/\
binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/\
pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
)
}
/// A `v6` reward ledger: `STATE_VERSION` 4, every field `v6` wrote, and **no** `talk:`,
/// `item:`, `hidden:` or `items:seeded` key in `seen`.
///
/// Written out by hand rather than exported from an adapter, because an exported one would be a
/// `v7` state with keys deleted — this is the shape the release box's checkpoints really carry,
/// field for field, including a `boundary:` key earned indoors (Red's staircase, map 38) that
/// `v7` would not have paid for and keeps anyway.
fn v6_reward() -> serde_json::Value {
serde_json::json!({
"version": 4,
"seen": [
"adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near",
"boundary:38:1:7:on"
],
"tiles": ["0:5:6", "0:5:7"],
"tileCounts": { "0": 2 },
"wildWins": { "0:112:4": 2 },
"catchCounts": { "176": 1 },
"replayBlocked": [],
"counts": {
"milestone": 2, "exploration": 0, "map": 1, "species": 1,
"trainer": 0, "battle": 2, "badge": 0, "boundary": 2, "catch": 1
},
"total": 2.45,
"recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }],
"last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } },
"initialized": true,
"sawBoot": true,
"location": "0:5:7",
"stable": 9,
"progress": 3,
"badges": 0,
"battle": null,
"mode": "OVERWORLD"
})
}
fn v6_checkpoint() -> Vec<u8> {
use flybrain_core::decoder::DecoderState;
use flybrain_core::lif::LifState;
use flybrain_core::ordered::NumberMap;
use flybrain_core::plasticity::PlasticityState;
let agent = flybrain_core::agent::AgentState {
version: 1,
remainder: 0.25,
warmed_up: true,
network: LifState {
membrane: vec![0.1, -0.2],
refractory: vec![0, 1],
last_spike_ms: vec![-1_000_000.0, 5.0],
visual_drive: vec![0.3],
rng: 42,
reward_remaining: 0.0,
ms: 1_234.5,
population_rate: 1.0,
rates: NumberMap::from_pairs([("forward", 1.0)]),
plasticity: PlasticityState {
version: "fly-kc-mbon-rstdp-v2".to_string(),
topology: 7,
enabled: true,
updates: 1.0,
signal: 0.0,
gains: vec![1.0],
traces: vec![0.0],
touched: vec![0.0],
},
},
decoder: DecoderState {
version: 4,
calibrated: true,
baseline: NumberMap::from_pairs([("forward", 1.0)]),
held_until: NumberMap::new(),
next_allowed: NumberMap::new(),
next_decision: 0.0,
current: None,
fatigue: NumberMap::new(),
macro_next_decision: 0.0,
macro_current: None,
macro_fatigue: NumberMap::new(),
},
};
let runtime = RuntimeState {
generation: 41,
wall_ms: 1_790_000_000_000,
rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(),
emulator_frame: 1_000_000,
compatibility: compatibility("pokered-unique8-v6"),
speed: 1.0,
buttons: 0,
rank_since_ms: 1_000.0,
last_event_id: 4_242,
reward: v6_reward(),
ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() },
emulator: vec![3; 64],
framebuffer: vec![0; 32],
ratchet_game: vec![1],
ratchet_frame: vec![2],
};
store::encode(&agent, &runtime).expect("the fixture encodes")
}
#[test]
fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() {
let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
let adapter = PokemonRedReward::new();
let current = compatibility(adapter.id());
assert_ne!(checkpoint.runtime.compatibility, current, "v7 is not v6");
for opt_in in [None, Some(""), Some("pokered-unique8-v5"), Some("some-other-adapter")] {
assert!(
matches!(
decide(
&checkpoint.runtime.compatibility,
&current,
adapter.migrates_from(),
&accepted_adapters(opt_in),
),
RestoreDecision::Refuse(_)
),
"FLY_ACCEPT_ADAPTERS={opt_in:?} must not migrate anything"
);
}
}
/// A flat 64 KiB address space: the one thing a sample reads.
struct Wram(Vec<u8>);
impl MemoryReader for Wram {
fn read8(&mut self, address: u16) -> u8 {
self.0[address as usize]
}
}
#[test]
fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_seeded() {
let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
let mut adapter = PokemonRedReward::new();
let current = compatibility(adapter.id());
assert_eq!(
decide(
&checkpoint.runtime.compatibility,
&current,
adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v6")),
),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
);
// The migration itself: `import_state`, exactly as `Sim::try_restore` calls it.
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a valid v7 ledger");
let after = adapter.export_state();
assert_eq!(after["counts"]["talk"], serde_json::json!(0), "no conversation was ever paid");
assert_eq!(after["counts"]["item"], serde_json::json!(0), "nor any item");
// And nothing else moved: every field the v6 state carried round-trips to the same value,
// and v7 adds no field at all -- its ledgers are keys in `seen`.
//
// `counts` is the one field that is not byte-identical, and it is not a change of meaning:
// it serializes every kind in the catalog, so a v7 state lists `talk` and `item` where a v6
// state had nothing to list. Every kind the v6 state did carry keeps its number.
let before = v6_reward();
for (key, value) in before.as_object().unwrap() {
if key == "counts" {
for (kind, count) in value.as_object().unwrap() {
assert_eq!(&after["counts"][kind], count, "counts.{kind}");
}
let added: Vec<&String> = after["counts"]
.as_object()
.unwrap()
.keys()
.filter(|kind| !value.as_object().unwrap().contains_key(*kind))
.collect();
assert_eq!(added, vec!["talk", "item"], "v7 counts two more kinds and no others");
continue;
}
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
}
let added: Vec<&String> = after
.as_object()
.unwrap()
.keys()
.filter(|key| !before.as_object().unwrap().contains_key(*key))
.collect();
assert!(added.is_empty(), "v7 adds no field: {added:?}");
// The first sample of the restored game. Under v6 the fly took an item ball (global
// toggleable index 0x2a) and a hidden item (index 9); v6 paid for neither. The sample seeds
// both into the ledger and pays nothing, which is the "no retroactive payout" half.
let mut wram = Wram(vec![0; 0x10000]);
wram.0[ram::wStatusFlags6 as usize] = 1;
wram.0[ram::wPartyCount as usize] = 1;
wram.0[ram::wCurMapWidth as usize] = 10;
wram.0[ram::wCurMapHeight as usize] = 9;
wram.0[ram::wXCoord as usize] = 5;
wram.0[ram::wYCoord as usize] = 7;
wram.0[(ram::wToggleableObjectFlags + 0x2a / 8) as usize] |= 1 << (0x2a % 8);
wram.0[(ram::wObtainedHiddenItemsFlags + 1) as usize] |= 1 << 1;
assert!(adapter.sample(&mut wram, 2_000.0).is_empty(), "the seed pays nothing");
let seen = adapter.export_state()["seen"].clone();
for key in ["item:42", "hidden:9", "items:seeded"] {
assert!(seen.as_array().unwrap().contains(&serde_json::json!(key)), "{key} seeded");
}
assert!(
!seen.as_array().unwrap().iter().any(|key| key.as_str().unwrap().starts_with("talk:")),
"the talk ledger starts empty"
);
// The rest of what a restore reads is untouched by the migration.
assert_eq!(adapter.progress().rank, 3);
assert_eq!(checkpoint.runtime.last_event_id, 4_242);
assert_eq!(checkpoint.runtime.ratchet.best, 3);
}
#[test]
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
let adapter = PokemonRedReward::new();
let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let current = compatibility(adapter.id());
// A v6 string whose state format also moved: a different build, not a rule change.
let other_abi = compatibility("pokered-unique8-v6").replace("199616", "199617");
assert!(matches!(
decide(&other_abi, &current, adapter.migrates_from(), &accepted),
RestoreDecision::Refuse(_)
));
// And an identical string needs no opt-in at all.
assert_eq!(
decide(&current, &current, adapter.migrates_from(), &[]),
RestoreDecision::Exact
);
// And v5 -> v7 is not a migration this adapter wrote, whatever the operator names.
assert!(matches!(
decide(
&compatibility("pokered-unique8-v5"),
&current,
adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")),
),
RestoreDecision::Refuse(_)
));
}