diff --git a/services/flysim/crates/flybrain-gb/README.md b/services/flysim/crates/flybrain-gb/README.md index 91cc6b4..fa9f917 100644 --- a/services/flysim/crates/flybrain-gb/README.md +++ b/services/flysim/crates/flybrain-gb/README.md @@ -16,7 +16,7 @@ ROM bytes -> Emulator::run_frame -> RGBA frame + PCM + WRAM | --- | --- | | `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash | | `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` | -| `pokemon_red` | The `pokered-unique8-v5` reward adapter, its catalog and its generated symbol table | +| `pokemon_red` | The `pokered-unique8-v7` reward adapter, its catalog and its generated symbol table | | `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map | | `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` | | `recovery` | Rolling the game back to the ratchet's best safe snapshot | diff --git a/services/flysim/crates/flybrain-gb/src/adapter.rs b/services/flysim/crates/flybrain-gb/src/adapter.rs index 9e24815..ef83db6 100644 --- a/services/flysim/crates/flybrain-gb/src/adapter.rs +++ b/services/flysim/crates/flybrain-gb/src/adapter.rs @@ -56,10 +56,10 @@ impl MemoryReader for &mut dyn MemoryReader { /// One reward payout in one frame. /// /// `kind` is an adapter-owned interned name (Pokémon: `milestone`, -/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the -/// key the statistics counters and the on-screen ticker group by. Field names -/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint -/// written by either implementation reads in the other. +/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`, +/// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group +/// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a +/// checkpoint written by either implementation reads in the other. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct RewardEvent { pub kind: &'static str, @@ -241,7 +241,7 @@ impl std::error::Error for AdapterError {} /// A game, as the sim loop sees it. pub trait GameAdapter: Send { /// Adapter version string, pinned into the checkpoint compatibility string. - /// Pokémon: `pokered-unique8-v6`. + /// Pokémon: `pokered-unique8-v7`. fn id(&self) -> &'static str; /// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration diff --git a/services/flysim/crates/flybrain-gb/src/compatibility.rs b/services/flysim/crates/flybrain-gb/src/compatibility.rs index b3fc959..3b01985 100644 --- a/services/flysim/crates/flybrain-gb/src/compatibility.rs +++ b/services/flysim/crates/flybrain-gb/src/compatibility.rs @@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2"; 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`. + /// 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, @@ -78,7 +78,7 @@ 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`. +/// 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. @@ -180,7 +180,7 @@ mod tests { assert_eq!( fixture().prototype_string(), concat!( - "lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/", + "lif-1ms-f64-v2/pokered-unique8-v7/aa:bb:cc:dd:ee:ff:00/", "fly-kc-mbon-rstdp-v2/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", @@ -194,37 +194,37 @@ mod tests { #[test] fn an_identical_string_restores_without_any_opt_in() { - let current = with_adapter("pokered-unique8-v6"); + let current = with_adapter("pokered-unique8-v7"); 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"]; + 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-v5"))), - RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } + 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-v5"))), + decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))), 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"); + 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-v5", + adapter: "pokered-unique8-v6", dataset_fingerprint: "00:11:22:33:44:55:66", ..fixture() } @@ -236,7 +236,7 @@ mod tests { // 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() } + Compatibility { adapter: "pokered-unique8-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } .string(); assert!(matches!( decide(&other_kernel, &new, &migrates, &accepted), @@ -250,8 +250,8 @@ mod tests { 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()] + accepted_adapters(Some("pokered-unique8-v6, pokered-unique8-v5")), + vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()] ); } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs index 4e7a0e6..b009c4c 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs @@ -1,4 +1,4 @@ -//! The `pokered-unique8-v6` reward catalog. +//! The `pokered-unique8-v7` reward catalog. //! //! A direct port of the prototype's `src/reward/catalog.ts`, including the //! declaration order, which is the order `counts` and `last` serialize in. @@ -21,6 +21,8 @@ pub mod kind { pub const BADGE: &str = "badge"; pub const BOUNDARY: &str = "boundary"; pub const CATCH: &str = "catch"; + pub const TALK: &str = "talk"; + pub const ITEM: &str = "item"; } /// What a `catch` of a species this run has already caught pays. @@ -44,7 +46,7 @@ pub struct RewardRule { pub stimulation_ms: u32, } -pub const REWARDS: [RewardRule; 9] = [ +pub const REWARDS: [RewardRule; 11] = [ RewardRule { kind: kind::MILESTONE, label: "Story", @@ -125,6 +127,30 @@ pub const REWARDS: [RewardRule; 9] = [ value: 0.30, stimulation_ms: 150, }, + // The operator's decision of 2026-09-23: pay the fly for engaging with what is *inside* a + // building rather than for leaving it (`boundary` pays nothing on an indoor map from v7). + // Both appended, for the reason every rule since `boundary` was: the declaration order is + // the key order `counts` serializes in, and every checkpoint already written carries the + // first nine in this order. + // + // `talk` is one payout per person or sign per map for the lifetime of the ledger, and only + // indoors: the conversation the fly opened by pressing A at it, paid when the box closes. + RewardRule { + kind: kind::TALK, + label: "Talk", + trigger: "Conversation the fly opened indoors; once per map and person or sign", + value: 0.10, + stimulation_ms: 100, + }, + // `item` is one payout per item ball or hidden item for the lifetime of the ledger, on any + // map: the cartridge's own "this one has been taken" bit rising. + RewardRule { + kind: kind::ITEM, + label: "Item", + trigger: "Item ball or hidden item picked up; once per item", + value: 0.15, + stimulation_ms: 120, + }, ]; /// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is @@ -229,12 +255,17 @@ mod tests { assert_eq!(rule(kind::CATCH).unwrap().value, 0.30); assert_eq!(CATCH_REPEAT_VALUE, 0.10); assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150); + // Nor these: the operator's engagement rules, `pokered-unique8-v7`. + assert_eq!(rule(kind::TALK).unwrap().value, 0.10); + assert_eq!(rule(kind::TALK).unwrap().stimulation_ms, 100); + assert_eq!(rule(kind::ITEM).unwrap().value, 0.15); + assert_eq!(rule(kind::ITEM).unwrap().stimulation_ms, 120); assert!(rule("blackout").is_none(), "the catalog has no penalties"); assert!(REWARDS.iter().all(|rule| rule.value > 0.0)); } #[test] - fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() { + fn new_rules_are_appended_so_the_older_key_order_does_not_move() { let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect(); assert_eq!( order, @@ -248,9 +279,11 @@ mod tests { kind::BADGE, kind::BOUNDARY, kind::CATCH, + kind::TALK, + kind::ITEM, ] ); - assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1)); + assert_eq!(index(kind::ITEM), Some(REWARDS.len() - 1)); } #[test] diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs new file mode 100644 index 0000000..734c873 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs @@ -0,0 +1,378 @@ +//! What the engagement rules of `pokered-unique8-v7` read: indoors, a conversation the fly +//! opened, and an item picked up. +//! +//! The operator's decision of 2026-09-23 (`docs/rewards-learning.md`, "Engagement rewards"): +//! pay the fly for engaging with what is inside a building -- `talk` and `item` -- and stop +//! paying `boundary` for walking back out of one. Everything here is a read of game memory +//! after a frame; nothing chooses, biases or presses a button, and nothing is checkpointed. +//! The lifetime ledgers the payouts are keyed into are the adapter's own `seen` set, in +//! [`super::PokemonRedReward`]. + +use crate::adapter::MemoryReader; + +use super::macros::state::{Facing, Player}; +use super::state::{self, poke}; +use super::symbols::ram; + +/// Tileset ids, `constants/tileset_constants.asm` at [`super::symbols::POKERED_COMMIT`]. Only the +/// ones the indoor rule names. +pub mod tileset { + pub const OVERWORLD: u8 = 0; + pub const FOREST: u8 = 3; + pub const UNDERGROUND: u8 = 11; + pub const SHIP_PORT: u8 = 14; + pub const CAVERN: u8 = 17; + pub const PLATEAU: u8 = 23; + /// `DEF NUM_TILESETS EQU const_value`: 24 tilesets, ids 0 to 23. + pub const COUNT: u8 = 24; +} + +/// Whether a map with this tileset is **inside a building**, by the cartridge's own two tables. +/// +/// - `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's own outdoor test: tileset +/// `OVERWORLD` or `PLATEAU` is "a town or route", and `WarpFound2` labels the other branch +/// `.indoorMaps`. On its own that also calls Viridian Forest and every cave indoor. +/// - `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is the game's list of places +/// a bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`, `CAVERN` -- and +/// the bike is the one thing the cartridge refuses *inside a building* by rule. +/// +/// Indoor is neither: not outside, and not somewhere the bike is allowed. That is every house, +/// mart, Pokémon Center, gym, gate, lab, museum, the S.S. Anne, Silph Co., the Pokémon Tower, the +/// Mansion, the Rocket Hideout and the Indigo Plateau's rooms -- and *not* Viridian Forest, a +/// cave, the Underground Path or Vermilion's dock, whose exits are how the fly gets anywhere. +/// A tileset id past the table is not indoor: an unreadable map is never a reason to withhold +/// `boundary`. +pub fn indoor(tileset: u8) -> bool { + use tileset::*; + tileset < COUNT + && !matches!( + tileset, + OVERWORLD | PLATEAU | FOREST | UNDERGROUND | SHIP_PORT | CAVERN + ) +} + +/// What a conversation was with, as `DisplayTextID` names it: a sprite slot, or a sign's text id. +/// +/// The same split the macros' session `talked` ledger uses, but this is not that ledger: the +/// payout is keyed into the adapter's lifetime `seen` set, which is checkpointed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Thing { + Sprite(u8), + Sign(u8), +} + +/// A conversation the fly opened and the cartridge has now closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Conversation { + pub map: u8, + pub thing: Thing, +} + +impl Conversation { + /// The `seen` ledger key: one payout per `(map, object)` for the lifetime of the ledger. + pub fn key(&self) -> String { + match self.thing { + Thing::Sprite(slot) => format!("talk:{}:sprite:{slot}", self.map), + Thing::Sign(id) => format!("talk:{}:sign:{id}", self.map), + } + } + + pub fn label(&self) -> String { + match self.thing { + Thing::Sprite(slot) => format!("TALKED TO #{slot} IN AREA {}", self.map), + Thing::Sign(id) => format!("READ SIGN #{id} IN AREA {}", self.map), + } + } +} + +/// Samples after the box opens in which `wSpriteIndex` may still be read. +/// +/// `DisplayTextIDInit` sets the font bit a few hundred cycles before `DisplayTextID` copies its +/// argument into `wSpriteIndex`, with no frame in between -- but a frame boundary is wherever the +/// CPU happens to be at the vertical blank, so a sample can land between the two. A stale +/// argument names the previous conversation, which is either not in front of the fly (and does +/// not resolve) or the same thing (and is the same key), so a short window costs nothing. +const OPENING_SAMPLES: u8 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Armed { + map: u8, + x: u32, + y: u32, + /// The fly had the joypad and was standing still: [`state::controllable`] and a zero + /// `wWalkCounter`. The overworld only reads an A press in that state. + ready: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Opening { + map: u8, + samples: u8, +} + +/// The `talk` rule's per-frame watch. Transient: a restore or a rollback clears it, so a +/// conversation in flight at a checkpoint pays nothing, which is the conservative answer. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TalkWatch { + armed: Option, + opening: Option, + pending: Option, +} + +impl TalkWatch { + pub fn clear(&mut self) { + *self = Self::default(); + } + + /// A sample that is not the overworld -- a battle. Whatever the fly was doing before it is + /// not what opens the next text box, so the arming is dropped; a conversation already open + /// (a trainer the fly spoke to) stays pending and pays when its box is finally closed. + pub fn interrupt(&mut self) { + self.armed = None; + self.opening = None; + } + + /// One overworld sample (`wIsInBattle` zero, every playability gate passed). + /// + /// A conversation pays when all of this holds, each read out of WRAM: + /// + /// 1. **The fly started it.** On the last sample before the text box opened + /// (`wFontLoaded` bit 0 rising) the fly had the joypad -- no ignored buttons, no simulated + /// input, no scripted movement ([`state::controllable`]) -- was standing still + /// (`wWalkCounter` zero, which is the only state the overworld reads A in) and stood on the + /// tile it is on now. A script's text opens with the joypad already taken, or on the frame + /// a step onto a trigger tile ends; neither is `ready`. + /// 2. **It is with the thing in front of the fly.** `DisplayTextID` copies its argument into + /// `wSpriteIndex`: a value up to `wNumSprites` is a sprite slot, and that sprite must stand + /// on the tile the player faces -- or one further, across a counter, on a tileset that has + /// counter tiles (`IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter`). A larger + /// value is a text id, and it must be the text id of the sign on the tile the player faces. + /// An item ball is a sprite but not a person: it pays `item`, not `talk`. + /// 3. **Indoors**, by [`indoor`], on the map the box opened on. + /// 4. **It finished.** The box closed again on the same map. A conversation that ends in a + /// warp, a restore or a blackout pays nothing. + /// + /// Returns the conversation on the sample the box closes; the caller pays it once per key. + pub fn observe( + &mut self, + memory: &mut dyn MemoryReader, + map: u8, + x: u32, + y: u32, + ) -> Option { + let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0; + if !open { + self.opening = None; + let finished = self + .pending + .take() + .filter(|conversation| conversation.map == map); + let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0; + self.armed = Some(Armed { map, x, y, ready }); + return finished; + } + if let Some(armed) = self.armed.take() + && armed.ready + && armed.map == map + && armed.x == x + && armed.y == y + && self.pending.is_none() + && indoor(memory.read8(ram::wCurMapTileset)) + { + self.opening = Some(Opening { map, samples: 0 }); + } + if let Some(opening) = self.opening.as_mut() { + opening.samples += 1; + let map = opening.map; + let expired = opening.samples >= OPENING_SAMPLES; + if let Some(thing) = thing_in_front(memory) { + self.pending = Some(Conversation { map, thing }); + self.opening = None; + } else if expired { + self.opening = None; + } + } + None + } +} + +/// The step `facing` points at from `(x, y)`, or `None` off the top or left of the map. +fn ahead(x: u8, y: u8, facing: Facing) -> Option<(u8, u8)> { + let (dx, dy) = facing.delta(); + let x = u8::try_from(i16::from(x) + dx).ok()?; + let y = u8::try_from(i16::from(y) + dy).ok()?; + Some((x, y)) +} + +/// `wMapSpriteExtraData`'s two bytes for a sprite slot (1-based, as `hSpriteIndex` is). +fn extra_data(memory: &mut dyn MemoryReader, slot: u8) -> (u8, u8) { + let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2; + (memory.read8(entry), memory.read8(entry + 1)) +} + +/// Whether sprite `slot` is an item ball: `LoadMapHeader` writes `(item id, 0)` into its extra +/// data for an `ITEM`-flagged `object_event`, `(trainer class, trainer number)` for a `TRAINER` +/// one -- trainer numbers start at 1 -- and two zeroes for everything else. +fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool { + let (item, second) = extra_data(memory, slot); + item != 0 && second == 0 +} + +/// `DisplayTextID`'s argument, if it names something the player is facing. +fn thing_in_front(memory: &mut dyn MemoryReader) -> Option { + let argument = memory.read8(ram::wSpriteIndex); + if argument == 0 { + // TEXT_START_MENU. + return None; + } + let player: Player = state::player(memory)?; + let one = ahead(player.x, player.y, player.facing)?; + let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1); + if argument <= sprites { + let npc = state::npcs(memory) + .into_iter() + .find(|npc| npc.slot == argument)?; + let at = (npc.x, npc.y); + let reached = at == one + || (ahead(one.0, one.1, player.facing) == Some(at) + && state::counter_tiles(memory) + .iter() + .any(|tile| *tile != poke::NO_COUNTER_TILE)); + if reached && !is_item_ball(memory, argument) { + return Some(Thing::Sprite(argument)); + } + return None; + } + state::signs(memory) + .into_iter() + .any(|sign| sign.text_id == argument && (sign.x, sign.y) == one) + .then_some(Thing::Sign(argument)) +} + +/// `wToggleableObjectFlags` is `flag_array $100`. +const TOGGLE_BYTES: usize = 32; +/// `wObtainedHiddenItemsFlags` is `flag_array MAX_HIDDEN_ITEMS`, and `MAX_HIDDEN_ITEMS` is 112 +/// (`constants/item_constants.asm`). +const HIDDEN_ITEM_BYTES: usize = 14; +/// `wToggleableObjectList` is `ds 16 * 2 + 1`: sixteen `(sprite slot, global index)` pairs and a +/// `$ff` terminator. +const TOGGLE_LIST_ENTRIES: u16 = 16; + +/// The two item balls a script *reveals*: `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4` (`$87`, the Silph +/// Scope) and `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5` (`$88`, the Lift Key), the only `ITEM` +/// `object_event`s `data/maps/toggleable_objects.asm` starts `OFF`, and the only item entries +/// `constants/toggle_constants.asm` does not mark "X, never toggled by a script". +/// +/// Every other item ball's bit is clear from a new game until `PickUpItem` sets it, so a set bit +/// is a pickup. These two are set from the start and cleared when Giovanni's defeat shows them, +/// so seeding them as "already taken" would withhold two payouts for ever. They are the only +/// bits the seed leaves out. +pub const SCRIPT_SHOWN_ITEM_BALLS: [u8; 2] = [0x87, 0x88]; + +/// The cartridge's two "this item has been taken" bitsets, as of one sample. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ItemFlags { + toggles: [u8; TOGGLE_BYTES], + hidden: [u8; HIDDEN_ITEM_BYTES], +} + +fn bit(bytes: &[u8], index: usize) -> bool { + bytes + .get(index / 8) + .is_some_and(|byte| byte & (1 << (index % 8)) != 0) +} + +impl ItemFlags { + pub fn read(memory: &mut dyn MemoryReader) -> Self { + let mut toggles = [0; TOGGLE_BYTES]; + for (offset, byte) in toggles.iter_mut().enumerate() { + *byte = memory.read8(ram::wToggleableObjectFlags + offset as u16); + } + let mut hidden = [0; HIDDEN_ITEM_BYTES]; + for (offset, byte) in hidden.iter_mut().enumerate() { + *byte = memory.read8(ram::wObtainedHiddenItemsFlags + offset as u16); + } + Self { toggles, hidden } + } + + /// Ledger keys for every item this state already shows as taken: the seed that stops a + /// pickup made before the rule existed from paying after a rollback un-takes it. + /// + /// Every set toggle bit but [`SCRIPT_SHOWN_ITEM_BALLS`] -- which includes the bits of people + /// a script has hidden, harmlessly, because only an item ball's key is ever looked up -- and + /// every set hidden-item bit. + pub fn seed(&self) -> Vec { + let mut keys = Vec::new(); + for index in 0..TOGGLE_BYTES * 8 { + if bit(&self.toggles, index) && !SCRIPT_SHOWN_ITEM_BALLS.contains(&(index as u8)) { + keys.push(ball_key(index as u8)); + } + } + for index in 0..HIDDEN_ITEM_BYTES * 8 { + if bit(&self.hidden, index) { + keys.push(hidden_key(index as u8)); + } + } + keys + } +} + +/// One item the fly has just picked up. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pickup { + pub key: String, + pub label: String, +} + +pub fn ball_key(global: u8) -> String { + format!("item:{global}") +} + +pub fn hidden_key(index: u8) -> String { + format!("hidden:{index}") +} + +/// Items whose "taken" bit rose between `before` and `now`. +/// +/// - **An item ball** is one of this map's toggleable sprites (`wToggleableObjectList`) whose +/// extra data says item ([`is_item_ball`]). `PickUpItem` sets its global bit in +/// `wToggleableObjectFlags` through `HideObject`, and only after `GiveItem` succeeded, so a full +/// bag pays nothing. A toggleable that is not an item -- a person a script hides, a legendary +/// after its battle -- is never looked at. +/// - **A hidden item** is a bit of `wObtainedHiddenItemsFlags`, which `FoundHiddenItemText` sets +/// after `GiveItem` succeeded and nothing else in the game writes. Hidden coins have a bitset of +/// their own and are not items. +/// +/// A bit that was already set on the previous sample is not a pickup, which is what keeps a +/// restored or seeded state from paying for anything it already holds. +pub fn pickups(memory: &mut dyn MemoryReader, before: &ItemFlags, now: &ItemFlags) -> Vec { + let mut out = Vec::new(); + let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1); + for entry in 0..TOGGLE_LIST_ENTRIES { + let slot = memory.read8(ram::wToggleableObjectList + entry * 2); + if slot == 0xff { + break; + } + let global = memory.read8(ram::wToggleableObjectList + entry * 2 + 1); + let index = usize::from(global); + if slot == 0 || slot > sprites || !bit(&now.toggles, index) || bit(&before.toggles, index) { + continue; + } + let (item, second) = extra_data(memory, slot); + if item != 0 && second == 0 { + out.push(Pickup { + key: ball_key(global), + label: format!("FOUND ITEM #{item}"), + }); + } + } + for index in 0..HIDDEN_ITEM_BYTES * 8 { + if bit(&now.hidden, index) && !bit(&before.hidden, index) { + out.push(Pickup { + key: hidden_key(index as u8), + label: "FOUND A HIDDEN ITEM".to_string(), + }); + } + } + out +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs index 7d4058f..2d6b390 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs @@ -1,4 +1,4 @@ -//! The Pokémon Red reward adapter, `pokered-unique8-v6`. +//! The Pokémon Red reward adapter, `pokered-unique8-v7`. //! //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the @@ -6,9 +6,12 @@ //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule, //! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step //! next to and the first step onto each of a map's exits; v6 adds `catch`, the -//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon. +//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon; v7 adds +//! `talk` and `item` and stops `boundary` paying indoors, the operator's decision of +//! 2026-09-23 to pay for engaging with a building rather than for leaving it. pub mod catalog; +pub mod engage; #[cfg(test)] pub(crate) mod fake_wram; pub mod macros; @@ -33,33 +36,39 @@ use symbols::ram; /// Adapter version, pinned into the checkpoint compatibility string. /// -/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision -/// rather than an accident: the compatibility string is compared whole before a -/// restore is attempted, so a `v5` run is refused by default and resumed only when -/// the operator names it in `FLY_ACCEPT_ADAPTERS` -/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That -/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is -/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero. +/// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an +/// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a +/// decision rather than an accident: the compatibility string is compared whole before a +/// restore is attempted, so a `v6` run is refused by default and resumed only when the +/// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`], +/// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this +/// pair: `v6`'s ledger is a `v7` ledger holding no `talk:`, `item:` or `hidden:` keys, and the +/// first sample after the restore seeds the item keys from the cartridge's own bits, so +/// nothing already picked up pays ([`engage::ItemFlags::seed`]). /// -/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never +/// (`v6` was the `catch` rule and migrated `v5` the same way: an absent counter reads as +/// zero. `v5` was the `boundary` rule, and rejected `v4` because a ledger that had never /// recorded a `boundary:` key could not be resumed as though its exits were already /// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank /// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of -/// those is a migration: this one is, because nothing a `v5` ledger holds means -/// something different under `v6`.) -pub const REWARD_ADAPTER: &str = "pokered-unique8-v6"; +/// those is a migration; the last two are, because nothing an older ledger holds means +/// something different under the newer rules.) +pub const REWARD_ADAPTER: &str = "pokered-unique8-v7"; -/// Adapter ids whose checkpoints `v6` can read. +/// Adapter ids whose checkpoints `v7` can read. /// -/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else: -/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the -/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so -/// resuming it would pay a second time for every exit the run had already found -- and neither is -/// `v3`, whose stored rank is a rung on a different ladder. +/// Exactly one, and it is one because the engagement rules add ledger keys and change nothing +/// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start +/// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the +/// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback +/// un-takes it. The `boundary:` keys a `v6` run earned indoors stay in the ledger and mean what +/// they meant -- `exit_visited` still reads them. `v5` is not here: the live run is `v6`, and +/// the one migration the operator asked for is the one this adapter tests. `v4` and `v3` stay +/// refused for the reasons [`REWARD_ADAPTER`] gives. /// /// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too /// (`crate::compatibility::decide`, `docs/design/flysim.md`). -pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"]; +pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v6"]; /// The only cartridge semantic rewards are enabled for. Even the canonical /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. @@ -89,8 +98,19 @@ pub const SUPPORTED_ROM: &str = /// empty, which is the truth about a run that was never paid for a catch. That is the /// whole of the documented `v5` -> `v6` migration; see /// [`crate::compatibility::RestoreDecision`]. +/// +/// *Not* bumped for the engagement rules either. `talk` and `item` key their payouts into the +/// existing `seen` array, the way `boundary` did, and the item seed is marked there too +/// ([`ITEMS_SEEDED`]); a `v6` state is structurally a `v7` state with none of those keys. pub const STATE_VERSION: u64 = 4; +/// The `seen` key that says the item keys have been seeded from the cartridge's flags. +/// +/// Absent from every `v6` state and from a fresh adapter; the first playable sample that finds +/// it absent writes one `item:`/`hidden:` key per item the game already shows as taken, pays +/// nothing for any of them, and writes this. +const ITEMS_SEEDED: &str = "items:seeded"; + /// Catch payouts one species may earn in the lifetime of a run's ledger. /// /// The same cap and the same reason as the wild-KO rule's three: a species the fly can @@ -441,6 +461,13 @@ pub struct PokemonRedReward { battle: Option, mode: String, + /// The `talk` rule's frame-to-frame watch. Transient: never checkpointed, cleared by a + /// rollback and by a restore. + talk: engage::TalkWatch, + /// The item bitsets as of the last playable sample, so a pickup is a bit that *rose*. + /// Transient for the same reason. + item_flags: Option, + /// Transient, recomputed every sample and never checkpointed. safe: bool, progress: u32, @@ -491,6 +518,8 @@ impl PokemonRedReward { stable: 0, battle: None, mode: "BOOT".to_string(), + talk: engage::TalkWatch::default(), + item_flags: None, safe: false, progress: 0, badges: 0, @@ -635,6 +664,10 @@ impl PokemonRedReward { self.stable = 0; self.battle = None; self.safe = false; + // A conversation or a pickup in flight across a rollback is not paid: the game the + // fly returns to has not had it. What *was* paid stays in `seen` and blocks a replay. + self.talk.clear(); + self.item_flags = None; let keys: Vec = self.wild_wins.keys().cloned().collect(); for key in keys { self.replay_blocked.insert(&key); @@ -750,11 +783,13 @@ impl PokemonRedReward { self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms); self.initialized = true; } + self.items(&mut emitted, memory, brain_ms); let in_battle = memory.read8(ram::wIsInBattle); if in_battle == 1 || in_battle == 2 || in_battle == 255 { self.mode = "BATTLE".to_string(); self.stable = 0; + self.talk.interrupt(); let species_paid = self.counts.get(kind::SPECIES); if self.battle.is_none() && in_battle != 255 { self.battle = Some(Battle { @@ -849,6 +884,18 @@ impl PokemonRedReward { self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS)); } } + // The talk rule (`docs/rewards-learning.md`, the operator 2026-09-23): a conversation + // the fly opened indoors, paid once per (map, object) when its box closes. + if let Some(conversation) = self.talk.observe(memory, map, x, y) { + self.once( + &mut emitted, + &conversation.key(), + kind::TALK, + conversation.label(), + false, + brain_ms, + ); + } let location = format!("{map}:{x}:{y}"); self.stable = if self.location == location { self.stable + 1 } else { 1 }; self.location = location.clone(); @@ -1056,6 +1103,11 @@ impl PokemonRedReward { /// What this is not: a path. No button is chosen here, nothing is planned, and no map /// knowledge reaches the readout. It is a reward the fly may or may not find, like every other /// rule in the catalog. + /// + /// **Indoors it pays nothing** (the operator, 2026-09-23): on a map [`engage::indoor`] calls a + /// building, every key is still written to the ledger -- so [`GameAdapter::exit_visited`] + /// answers exactly what it did, and a door found indoors is found -- but no payout is emitted. + /// The exits of a town, a route, a forest or a cave pay as they always have. #[allow(clippy::too_many_arguments)] fn boundary( &mut self, @@ -1072,6 +1124,7 @@ impl PokemonRedReward { // Collected first, paid second: the ledger writes need `&mut self` and the table walk // needs the sample cache, and the order of the collection is the order of the payouts. let mut hits: Vec<(String, bool)> = Vec::new(); + let pays = !engage::indoor(memory.read8(ram::wCurMapTileset)); let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS); for index in 0..u16::from(warps) { @@ -1112,6 +1165,10 @@ impl PokemonRedReward { continue; } let key = format!("{key}:{}", if on_exit { "on" } else { "near" }); + if !pays { + self.seen.insert(&key); + continue; + } self.once_scaled( emitted, &key, @@ -1124,6 +1181,34 @@ impl PokemonRedReward { } } + /// Pay each item picked up since the last playable sample, once per item for the lifetime of + /// the ledger (`docs/rewards-learning.md`, the operator 2026-09-23). + /// + /// The first sample that finds [`ITEMS_SEEDED`] absent -- a fresh adapter, or a `v6` state + /// restored under `v7` -- keys every item the cartridge already shows as taken and pays for + /// none of them. After that a pickup is a bit that rose between two playable samples + /// ([`engage::pickups`]) and pays unless its key is already in `seen`, which is what stops a + /// rollback that un-takes an item from paying for it twice. + fn items( + &mut self, + emitted: &mut Vec, + memory: &mut impl MemoryReader, + brain_ms: f64, + ) { + let now = engage::ItemFlags::read(memory); + if !self.seen.contains(ITEMS_SEEDED) { + for key in now.seed() { + self.seen.insert(&key); + } + self.seen.insert(ITEMS_SEEDED); + } else if let Some(before) = &self.item_flags { + for pickup in engage::pickups(memory, before, &now) { + self.once(emitted, &pickup.key, kind::ITEM, pickup.label, false, brain_ms); + } + } + self.item_flags = Some(now); + } + pub fn export_state(&self) -> Value { json!({ "version": STATE_VERSION, @@ -1287,6 +1372,8 @@ impl PokemonRedReward { self.mode = mode.to_string(); self.progress = progress; self.badges = badges; + self.talk.clear(); + self.item_flags = None; Ok(()) } } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs index 87559c1..3878eb3 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs @@ -865,8 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() { #[test] fn the_adapter_reports_its_identity_and_pinned_rom() { let reward = PokemonRedReward::new(); - assert_eq!(reward.id(), "pokered-unique8-v6"); - assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]); + assert_eq!(reward.id(), "pokered-unique8-v7"); + assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]); assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(!reward.rom_allowed( "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" @@ -1356,3 +1356,467 @@ fn a_rung_earned_out_of_order_does_not_skip_the_ones_under_it() { f.visit(3, 3); assert_eq!(f.reward.rank(), 9); } + +// --- Engagement rewards (the operator, 2026-09-23) ----------------------------------------------- + +/// `constants/tileset_constants.asm`: a gym, a house, a mart, the forest and a cave. +const GYM: u8 = 7; +const HOUSE: u8 = 8; +const MART: u8 = 2; +const FOREST: u8 = 3; +const CAVERN: u8 = 17; + +/// `wSpriteStateData1`'s facing byte: `SPRITE_FACING_DOWN`, `_UP`, `_LEFT`, `_RIGHT`. +const FACING_DOWN: u8 = 0x00; +const FACING_UP: u8 = 0x04; +const FACING_LEFT: u8 = 0x08; + +impl Fixture { + /// Stand on `map`, drawn with `tileset`, with no counter tiles in the tileset header. + fn on_map(&mut self, map: u8, tileset: u8) { + self.memory.set(ram::wCurMap, map); + self.memory.set(ram::wCurMapTileset, tileset); + for index in 0..3 { + self.memory.set(ram::wTilesetTalkingOverTiles + index, 0xff); + } + } + + /// A visible sprite in `slot` at map tile `(x, y)`, stored plus four as `object_event` emits + /// it, with `extra` in its `wMapSpriteExtraData` entry. + fn sprite(&mut self, slot: u8, x: u8, y: u8, extra: (u8, u8)) { + let count = self.memory.bytes[ram::wNumSprites as usize].max(slot); + self.memory.set(ram::wNumSprites, count); + let data1 = ram::wSpriteStateData1 + u16::from(slot) * 16; + let data2 = ram::wSpriteStateData2 + u16::from(slot) * 16; + self.memory.set(data1, 1); + self.memory.set(data1 + 2, 0); + self.memory.set(data2 + 4, y + 4); + self.memory.set(data2 + 5, x + 4); + let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2; + self.memory.set(entry, extra.0); + self.memory.set(entry + 1, extra.1); + } + + fn face(&mut self, facing: u8) { + self.memory.set(ram::wSpriteStateData1 + 9, facing); + } + + /// One conversation as the cartridge draws it: a sample with the box closed (the frame the A + /// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying + /// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`. + fn talk(&mut self, argument: u8) -> Vec { + let mut events = self.sample(); + self.memory.set(ram::wFontLoaded, 1); + self.memory.set(ram::wSpriteIndex, argument); + events.extend(self.sample()); + events.extend(self.sample()); + self.memory.set(ram::wFontLoaded, 0); + events.extend(self.sample()); + events + } + + fn talk_events(&mut self, argument: u8) -> Vec { + self.talk(argument).into_iter().filter(|event| event.kind == kind::TALK).collect() + } + + /// `wToggleableObjectList` for this map: `(sprite slot, global index)` pairs and `$ff`. + fn toggle_list(&mut self, entries: &[(u8, u8)]) { + for (index, (slot, global)) in entries.iter().enumerate() { + self.memory.set(ram::wToggleableObjectList + index as u16 * 2, *slot); + self.memory.set(ram::wToggleableObjectList + index as u16 * 2 + 1, *global); + } + self.memory.set(ram::wToggleableObjectList + entries.len() as u16 * 2, 0xff); + } + + fn set_bit(&mut self, base: u16, index: u16, on: bool) { + let address = base + index / 8; + let mask = 1 << (index % 8); + let byte = self.memory.bytes[address as usize]; + self.memory.set(address, if on { byte | mask } else { byte & !mask }); + } + + fn item_events(&mut self) -> Vec { + self.sample().into_iter().filter(|event| event.kind == kind::ITEM).collect() + } +} + +#[test] +fn indoors_is_the_cartridges_building_tilesets_and_nothing_else() { + // CheckIfInOutsideMap's outside (OVERWORLD, PLATEAU) and BikeRidingTilesets (OVERWORLD, + // FOREST, UNDERGROUND, SHIP_PORT, CAVERN) are the two tables; indoor is neither. + let outdoor = [0, 3, 11, 14, 17, 23]; + for tileset in 0..24u8 { + assert_eq!(engage::indoor(tileset), !outdoor.contains(&tileset), "tileset {tileset}"); + } + assert!(!engage::indoor(24), "a tileset past the table is not a building"); + assert!(!engage::indoor(0xff)); +} + +#[test] +fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + + // Nothing on the frames the box is open: the payout waits for it to close. + let mut events = f.sample(); + f.memory.set(ram::wFontLoaded, 1); + f.memory.set(ram::wSpriteIndex, 1); + events.extend(f.sample()); + assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open"); + f.memory.set(ram::wFontLoaded, 0); + let events = f.sample(); + assert_eq!(kinds(&events), ["talk"]); + assert_eq!(events[0].value, 0.10); + assert_eq!(events[0].stimulation_ms, 100); + assert_eq!(labels(&events), [format!("TALKED TO #1 IN AREA {}", maps::PEWTER_GYM)]); + + // Talking to the same person again, as often as the fly likes, is not a farm. + for _ in 0..5 { + assert!(f.talk_events(1).is_empty(), "one payout per (map, object) for the ledger's life"); + } + + // A second person on the same map is a second key. + f.sprite(2, 4, 5, (0, 0)); + f.face(FACING_LEFT); + assert_eq!(kinds(&f.talk_events(2)), ["talk"]); + + // A sign is a text id past the sprite slots, on the tile the player faces. + f.memory.set(ram::wNumSigns, 1); + f.memory.set(ram::wSignCoords, 6); + f.memory.set(ram::wSignCoords + 1, 5); + f.memory.set(ram::wSignTextIDs, 7); + f.face(FACING_DOWN); + let sign = f.talk_events(7); + assert_eq!(labels(&sign), [format!("READ SIGN #7 IN AREA {}", maps::PEWTER_GYM)]); + assert!(f.talk_events(7).is_empty()); + assert_eq!(f.reward.statistics().counts[kind::TALK], 3); +} + +#[test] +fn the_same_slot_on_another_indoor_map_is_another_conversation() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + assert_eq!(kinds(&f.talk_events(1)), ["talk"]); + f.on_map(maps::OAKS_LAB, HOUSE); + f.visit(5, 5); + assert_eq!(kinds(&f.talk_events(1)), ["talk"]); +} + +#[test] +fn talking_outdoors_in_a_forest_or_in_a_cave_pays_nothing() { + for (map, tileset) in [ + (maps::PEWTER_CITY, 0), + (maps::VIRIDIAN_FOREST, FOREST), + (maps::MT_MOON_1F, CAVERN), + ] { + let mut f = Fixture::booted(); + f.on_map(map, tileset); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + assert!(f.talk_events(1).is_empty(), "map {map}, tileset {tileset}"); + } +} + +#[test] +fn text_the_fly_did_not_open_pays_nothing() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + + // A script that took the joypad before it drew the box: a trainer walking up, a guard. + f.memory.set(ram::wJoyIgnore, 0xff); + assert!(f.talk_events(1).is_empty(), "the joypad was the cartridge's"); + f.memory.set(ram::wJoyIgnore, 0); + + // Simulated input, and scripted movement. + f.memory.set(ram::wSimulatedJoypadStatesIndex, 3); + assert!(f.talk_events(1).is_empty(), "the buttons were simulated"); + f.memory.set(ram::wSimulatedJoypadStatesIndex, 0); + f.memory.set(ram::wStatusFlags5, 0x80); + assert!(f.talk_events(1).is_empty(), "the movement was scripted"); + f.memory.set(ram::wStatusFlags5, 0); + + // A trigger tile: the box opens on the frame a step ends, with the walk counter still + // running on the sample before it -- the overworld never reads A mid-step. + f.memory.set(ram::wWalkCounter, 1); + assert!(f.talk_events(1).is_empty(), "a step onto a trigger tile is not a press"); + f.memory.set(ram::wWalkCounter, 0); + + // Text about someone the fly is not facing: a script naming a sprite across the room. + f.sprite(2, 9, 9, (0, 0)); + assert!(f.talk_events(2).is_empty(), "not the thing in front"); + // A text id that is no sign in front of the fly. + assert!(f.talk_events(9).is_empty(), "no sign there"); + // The start menu is text id 0. + assert!(f.talk_events(0).is_empty(), "the start menu is not a conversation"); + + // A script that opens the box with the joypad taken and hands it back mid-conversation: + // the frames after it are the fly's, but the box did not open on one of them. + f.memory.set(ram::wJoyIgnore, 0xff); + f.sample(); + f.memory.set(ram::wFontLoaded, 1); + f.memory.set(ram::wSpriteIndex, 1); + f.sample(); + f.memory.set(ram::wJoyIgnore, 0); + f.sample(); + f.sample(); + f.memory.set(ram::wFontLoaded, 0); + assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "no open edge after a ready frame"); + + // None of that was recorded: the person is still worth one conversation. + assert_eq!(kinds(&f.talk_events(1)), ["talk"]); +} + +#[test] +fn a_conversation_that_ends_somewhere_else_pays_nothing() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + f.sample(); + f.memory.set(ram::wFontLoaded, 1); + f.memory.set(ram::wSpriteIndex, 1); + f.sample(); + // The script warped the fly out while the box was up. + f.on_map(maps::PEWTER_CITY, 0); + f.memory.set(ram::wFontLoaded, 0); + assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0); + + // A rollback mid-conversation, likewise: the watch is transient. + f.on_map(maps::PEWTER_GYM, GYM); + f.sample(); + f.memory.set(ram::wFontLoaded, 1); + f.sample(); + f.reward.clear_transient(); + f.memory.set(ram::wFontLoaded, 0); + assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0); +} + +#[test] +fn an_item_ball_is_an_item_not_a_conversation() { + let mut f = Fixture::booted(); + f.on_map(maps::OAKS_LAB, HOUSE); + f.visit(5, 5); + f.sprite(1, 5, 4, (0x14, 0)); + f.face(FACING_UP); + assert!(f.talk_events(1).is_empty()); + // A trainer is a person: `(class, number)`, and numbers start at one. + f.sprite(2, 4, 5, (0xcb, 2)); + f.face(FACING_LEFT); + assert_eq!(kinds(&f.talk_events(2)), ["talk"]); +} + +#[test] +fn a_clerk_across_a_counter_is_in_reach_only_where_the_tileset_has_counters() { + let mut f = Fixture::booted(); + f.on_map(maps::VIRIDIAN_MART, MART); + f.visit(5, 5); + f.sprite(1, 5, 3, (0, 0)); + f.face(FACING_UP); + assert!(f.talk_events(1).is_empty(), "two tiles away with no counter tiles in the header"); + f.memory.set(ram::wTilesetTalkingOverTiles, 0x18); + assert_eq!(kinds(&f.talk_events(1)), ["talk"], "IsSpriteOrSignInFrontOfPlayer's long range"); +} + +#[test] +fn a_rollback_or_a_restore_cannot_replay_a_conversation() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + assert_eq!(kinds(&f.talk_events(1)), ["talk"]); + + f.reward.clear_transient(); + let state = f.reward.export_state(); + assert!( + state["seen"] + .as_array() + .unwrap() + .contains(&json!(format!("talk:{}:sprite:1", maps::PEWTER_GYM))), + "the ledger is the checkpointed `seen` set, not the macros' session ledger" + ); + let mut restored = PokemonRedReward::new(); + restored.import_state(&state).unwrap(); + f.reward = restored; + f.visit(5, 5); + assert!(f.talk_events(1).is_empty()); + assert_eq!(f.reward.statistics().counts[kind::TALK], 1); +} + +#[test] +fn an_item_ball_pays_once_when_its_bit_rises_and_never_again() { + let mut f = Fixture::booted(); + f.on_map(maps::VIRIDIAN_FOREST, FOREST); + f.sprite(3, 5, 4, (0x14, 0)); // a Potion + f.toggle_list(&[(3, 0x2a)]); + f.visit(5, 5); + + // PickUpItem: GiveItem, then HideObject sets the ball's global bit. + f.set_bit(ram::wToggleableObjectFlags, 0x2a, true); + let events = f.item_events(); + assert_eq!(labels(&events), ["FOUND ITEM #20"]); + assert_eq!(events[0].value, 0.15); + assert_eq!(events[0].stimulation_ms, 120); + assert!(f.item_events().is_empty(), "a bit that stays set pays once"); + + // A rollback to a slot where the ball is still there, and the fly takes it again. + f.reward.clear_transient(); + f.set_bit(ram::wToggleableObjectFlags, 0x2a, false); + f.sample(); + f.set_bit(ram::wToggleableObjectFlags, 0x2a, true); + assert!(f.item_events().is_empty(), "once per item for the run"); + assert_eq!(f.reward.statistics().counts[kind::ITEM], 1); +} + +#[test] +fn only_an_item_balls_bit_pays_and_only_after_a_pickup() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_CITY, 0); + f.sprite(1, 2, 2, (0, 0)); // a person a script hides + f.sprite(2, 3, 3, (0xcb, 1)); // a trainer + f.sprite(4, 6, 6, (0x14, 0)); // a ball + f.toggle_list(&[(1, 0x03), (2, 0x04), (4, 0x05)]); + f.visit(5, 5); + f.set_bit(ram::wToggleableObjectFlags, 0x03, true); + f.set_bit(ram::wToggleableObjectFlags, 0x04, true); + assert!(f.item_events().is_empty(), "a hidden person or trainer is not an item"); + // A bag too full to take the ball leaves the bit clear, and nothing pays. + assert!(f.item_events().is_empty()); + f.set_bit(ram::wToggleableObjectFlags, 0x05, true); + assert_eq!(kinds(&f.item_events()), ["item"]); +} + +#[test] +fn a_hidden_item_pays_once_per_index() { + let mut f = Fixture::booted(); + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true); + assert_eq!(labels(&f.item_events()), ["FOUND A HIDDEN ITEM"]); + assert!(f.item_events().is_empty()); + f.set_bit(ram::wObtainedHiddenItemsFlags, 18, true); + assert_eq!(kinds(&f.item_events()), ["item"]); + f.reward.clear_transient(); + f.set_bit(ram::wObtainedHiddenItemsFlags, 17, false); + f.sample(); + f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true); + assert!(f.item_events().is_empty(), "a rollback cannot replay a hidden item"); +} + +#[test] +fn items_already_taken_are_seeded_and_the_two_script_shown_balls_are_not() { + let mut f = Fixture::new(); + f.memory.set(ram::wCurMap, maps::REDS_HOUSE_2F); + f.set_bit(ram::wToggleableObjectFlags, 0x10, true); + f.set_bit(ram::wToggleableObjectFlags, 0x87, true); + f.set_bit(ram::wToggleableObjectFlags, 0x88, true); + f.set_bit(ram::wObtainedHiddenItemsFlags, 4, true); + assert!(f.item_events().is_empty(), "the seed pays nothing"); + let seen = f.reward.export_state()["seen"].clone(); + let seen: Vec<&str> = seen.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + for key in ["item:16", "hidden:4", "items:seeded"] { + assert!(seen.contains(&key), "{key}"); + } + assert!(!seen.contains(&"item:135") && !seen.contains(&"item:136")); + + // Giovanni beaten: the script shows the Silph Scope's ball (its bit clears), and the fly + // takes it. + f.on_map(0x8a, 22); + f.sprite(9, 25, 2, (0x48, 0)); + f.toggle_list(&[(9, 0x87)]); + f.sample(); + f.set_bit(ram::wToggleableObjectFlags, 0x87, false); + f.sample(); + f.set_bit(ram::wToggleableObjectFlags, 0x87, true); + assert_eq!(kinds(&f.item_events()), ["item"]); +} + +#[test] +fn boundary_pays_nothing_indoors_but_still_records_the_exit() { + let mut f = Fixture::booted(); + f.on_map(maps::REDS_HOUSE_1F, 1); + f.warps(&[(4, 4)]); + assert!(boundary_values(&f.visit(3, 4)).is_empty(), "no payout beside an indoor door"); + assert!(boundary_values(&f.visit(4, 4)).is_empty(), "nor on it"); + assert!( + f.reward.exit_visited(MapExit::Warp { map: maps::REDS_HOUSE_1F, x: 4, y: 4 }), + "the ledger still knows the door, so the macros see what they always saw" + ); + assert_eq!(f.reward.statistics().counts[kind::BOUNDARY], 0); + + // Outdoors, in the forest and in a cave, exits pay exactly what they did. + let outdoors = + [(maps::PALLET_TOWN, 0), (maps::VIRIDIAN_FOREST, FOREST), (maps::MT_MOON_1F, CAVERN)]; + for (map, tileset) in outdoors { + f.on_map(map, tileset); + assert_eq!(boundary_values(&f.visit(3, 4)), [0.05], "map {map}"); + assert_eq!(boundary_values(&f.visit(4, 4)), [0.10], "map {map}"); + } +} + +#[test] +fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() { + // A v6 run: some play, a pickup the v6 adapter did not pay for, and a v6 export -- which is + // a v7 export without any of the keys v7 writes. + let mut f = Fixture::booted(); + f.on_map(maps::VIRIDIAN_FOREST, FOREST); + f.sprite(3, 5, 4, (0x14, 0)); + f.toggle_list(&[(3, 0x2a)]); + f.visit(5, 5); + f.catch(0xb0, Some(3)); + let mut v6 = f.reward.export_state(); + let seen: Vec = v6["seen"] + .as_array() + .unwrap() + .iter() + .filter(|key| { + let key = key.as_str().unwrap(); + !(key.starts_with("talk:") || key.starts_with("item") || key.starts_with("hidden:")) + }) + .cloned() + .collect(); + v6["seen"] = json!(seen); + v6["counts"].as_object_mut().unwrap().remove("talk"); + v6["counts"].as_object_mut().unwrap().remove("item"); + assert_eq!(v6["version"], json!(STATE_VERSION), "v6 and v7 share a schema version"); + + // Under v6 the fly took the ball. + f.set_bit(ram::wToggleableObjectFlags, 0x2a, true); + f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true); + + let mut restored = PokemonRedReward::new(); + restored.import_state(&v6).unwrap(); + assert_eq!(restored.statistics().counts[kind::TALK], 0); + assert_eq!(restored.statistics().counts[kind::ITEM], 0); + assert_eq!(restored.statistics().counts[kind::CATCH], 1, "nothing else moves"); + f.reward = restored; + assert!(f.sample().is_empty(), "no retroactive payout for anything taken under v6"); + + // A rollback to a v6-era slot where the ball is still on the ground: taking it again is + // the same pickup, and it does not pay. + f.reward.clear_transient(); + f.set_bit(ram::wToggleableObjectFlags, 0x2a, false); + f.set_bit(ram::wObtainedHiddenItemsFlags, 9, false); + f.sample(); + f.set_bit(ram::wToggleableObjectFlags, 0x2a, true); + f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true); + assert!(f.item_events().is_empty()); + + // The talk ledger starts empty: the first conversation under v7 pays. + f.on_map(maps::PEWTER_GYM, GYM); + f.visit(5, 5); + f.sprite(1, 5, 4, (0, 0)); + f.face(FACING_UP); + assert_eq!(kinds(&f.talk_events(1)), ["talk"]); +} diff --git a/services/flysim/crates/flybrain-gb/tests/rom.rs b/services/flysim/crates/flybrain-gb/tests/rom.rs index 3132c61..06147cb 100644 --- a/services/flysim/crates/flybrain-gb/tests/rom.rs +++ b/services/flysim/crates/flybrain-gb/tests/rom.rs @@ -15,10 +15,10 @@ use std::time::Instant; -use flybrain_gb::adapter::MemoryReader; +use flybrain_gb::adapter::{MapExit, MemoryReader}; use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND}; use flybrain_gb::pokemon_red::symbols::ram; -use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; +use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM, engage}; use flybrain_gb::{ DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter, buttons, @@ -444,7 +444,10 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option { /// The boundary rule, against the cartridge rather than a synthetic trace. /// /// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first -/// payouts are boundary events near the stairs". Two things are checked here that no synthetic +/// payouts are boundary events near the stairs". Since `pokered-unique8-v7` the bedroom is +/// *indoors* (`engage::indoor`: tileset `REDS_HOUSE_2`, neither outside nor bike-ridable), so +/// the same walk now proves the other half of the rule: the stairs enter the ledger where they +/// always did, and nothing is paid for them. Three things are checked here that no synthetic /// WRAM trace can check: /// /// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp, @@ -453,9 +456,11 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option { /// layout were X-then-Y, or the stride were not four, this assertion is what fails. /// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at /// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first -/// playable sample and have to be walked to. +/// playable sample and have to be walked to -- and the ledger records them from beside them. +/// 3. **that the cartridge calls the bedroom a building**: `wCurMapTileset` reads `REDS_HOUSE_2` +/// on the running game, so the rule pays nothing there. #[test] -fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { +fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() { let mut emulator = skip_without_rom!(boot()); assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge"); let mut adapter = PokemonRedReward::new(); @@ -498,6 +503,8 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { 0, "an indoor map has no connected edges" ); + assert_eq!(emulator.read_wram(ram::wCurMapTileset), 4, "REDS_HOUSE_2"); + assert!(engage::indoor(4), "and the rule calls it a building"); let (spawn_x, spawn_y) = (emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord)); assert!( @@ -509,6 +516,7 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { let mut seed: u64 = 0x5eed_1234_5678_9abc; let mut boundary: Vec<(u8, u8, f64)> = Vec::new(); let mut kinds: Vec<&'static str> = Vec::new(); + let mut found_at: Option<(u8, u8)> = None; for frame in 0..24_000u32 { seed = seed .wrapping_mul(6_364_136_223_846_793_005) @@ -531,26 +539,19 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { boundary.push((x, y, event.value)); } } + if found_at.is_none() && adapter.exit_visited(MapExit::Warp { map: 0x26, x: 7, y: 1 }) { + found_at = Some((x, y)); + } if adapter.map_id() != Some(0x26) { break; } } eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}"); - let (x, y, value) = *boundary.first().expect("the stairs were never found"); + let (x, y) = found_at.expect("the stairs were never found"); assert!( x.abs_diff(7) + y.abs_diff(1) <= 1, - "the first boundary payout was at ({x}, {y}), not next to the stairs at (7, 1)" + "the stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)" ); - assert!( - value == 0.05 || value == 0.10, - "a boundary payout is the adjacent value or twice it, got {value}" - ); - assert!( - boundary.len() <= 2, - "one warp can pay at most twice in a lifetime, got {boundary:?}" - ); - for (x, y, _) in &boundary { - assert!(x.abs_diff(7) + y.abs_diff(1) <= 1, "payout away from the stairs at ({x}, {y})"); - } + assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}"); } diff --git a/services/flysim/crates/flysim/src/reset.rs b/services/flysim/crates/flysim/src/reset.rs index acf575e..d610950 100644 --- a/services/flysim/crates/flysim/src/reset.rs +++ b/services/flysim/crates/flysim/src/reset.rs @@ -321,7 +321,7 @@ mod tests { wall_ms: 1_700_000_000_000, rom_sha256: "ab".repeat(32), emulator_frame: 12_345, - compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(), + compatibility: "kernel/pokered-unique8-v7/fingerprint".to_string(), speed: 1.0, buttons: 0, rank_since_ms: 4_242.0, diff --git a/services/flysim/crates/flysim/src/snapshot.rs b/services/flysim/crates/flysim/src/snapshot.rs index dbcced1..c43be4c 100644 --- a/services/flysim/crates/flysim/src/snapshot.rs +++ b/services/flysim/crates/flysim/src/snapshot.rs @@ -242,7 +242,7 @@ pub struct FeedMacroOutcome { /// Reward categories the feed reports counts for. The adapter's own interned kinds /// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, -/// `catch`) map onto these. +/// `catch`, `talk`, `item`) map onto these. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RewardKind { @@ -291,6 +291,15 @@ impl RewardKind { // for it on the same frame, so the `pokedex` counter already moves. Mapping `catch` // there as well would count one event twice. "catch" => Self::Wildwin, + // `talk` and `item` (the operator, 2026-09-23) are the fly finding what is in a place: + // a person or a sign it opened, an item it picked up. That is the family `explore` + // already counts -- new ground, a door found -- at the same quiet scale (0.05 to + // 0.15), so both publish there and the feed's closed kind set does not move. Not + // `area`, which counts maps and is a notable row; not `story`, which is the plot; + // not `wildwin`, which is a battle. The Pokémon Red ticker's copy for `explore` says + // "new find" so that the row is true of all four (`apps/stage/src/games/pokemon-red.ts`). + "talk" => Self::Explore, + "item" => Self::Explore, // The platformer. "band" => Self::Explore, "coin" => Self::Wildwin, @@ -749,6 +758,8 @@ mod tests { assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin)); + assert_eq!(RewardKind::from_adapter("talk"), Some(RewardKind::Explore)); + assert_eq!(RewardKind::from_adapter("item"), Some(RewardKind::Explore)); } #[test] diff --git a/services/flysim/crates/flysim/tests/compat_migration.rs b/services/flysim/crates/flysim/tests/compat_migration.rs index ce47b05..2234d56 100644 --- a/services/flysim/crates/flysim/tests/compat_migration.rs +++ b/services/flysim/crates/flysim/tests/compat_migration.rs @@ -1,15 +1,21 @@ -//! A `v5` checkpoint restored under `v6`: accepted with the opt-in, refused without it. +//! 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-v5` compatibility string and a `v5` reward ledger — +//! 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. +//! 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`. +//! 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}; @@ -24,24 +30,30 @@ fn compatibility(adapter: &str) -> String { ) } -/// A `v5` reward ledger: `STATE_VERSION` 4, every field `v5` wrote, and **no** `catchCounts`. +/// 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 -/// `v6` state with the counter deleted — this is the shape the release box's checkpoints really -/// carry, field for field. -fn v5_reward() -> serde_json::Value { +/// `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"], + "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": 1 + "trainer": 0, "battle": 2, "badge": 0, "boundary": 2, "catch": 1 }, - "total": 2.05, + "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, @@ -55,7 +67,7 @@ fn v5_reward() -> serde_json::Value { }) } -fn v5_checkpoint() -> Vec { +fn v6_checkpoint() -> Vec { use flybrain_core::decoder::DecoderState; use flybrain_core::lif::LifState; use flybrain_core::ordered::NumberMap; @@ -105,12 +117,12 @@ fn v5_checkpoint() -> Vec { 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-v5"), + compatibility: compatibility("pokered-unique8-v6"), speed: 1.0, buttons: 0, rank_since_ms: 1_000.0, last_event_id: 4_242, - reward: v5_reward(), + reward: v6_reward(), ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() }, emulator: vec![3; 64], framebuffer: vec![0; 32], @@ -121,13 +133,13 @@ fn v5_checkpoint() -> Vec { } #[test] -fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { - let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); +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, "v6 is not v5"); + assert_ne!(checkpoint.runtime.compatibility, current, "v7 is not v6"); - for opt_in in [None, Some(""), Some("pokered-unique8-v4"), Some("some-other-adapter")] { + for opt_in in [None, Some(""), Some("pokered-unique8-v5"), Some("some-other-adapter")] { assert!( matches!( decide( @@ -143,9 +155,18 @@ fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { } } +/// A flat 64 KiB address space: the one thing a sample reads. +struct Wram(Vec); + +impl MemoryReader for Wram { + fn read8(&mut self, address: u16) -> u8 { + self.0[address as usize] + } +} + #[test] -fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() { - let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); +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()); @@ -154,25 +175,25 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z &checkpoint.runtime.compatibility, ¤t, adapter.migrates_from(), - &accepted_adapters(Some("pokered-unique8-v5")), + &accepted_adapters(Some("pokered-unique8-v6")), ), - RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } + 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 v5 ledger is a valid v6 ledger"); + adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a valid v7 ledger"); let after = adapter.export_state(); - assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0"); - assert_eq!(after["counts"]["catch"], serde_json::json!(0)); + 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 v5 state carried round-trips to the same value, - // and the only key v6 adds is the counter. + // 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 v6 state lists `catch` where a v5 state had - // nothing to list. Every kind the v5 state did carry keeps its number. - let before = v5_reward(); + // 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() { @@ -184,7 +205,7 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z .keys() .filter(|kind| !value.as_object().unwrap().contains_key(*kind)) .collect(); - assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others"); + 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"); @@ -195,7 +216,29 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z .keys() .filter(|key| !before.as_object().unwrap().contains_key(*key)) .collect(); - assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others"); + 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); @@ -206,11 +249,11 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z #[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-v5")); + let accepted = accepted_adapters(Some("pokered-unique8-v6")); let current = compatibility(adapter.id()); - // A v5 string whose state format also moved: a different build, not a rule change. - let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617"); + // 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, ¤t, adapter.migrates_from(), &accepted), RestoreDecision::Refuse(_) @@ -221,4 +264,15 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { decide(¤t, ¤t, 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"), + ¤t, + adapter.migrates_from(), + &accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")), + ), + RestoreDecision::Refuse(_) + )); } diff --git a/services/flysim/crates/flysim/tests/integration.rs b/services/flysim/crates/flysim/tests/integration.rs index 73c4692..2c6c36e 100644 --- a/services/flysim/crates/flysim/tests/integration.rs +++ b/services/flysim/crates/flysim/tests/integration.rs @@ -267,7 +267,7 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil let (status, versions) = service.get("/status"); assert_eq!(status, 200); assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2")); - assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v6")); + assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v7")); assert!( versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32, "the dataset fingerprint is in /status: {}",