//! The Super Mario Land reward adapter, `sml-progress-v1`. //! //! Implements `docs/design/platformer.md` (2026-09-15), which is binding: every //! address, value, cap, gate and rank in this module is from that document, and //! the module comments point at the section rather than restating the argument. //! //! Shape mirrors [`crate::pokemon_red`]: a per-sample byte cache so each address //! crosses the FFI boundary at most once, a playable gate that pays nothing //! unless every condition holds, baselining on the first valid sample, a //! lifetime once-ledger that makes oscillation unprofitable, and a checkpoint //! whose schema version gates crediting. //! //! Three things differ from the Pokémon adapter, all of them deliberate: //! //! - **The ROM pin is configuration, not a constant.** The design pins the //! SHA-256 of Super Mario Land (World) (Rev A), but only its SHA-1 is known //! (`docs/design/platformer.md` §8.5), so the hash arrives from //! `[game.platformer] rom_sha256` through [`PlatformerAdapter::with_rom_pin`]. //! No pin means no semantic rewards, exactly as a wrong cartridge does. //! - **Boot is `!playable`, not one mode string.** `BOOT`, `DEMO`, `GAME OVER` //! and `TRANSITION` all need the permissive Start/Select variant so the fly //! can begin a run after a game over ([`GameAdapter::boot`]). //! - **Recovery has its own budget** ([`GameAdapter::recovery_policy`]): a game //! over discards the whole run, so it restores immediately and needs a larger //! lifetime budget than Pokémon's 36 (§5). //! //! Death pays nothing and costs nothing. The only repeatable income is capped, //! and the only large income is ground never reached before (§2). pub mod catalog; pub mod symbols; use std::collections::{BTreeMap, HashMap}; use serde_json::{Value, json}; use crate::adapter::{ AdapterError, DecoderPresetId, GameAdapter, MemoryReader, ProgressSnapshot, RewardEvent, }; use crate::ordered::OrderedSet; use crate::ratchet::RecoveryPolicy; use catalog::{Counts, LastEvents, kind}; use symbols::{bcd, hram, wram}; /// Adapter version, pinned into the checkpoint compatibility string. pub const ADAPTER: &str = "sml-progress-v1"; /// Schema version of [`PlatformerAdapter::export_state`]. Anything else cannot /// be credited under the current rules and rebaselines instead. pub const STATE_VERSION: u64 = 1; /// The milestone ladder, ranks 0 to 15 (`docs/design/platformer.md` §3). Ranks 4 /// to 14 are `4 + highestClearedLevelIndex`; rank 15 is the game clear. pub const RANK_LADDER: [&str; 16] = [ "BOOTING", "GAME STARTED", "FIRST COIN", "HALFWAY THROUGH 1-1", "1-1 CLEARED", "1-2 CLEARED", "WORLD 1 CLEARED (King Totomesu)", "2-1 CLEARED", "2-2 CLEARED", "WORLD 2 CLEARED (Dragonzamasu, Marine Pop)", "3-1 CLEARED", "3-2 CLEARED", "WORLD 3 CLEARED (Hiyoihoi)", "4-1 CLEARED", "4-2 CLEARED", "GAME CLEARED (Tatanga)", ]; /// Column rank 3 needs: `60 + (18 - 3) * 20 / 2`, i.e. halfway through 1-1 (§3). pub const HALFWAY_1_1_COLUMN: u32 = symbols::FIRST_COLUMN + 15 * symbols::COLUMNS_PER_SCREEN / 2; /// Consecutive samples in the same level before anything pays, mirroring the /// Pokémon adapter's `stable >= 3`. pub const STABLE_SAMPLES: u64 = 3; /// Lowest decoded `wGameTimer` value a snapshot may be taken at (§2, "Safe /// snapshot"). The field's unit is the raw 3-byte BCD value; the *scale* of that /// value is **UNVERIFIED**, so this threshold is the design's number applied to /// the decoded field and nothing more. pub const SAFE_MIN_GAME_TIMER: u32 = 100; /// Recovery budgets for this game (§5): a game over restores immediately with a /// 60 s cooldown, the stall window is 300 brain seconds, and the lifetime budget /// is 48 rather than Pokémon's 36, which a platformer would spend in an hour. /// Only the budgets are per-game: the rank bound is the ladder's own length, which /// [`crate::Ratchet::import`] takes from the adapter. pub const RECOVERY_POLICY: RecoveryPolicy = RecoveryPolicy { stall_ms: 300_000, cooldown_ms: 180_000, game_over_cooldown_ms: Some(60_000), unsafe_reset_ms: crate::ratchet::UNSAFE_RESET_MS, max_attempts: 3, max_recoveries: 48, }; /// Immutable per-sample byte cache, as [`crate::pokemon_red`] uses: each address /// requested during one sample crosses the FFI boundary at most once. struct SampleCache<'a> { source: &'a mut dyn MemoryReader, bytes: HashMap, } impl MemoryReader for SampleCache<'_> { fn read8(&mut self, address: u16) -> u8 { if let Some(value) = self.bytes.get(&address) { return *value; } let value = self.source.read8(address); self.bytes.insert(address, value); value } } /// One playable sample's decoded game state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Sample { level: u32, world: u32, stage: u32, column: u32, coins: u32, score: u32, lives: u32, super_status: u8, superball: u8, wins: u32, game_state: u8, underground: bool, on_ground: bool, jumping: bool, invincible: bool, game_timer: u32, timer_expiring: bool, } /// Per-life latches: what the previous playable sample read. All of them are /// transient, because a rollback invalidates every delta they feed. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct Latches { coins: Option, score: Option, lives: Option, super_status: Option, superball: Option, wins: Option, } pub struct PlatformerAdapter { /// SHA-256 semantic rewards are enabled for, lowercased. `None` disables /// them, which is also what a wrong cartridge does. rom_pin: Option, /// Lifetime once-ledger: `band::`, `powerup:super:`, /// `powerup:ball:`, `level:`, `world:`, `started`, /// `clear`, `halfway`. ledger: OrderedSet, /// Band keys in [`PlatformerAdapter::ledger`], maintained incrementally. bands: u64, /// Lifetime coin payouts per level, capped by the catalog. coin_payouts: BTreeMap, /// Lifetime score payouts per level, capped by the catalog and decaying. score_payouts: BTreeMap, /// Furthest column reached per level, for the hero number and the per-level /// ghost marker (§6). Not a reward input. best_column: BTreeMap, counts: Counts, total: f64, recent: Vec, last: LastEvents, initialized: bool, saw_boot: bool, /// Highest `hLevelIndex` observed in a valid sample, ever. max_level: u32, /// Highest world nibble observed in a valid sample, ever. max_world: u32, /// Monotone rank on [`RANK_LADDER`]. rank: u32, /// Transient: consecutive valid samples in the same level. stable: u64, /// Transient: level of the previous valid sample. level: Option, /// Transient: per-life latches. latches: Latches, /// Transient: last sampled column, lives and mode. column: u32, lives: u32, mode: String, safe: bool, game_over: bool, playable: bool, } /// What the stream page shows about reward history, as the Pokémon adapter's /// `Statistics` does. #[derive(Debug, Clone, PartialEq)] pub struct Statistics { pub counts: BTreeMap<&'static str, u64>, pub total: f64, pub recent: Vec, pub last: Vec<(&'static str, RewardEvent)>, pub mode: String, /// Band keys earned, lifetime. The ratchet's coverage signal. pub bands: u64, } impl Default for PlatformerAdapter { fn default() -> Self { Self::new() } } impl PlatformerAdapter { /// An adapter with no ROM pin: it samples and reports, and pays nothing. pub fn new() -> Self { Self { rom_pin: None, ledger: OrderedSet::new(), bands: 0, coin_payouts: BTreeMap::new(), score_payouts: BTreeMap::new(), best_column: BTreeMap::new(), counts: Counts::default(), total: 0.0, recent: Vec::new(), last: LastEvents::default(), initialized: false, saw_boot: false, max_level: 0, max_world: 0, rank: 0, stable: 0, level: None, latches: Latches::default(), column: 0, lives: 0, mode: "BOOT".to_string(), safe: false, game_over: false, playable: false, } } /// Pin the cartridge semantic rewards are enabled for, from /// `[game.platformer] rom_sha256`. An empty or non-hex value is treated as /// no pin, so a misconfigured deployment runs with rewards visibly off /// rather than crediting an unknown ROM. pub fn with_rom_pin(pin: Option<&str>) -> Self { let mut adapter = Self::new(); adapter.rom_pin = pin .map(str::trim) .filter(|pin| pin.len() == 64 && pin.chars().all(|c| c.is_ascii_hexdigit())) .map(|pin| pin.to_ascii_lowercase()); adapter } /// The pinned cartridge hash, if one was configured. pub fn rom_pin(&self) -> Option<&str> { self.rom_pin.as_deref() } /// Whether the last sample was a state a snapshot can be restored into. pub fn safe(&self) -> bool { self.safe } /// Whether the last sample saw a game over (§5, restore trigger A). pub fn game_over(&self) -> bool { self.game_over } /// Rank on [`RANK_LADDER`], 0 to 15. pub fn rank(&self) -> u32 { self.rank } /// Level index of the last valid sample, or `None` before one. pub fn level(&self) -> Option { self.level } /// Progress through the current level, 0.0 to 1.0, from the *camera*: the /// column loader runs up to one screen ahead of Mario (§8.2), and the stream /// must label it as camera progress. pub fn level_percent(&self) -> Option { let level = self.level?; let columns = symbols::level_columns(level)?; let ahead = self.column.saturating_sub(symbols::FIRST_COLUMN); Some((f64::from(ahead) / f64::from(columns)).clamp(0.0, 1.0)) } /// Furthest column ever reached in `level`. pub fn best_column(&self, level: u32) -> Option { self.best_column.get(&level).copied() } pub fn statistics(&self) -> Statistics { Statistics { counts: self.counts.to_map(), total: self.total, recent: self.recent.clone(), last: self.last.iter().map(|(kind, event)| (kind, event.clone())).collect(), mode: self.mode.clone(), bands: self.bands, } } /// Forget observations a rollback invalidates. The lifetime ledger, the /// per-level payout counters, the rank and the totals all survive, so the /// ground a restore replays pays nothing on the way back (§5). pub fn clear_transient(&mut self) { self.stable = 0; self.level = None; self.latches = Latches::default(); self.column = 0; self.safe = false; self.game_over = false; self.playable = false; } /// Sample WRAM and HRAM after one completed frame and return this frame's /// payouts. pub fn sample(&mut self, source: &mut dyn MemoryReader, brain_ms: f64) -> Vec { self.safe = false; self.game_over = false; self.playable = false; if self.rom_pin.is_none() { self.mode = "UNSUPPORTED ROM · SEMANTIC REWARDS OFF".to_string(); self.stable = 0; return Vec::new(); } let mut cache = SampleCache { source, bytes: HashMap::new() }; let memory = &mut cache; let Some(sample) = self.gate(memory) else { return Vec::new(); }; self.playable = true; self.column = sample.column; self.lives = sample.lives; self.mode = format!("IN LEVEL {}-{}", sample.world, sample.stage); let best = self.best_column.entry(sample.level).or_insert(0); *best = (*best).max(sample.column); let mut emitted: Vec = Vec::new(); let first = !self.initialized; if first { self.baseline(&sample, &mut emitted, brain_ms); } self.pay_bands(&sample, &mut emitted, brain_ms); self.pay_coins(&sample, &mut emitted, brain_ms); self.pay_score(&sample, &mut emitted, brain_ms); self.pay_powerups(&sample, &mut emitted, brain_ms); self.pay_lives(&sample, &mut emitted, brain_ms); self.pay_progress(&sample, &mut emitted, brain_ms); self.latches = Latches { coins: Some(sample.coins), score: Some(sample.score), lives: Some(sample.lives), super_status: Some(sample.super_status), superball: Some(sample.superball), wins: Some(sample.wins), }; self.max_level = self.max_level.max(sample.level); self.max_world = self.max_world.max(sample.world); self.update_rank(&sample); self.safe = self.is_safe(&sample); // Newest first, capped at eight, as the Pokémon adapter does. let mut recent = emitted.clone(); recent.append(&mut self.recent); recent.truncate(8); self.recent = recent; emitted } /// The playable gate (§2): every condition, every sample, before any payout. /// Returns the decoded sample only when all of them hold. fn gate(&mut self, memory: &mut impl MemoryReader) -> Option { let game_state = memory.read8(hram::hGameState); let lives = u32::from(memory.read8(wram::wLives)); let game_over_window = memory.read8(wram::wGameOverWindowEnabled); // Game over first: it is both a mode and the ratchet's restore trigger, // and it must not be reported as a transition. if game_state == symbols::GAME_STATE_GAME_OVER_TEXT || game_state == symbols::GAME_STATE_GAME_OVER_WAIT || (lives == 0 && game_over_window != 0) { self.mode = "GAME OVER".to_string(); self.game_over = true; self.saw_boot = true; self.stable = 0; return None; } // Menus. `0x0E` initialises the menu, `0x0F` is the start menu. if game_state == 0x0e || game_state == 0x0f { self.mode = "BOOT".to_string(); self.saw_boot = true; self.stable = 0; return None; } // The attract demo runs real gameplay states, so only this byte // distinguishes it. Without the gate the demo would farm rewards. if memory.read8(hram::UNNAMED_DEMO_GATE) != 0 { self.mode = "DEMO".to_string(); self.saw_boot = true; self.stable = 0; return None; } let playable_state = game_state == symbols::GAME_STATE_NORMAL || game_state == symbols::GAME_STATE_AUTOSCROLL; if !playable_state || memory.read8(hram::hGamePaused) != 0 || game_over_window != 0 || lives == 0 { self.mode = "TRANSITION".to_string(); self.stable = 0; return None; } // Consistency: a mismatch is a mid-transition or a wrong-revision read. // Cheap, and it catches a wrong ROM immediately. let world_and_level = memory.read8(hram::hWorldAndLevel); let world = u32::from(world_and_level >> 4); let stage = u32::from(world_and_level & 0x0f); let level = u32::from(memory.read8(hram::hLevelIndex)); let screen = u32::from(memory.read8(hram::hScreenIndex)); let column_in_screen = u32::from(memory.read8(hram::hColumnIndex)); let screens = symbols::screens(level); if !(1..=4).contains(&world) || !(1..=3).contains(&stage) || level >= symbols::LEVEL_COUNT || level != (world - 1) * 3 + (stage - 1) || screens.is_none_or(|screens| !(symbols::FIRST_SCREEN..=screens).contains(&screen)) || column_in_screen >= symbols::COLUMNS_PER_SCREEN { self.mode = "TRANSITION".to_string(); self.stable = 0; return None; } let score = bcd(&[ memory.read8(wram::wScore), memory.read8(wram::wScore + 1), memory.read8(wram::wScore + 2), ]); let coins = bcd(&[memory.read8(hram::hCoins)]); let game_timer = bcd(&[ memory.read8(wram::wGameTimer), memory.read8(wram::wGameTimer + 1), memory.read8(wram::wGameTimer + 2), ]); // A non-BCD nibble in any of the three is the same class of evidence as // an inconsistent level: something is mid-write or this is not Rev A. let (Some(score), Some(coins), Some(game_timer)) = (score, coins, game_timer) else { self.mode = "TRANSITION".to_string(); self.stable = 0; return None; }; self.stable = if self.level == Some(level) { self.stable + 1 } else { 1 }; self.level = Some(level); if self.stable < STABLE_SAMPLES { self.mode = format!("IN LEVEL {world}-{stage}"); return None; } Some(Sample { level, world, stage, column: screen * symbols::COLUMNS_PER_SCREEN + column_in_screen, coins, score, lives, super_status: memory.read8(hram::hSuperStatus), superball: memory.read8(hram::hSuperballMario), wins: u32::from(memory.read8(hram::hWinCount)), game_state, underground: memory.read8(hram::UNNAMED_UNDERGROUND) != 0, on_ground: memory.read8(wram::UNNAMED_ON_GROUND) == 1, jumping: memory.read8(wram::UNNAMED_JUMP_STATUS) != 0, invincible: memory.read8(wram::wInvincibilityTimer) != 0, game_timer, timer_expiring: memory.read8(wram::wGameTimerExpiringFlag) != 0, }) } /// Record the first valid sample without paying for it, so a restored /// checkpoint mid-level replays nothing (§2, "Baselining"). fn baseline(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { for band in self.band_keys(sample.level, sample.column) { self.remember(&band); } if sample.super_status >= 2 { self.remember(&format!("powerup:super:{}", sample.level)); } if sample.superball != 0 { self.remember(&format!("powerup:ball:{}", sample.level)); } for index in 0..=sample.level { self.remember(&format!("level:{index}")); } for world in 1..=sample.world { self.remember(&format!("world:{world}")); } if sample.wins > 0 { self.remember("clear"); } if sample.column >= HALFWAY_1_1_COLUMN && sample.level == 0 { self.remember("halfway"); } self.max_level = self.max_level.max(sample.level); self.max_world = self.max_world.max(sample.world); // `started` is the one payout a first sample can make, and only when a // boot state was seen first: a migrated save is not a new adventure. if self.saw_boot && !self.ledger.contains("started") { self.emit(emitted, kind::STARTED, "RUN STARTED".to_string(), 1.0, brain_ms); } self.remember("started"); self.initialized = true; } /// Band keys from the start of the level up to `column`, clamped to the /// level's band cap of `2 * (screens - 3)`. fn band_keys(&self, level: u32, column: u32) -> Vec { let Some(bands) = symbols::level_bands(level) else { return Vec::new(); }; let first = symbols::FIRST_COLUMN / symbols::BAND_COLUMNS; let reached = column / symbols::BAND_COLUMNS; (first..=reached.min(first + bands - 1)) .map(|band| format!("band:{level}:{band}")) .collect() } /// Bands are keyed positions in a lifetime set, so walking back and forth /// across a boundary pays once, ever (§2). fn pay_bands(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { // Pipe sub-rooms reuse `hScreenIndex`, so their columns would alias onto // the main level's bands (§8.3). Safe v1: pay nothing underground. if sample.underground { return; } let Some(key) = self.band_keys(sample.level, sample.column).pop() else { return; }; if self.ledger.contains(&key) { return; } self.remember(&key); let percent = symbols::level_columns(sample.level) .map(|columns| { f64::from(sample.column.saturating_sub(symbols::FIRST_COLUMN)) / f64::from(columns) }) .unwrap_or(0.0); self.emit( emitted, kind::BAND, format!( "NEW GROUND {}-{} {}%", sample.world, sample.stage, (percent * 100.0).round() as u32 ), 1.0, brain_ms, ); } fn pay_coins(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { let Some(previous) = self.latches.coins else { return; }; // A mod-100 wrap is an increase of one, never a decrease: the counter is // two BCD digits and rolls over at 99. if sample.coins == previous { return; } let paid = self.coin_payouts.get(&sample.level).copied().unwrap_or(0); let cap = catalog::rule(kind::COIN).expect("coin is a catalog kind").per_level_cap; if cap.is_some_and(|cap| paid >= cap) { return; } self.coin_payouts.insert(sample.level, paid + 1); self.emit(emitted, kind::COIN, "COIN".to_string(), 1.0, brain_ms); } fn pay_score(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { let Some(previous) = self.latches.score else { return; }; if sample.score <= previous { return; } let paid = self.score_payouts.get(&sample.level).copied().unwrap_or(0); let cap = catalog::rule(kind::SCORE).expect("score is a catalog kind").per_level_cap; if cap.is_some_and(|cap| paid >= cap) { return; } let delta = f64::from(sample.score - previous); let size = (delta / catalog::SCORE_FULL_DELTA).min(1.0); let decay = 1.0 + (paid / catalog::SCORE_DECAY_STEP) as f64; self.score_payouts.insert(sample.level, paid + 1); self.emit( emitted, kind::SCORE, format!("+{}", sample.score - previous), size / decay, brain_ms, ); } fn pay_powerups(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { // `hSuperStatus` 3 and up is injury i-frames, so only "reached 2" is a // power-up: 2 -> 3 and 3 -> 2 pay nothing. if sample.super_status == 2 { let key = format!("powerup:super:{}", sample.level); if !self.ledger.contains(&key) { self.remember(&key); self.emit( emitted, kind::POWERUP, "SUPER MARIO".to_string(), 1.0, brain_ms, ); } } if sample.superball != 0 { let key = format!("powerup:ball:{}", sample.level); if !self.ledger.contains(&key) { self.remember(&key); self.emit(emitted, kind::POWERUP, "SUPERBALL".to_string(), 1.0, brain_ms); } } } fn pay_lives(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { if self.latches.lives.is_some_and(|previous| sample.lives > previous) { self.emit(emitted, kind::LIFE, "1UP".to_string(), 1.0, brain_ms); } } /// Level, world and game clear. All three are lifetime-keyed, so replaying a /// level after a rollback pays nothing. fn pay_progress(&mut self, sample: &Sample, emitted: &mut Vec, brain_ms: f64) { if sample.level > self.max_level { let key = format!("level:{}", sample.level); if !self.ledger.contains(&key) { self.remember(&key); self.emit( emitted, kind::LEVEL, format!("LEVEL CLEARED: {}-{} REACHED", sample.world, sample.stage), 1.0, brain_ms, ); } } if sample.world > self.max_world { let key = format!("world:{}", sample.world); if !self.ledger.contains(&key) { self.remember(&key); self.emit( emitted, kind::WORLD, format!("WORLD CLEARED: WORLD {} REACHED", sample.world), 1.0, brain_ms, ); } } let rose = self.latches.wins.is_some_and(|previous| sample.wins > previous); if rose && !self.ledger.contains("clear") { self.remember("clear"); self.emit(emitted, kind::CLEAR, "GAME CLEARED".to_string(), 1.0, brain_ms); } } /// Ranks are monotone: the ladder never falls back, because a rank is /// supposed to name a state worth archiving (§3). fn update_rank(&mut self, sample: &Sample) { let mut rank = 1; if self.counts.get(kind::COIN) > 0 { rank = 2; } if sample.level == 0 && sample.column >= HALFWAY_1_1_COLUMN { self.remember("halfway"); } if self.ledger.contains("halfway") { rank = 3; } if self.max_level >= 1 { // 4 + highestClearedLevelIndex, and reaching level index n means // level n-1 was cleared. rank = 4 + (self.max_level - 1); } if self.ledger.contains("clear") { rank = (RANK_LADDER.len() - 1) as u32; } self.rank = self.rank.max(rank.min((RANK_LADDER.len() - 1) as u32)); } /// The ratchet's safe-observation gate (§2, "Safe snapshot"): everything in /// the playable gate, plus a grounded, un-invincible, not-growing Mario in a /// non-autoscrolling level with time on the clock. fn is_safe(&self, sample: &Sample) -> bool { sample.game_state == symbols::GAME_STATE_NORMAL && !sample.underground && sample.on_ground && !sample.jumping && !sample.invincible && (sample.super_status == 0 || sample.super_status == 2) && sample.game_timer >= SAFE_MIN_GAME_TIMER && !sample.timer_expiring && self.stable >= STABLE_SAMPLES } /// Insert a ledger key, keeping the band counter in step. fn remember(&mut self, key: &str) { if self.ledger.insert(key) && key.starts_with("band:") { self.bands += 1; } } fn emit( &mut self, emitted: &mut Vec, kind: &'static str, label: String, scale: f64, brain_ms: f64, ) { let rule = catalog::rule(kind).expect("emit is only called with catalog kinds"); let event = RewardEvent { kind, label, brain_ms, value: rule.value * scale, stimulation_ms: rule.stimulation_ms, }; emitted.push(event.clone()); self.counts.bump(kind); self.total += event.value; self.last.set(event); } pub fn export_state(&self) -> Value { json!({ "version": STATE_VERSION, "adapter": ADAPTER, "ledger": self.ledger.as_slice(), "coinPayouts": level_object(&self.coin_payouts), "scorePayouts": level_object(&self.score_payouts), "bestColumn": level_object(&self.best_column), "counts": self.counts, "total": self.total, "recent": self.recent, "last": self.last, "initialized": self.initialized, "sawBoot": self.saw_boot, "maxLevel": self.max_level, "maxWorld": self.max_world, "rank": self.rank, "mode": self.mode, }) } /// Restore lifetime history from [`PlatformerAdapter::export_state`]. /// /// A state whose `version` is not [`STATE_VERSION`] is ignored and the /// adapter rebaselines at its next valid sample, because old reward /// semantics cannot be credited under the current rules. Malformed /// current-version state is an error, and nothing is assigned until every /// field has validated, so a failed restore leaves the adapter untouched. pub fn import_state(&mut self, input: &Value) -> Result<(), AdapterError> { if input.get("version").and_then(Value::as_u64) != Some(STATE_VERSION) { return Ok(()); } const BAD_CHECKPOINT: AdapterError = AdapterError("Invalid reward checkpoint"); const BAD_HISTORY: AdapterError = AdapterError("Invalid reward history"); let ledger = string_array(input.get("ledger")).ok_or(BAD_CHECKPOINT)?; let total = input.get("total").and_then(Value::as_f64).ok_or(BAD_CHECKPOINT)?; if !total.is_finite() { return Err(BAD_CHECKPOINT); } let initialized = input.get("initialized").and_then(Value::as_bool).ok_or(BAD_CHECKPOINT)?; let saw_boot = input.get("sawBoot").and_then(Value::as_bool).ok_or(BAD_CHECKPOINT)?; let mode = input.get("mode").and_then(Value::as_str).ok_or(BAD_CHECKPOINT)?; let max_level = counter(input.get("maxLevel")).ok_or(BAD_CHECKPOINT)?; let max_world = counter(input.get("maxWorld")).ok_or(BAD_CHECKPOINT)?; let rank = counter(input.get("rank")).ok_or(BAD_CHECKPOINT)?; if max_level >= symbols::LEVEL_COUNT || max_world > 4 || rank >= RANK_LADDER.len() as u32 { return Err(BAD_CHECKPOINT); } let coin_payouts = level_keyed(input.get("coinPayouts")).ok_or(BAD_CHECKPOINT)?; let score_payouts = level_keyed(input.get("scorePayouts")).ok_or(BAD_CHECKPOINT)?; let best_column = level_keyed(input.get("bestColumn")) .ok_or(BAD_CHECKPOINT)? .into_iter() .map(|(level, column)| (level, column as u32)) .collect(); let counts_raw = counted_record(input.get("counts")).ok_or(BAD_CHECKPOINT)?; let recent = input .get("recent") .and_then(Value::as_array) .ok_or(BAD_CHECKPOINT)? .iter() .map(parse_event) .collect::>>() .ok_or(BAD_HISTORY)?; let mut last = LastEvents::default(); for (key, value) in input.get("last").and_then(Value::as_object).ok_or(BAD_HISTORY)?.iter() { let event = parse_event(value).ok_or(BAD_HISTORY)?; if catalog::index(key).is_some() { last.set(event); } } self.ledger = ledger.iter().map(String::as_str).collect(); self.bands = self .ledger .as_slice() .iter() .filter(|key| key.starts_with("band:")) .count() as u64; self.coin_payouts = coin_payouts; self.score_payouts = score_payouts; self.best_column = best_column; self.counts = Counts::default(); for (key, count) in &counts_raw { self.counts.set(key, *count); } self.total = total; self.recent = recent; self.last = last; self.initialized = initialized; self.saw_boot = saw_boot; self.max_level = max_level; self.max_world = max_world; self.rank = rank; self.mode = mode.to_string(); // Everything a rollback would invalidate starts empty: the restored run // re-baselines its latches at the next valid sample without paying. self.clear_transient(); Ok(()) } } /// A level-keyed counter map as a JSON object: JSON keys are strings, the values /// stay numbers. fn level_object(map: &BTreeMap>) -> BTreeMap { map.iter().map(|(level, value)| (level.to_string(), (*value).into())).collect() } /// An array whose every element is a string, or `None`. fn string_array(value: Option<&Value>) -> Option> { let array = value?.as_array()?; array.iter().map(|item| item.as_str().map(str::to_string)).collect() } /// `Number.MAX_SAFE_INTEGER`, so a checkpoint no JavaScript build could have /// written is rejected here too. const MAX_SAFE_INTEGER: u64 = (1u64 << 53) - 1; fn counter(value: Option<&Value>) -> Option { u32::try_from(value?.as_u64()?).ok() } /// An object whose every value is a non-negative safe integer, or `None`. fn counted_record(value: Option<&Value>) -> Option> { let object = value?.as_object()?; object .iter() .map(|(key, value)| { let count = value.as_u64().filter(|count| *count <= MAX_SAFE_INTEGER)?; Some((key.clone(), count)) }) .collect() } /// A `{"": }` object, where every key is a level index and every /// value a non-negative safe integer. The inverse of [`level_object`]. fn level_keyed(value: Option<&Value>) -> Option> { let object = value?.as_object()?; object .iter() .map(|(key, value)| { let level: u32 = key.parse().ok()?; if level >= symbols::LEVEL_COUNT { return None; } let count = value.as_u64()?; (count <= MAX_SAFE_INTEGER).then_some((level, count)) }) .collect() } fn parse_event(value: &Value) -> Option { let kind = catalog::rule(value.get("kind")?.as_str()?)?; let label = value.get("label")?.as_str()?.to_string(); let brain_ms = value.get("brainMs")?.as_f64().filter(|ms| ms.is_finite())?; let reward = value.get("value")?.as_f64().filter(|value| value.is_finite())?; Some(RewardEvent { kind: kind.kind, label, brain_ms, value: reward, stimulation_ms: kind.stimulation_ms, }) } impl GameAdapter for PlatformerAdapter { fn id(&self) -> &'static str { ADAPTER } /// Only the configured cartridge. With no pin nothing is allowed, so a /// deployment that forgot `[game.platformer] rom_sha256` runs with semantic /// rewards visibly off instead of crediting an unknown ROM. fn rom_allowed(&self, sha256: &str) -> bool { self.rom_pin.as_deref() == Some(sha256.to_ascii_lowercase().as_str()) } fn sample(&mut self, memory: &mut dyn MemoryReader, ms: f64) -> Vec { PlatformerAdapter::sample(self, memory, ms) } fn mode(&self) -> &str { &self.mode } /// The level index, which is what `game.map` means for this game. fn map_id(&self) -> Option { self.level } /// Every non-playable state is boot, so Start and Select keep their /// permissive variant on the title screen, in the attract demo and after a /// game over — the only moments the fly needs them (§4). fn boot(&self) -> bool { !self.playable } fn progress(&self) -> ProgressSnapshot { ProgressSnapshot { rank: self.rank, rank_max: RANK_LADDER.len() as u32 - 1, rank_label: RANK_LADDER[(self.rank as usize).min(RANK_LADDER.len() - 1)], counter: self.lives, counter_label: "LIVES", unique_locations: self.bands as usize, reward_total: self.total, counts: self.counts.to_map(), } } fn safe_for_snapshot(&self) -> bool { self.safe } fn game_over(&self) -> bool { self.game_over } fn recovery_policy(&self) -> RecoveryPolicy { RECOVERY_POLICY } /// The disassembly the RAM map came from, plus the pinned cartridge. Folding /// the hash in means a checkpoint earned on one ROM revision cannot be loaded /// under another's semantics (§8.5); the decoder preset needs no segment of /// its own, because [`ADAPTER`] selects it one to one. fn symbol_provenance(&self) -> String { format!( "sml:{}+rom:{}", symbols::SML_DISASSEMBLY, self.rom_pin.as_deref().unwrap_or("unpinned") ) } fn rank_ladder(&self) -> &'static [&'static str] { &RANK_LADDER } fn decoder_preset(&self) -> DecoderPresetId { DecoderPresetId::Platformer } fn clear_transient(&mut self) { PlatformerAdapter::clear_transient(self); } fn export_state(&self) -> Value { PlatformerAdapter::export_state(self) } fn import_state(&mut self, state: &Value) -> Result<(), AdapterError> { PlatformerAdapter::import_state(self, state) } } #[cfg(test)] mod tests;