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.
This commit is contained in:
acamilo 2026-09-23 11:32:59 +00:00
parent ed0080ab2c
commit 2e7eed0a78
12 changed files with 1134 additions and 106 deletions

View file

@ -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 |

View file

@ -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

View file

@ -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(&current, &current, &[], &[]), 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()]
);
}

View file

@ -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]

View file

@ -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<Armed>,
opening: Option<Opening>,
pending: Option<Conversation>,
}
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<Conversation> {
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<Thing> {
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<String> {
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<Pickup> {
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
}

View file

@ -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<Battle>,
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<engage::ItemFlags>,
/// 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<String> = 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<RewardEvent>,
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(())
}
}

View file

@ -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<RewardEvent> {
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<RewardEvent> {
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<RewardEvent> {
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<Value> = 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"]);
}

View file

@ -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<u32> {
/// 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<u32> {
/// 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:?}");
}

View file

@ -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,

View file

@ -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]

View file

@ -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<u8> {
fn v6_checkpoint() -> Vec<u8> {
use flybrain_core::decoder::DecoderState;
use flybrain_core::lif::LifState;
use flybrain_core::ordered::NumberMap;
@ -105,12 +117,12 @@ fn v5_checkpoint() -> Vec<u8> {
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<u8> {
}
#[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<u8>);
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,
&current,
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, &current, adapter.migrates_from(), &accepted),
RestoreDecision::Refuse(_)
@ -221,4 +264,15 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
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(_)
));
}

View file

@ -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: {}",