The walkable predicate answered for the ten-by-nine screen window and Unknown everywhere else. MapGrid is the same rule over every tile of the loaded map: block ids out of wOverworldMap, a blocks-to-tiles read of the tileset header s blockset through the new bank-aware ROM read, the tileset s collision list as before, and the TilePairCollisionsLand values as directed walls both ways. The reader checks itself before it answers: the decode is compared against the window predicate over the player s own neighbourhood, and a frame where the window can answer for none of it -- a battle, a text box, a frame mid-warp -- is refused, because wOverworldMap shares its bytes with the picture buffer. Every refusal is named (GridRefusal) and leaves the window predicate in charge. Cached per map id and size, so a map is decoded once on arrival rather than once per question, and owned beside the session ledgers: never checkpointed.
1312 lines
56 KiB
Rust
1312 lines
56 KiB
Rust
//! The Pokémon Red reward adapter, `pokered-unique8-v5`.
|
|
//!
|
|
//! 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
|
|
//! source evidence behind each gate. v4 replaced the 16-rung boot-to-badges
|
|
//! 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.
|
|
|
|
pub mod catalog;
|
|
#[cfg(test)]
|
|
pub(crate) mod fake_wram;
|
|
pub mod macros;
|
|
pub mod mapgrid;
|
|
pub mod maps;
|
|
pub mod scene;
|
|
pub mod state;
|
|
pub mod symbols;
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::fmt::Write as _;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::adapter::{
|
|
AdapterError, DecoderPresetId, GameAdapter, MapEdge, MapExit, MapPlace, MapTile, MemoryReader,
|
|
ProgressSnapshot, RewardEvent,
|
|
};
|
|
use crate::ordered::OrderedSet;
|
|
use catalog::{Counts, LastEvents, kind};
|
|
use symbols::ram;
|
|
|
|
/// Adapter version, pinned into the checkpoint compatibility string.
|
|
///
|
|
/// `v5` is the `boundary` rule. Bumping it is what rejects every checkpoint written
|
|
/// by `v4`: the string is compared whole before a restore is attempted, so a ledger
|
|
/// that has never recorded a single `boundary:` key can never be resumed as though
|
|
/// its exits were already collected. (`v4` was the 38-rung ladder, and rejected
|
|
/// `v3` for the same reason: a stored rank that meant "4 badges" on the old ladder
|
|
/// could not be read as a rung on the new one.) Pre-launch, so no run is lost.
|
|
pub const REWARD_ADAPTER: &str = "pokered-unique8-v5";
|
|
|
|
/// The only cartridge semantic rewards are enabled for. Even the canonical
|
|
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
|
|
pub const SUPPORTED_ROM: &str =
|
|
"0e853043e15e0f7dd150f297be5557be9890884bff3df1e9bbd7b3e1caef219f";
|
|
|
|
/// Schema version of [`PokemonRedReward::export_state`]. Anything older cannot
|
|
/// be credited under the current rules and rebaselines instead.
|
|
///
|
|
/// Bumped to 4 with the ladder even though no field changed shape: `progress` is
|
|
/// carried in the state, and a v3 `progress` is a rank on a different ladder.
|
|
/// Rebaselining costs one overworld sample; reading it as a v4 rank would put the
|
|
/// stream on the wrong rung until the fly next climbed.
|
|
///
|
|
/// *Not* bumped for the `boundary` rule. Its ledger keys live in the existing
|
|
/// `seen` array, so a v4 state is structurally a valid v5 state and means the same
|
|
/// thing field for field: every key it holds was earned under a rule that still
|
|
/// exists, and the exits it does not hold are exits the fly has not been paid for.
|
|
/// [`REWARD_ADAPTER`] is the gate that refuses such a checkpoint anyway, and it is
|
|
/// the right gate, because the objection to loading one is about semantics rather
|
|
/// than shape.
|
|
pub const STATE_VERSION: u64 = 4;
|
|
|
|
const BADGE_NAMES: [&str; 8] = [
|
|
"BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH",
|
|
];
|
|
|
|
/// The milestone ladder, ranks 0 to 37 (`docs/design/ladder.md`).
|
|
///
|
|
/// Short because the stream's rung panel draws them in Press Start 2P at 33 px in
|
|
/// a 1012 px rail. Index is the rank; [`RUNGS`] holds the condition for each one.
|
|
pub const RANK_LADDER: [&str; 38] = [
|
|
"BOOT",
|
|
"BEDROOM",
|
|
"DOWNSTAIRS",
|
|
"PALLET TOWN",
|
|
"OAK'S LAB",
|
|
"GOT A STARTER",
|
|
"OAK'S PARCEL",
|
|
"POKEDEX",
|
|
"VIRIDIAN CITY",
|
|
"VIRIDIAN FOREST",
|
|
"PEWTER CITY",
|
|
"BOULDER BADGE",
|
|
"MT. MOON",
|
|
"CERULEAN CITY",
|
|
"CASCADE BADGE",
|
|
"NUGGET BRIDGE",
|
|
"MET BILL",
|
|
"VERMILION CITY",
|
|
"HM CUT",
|
|
"THUNDER BADGE",
|
|
"ROCK TUNNEL",
|
|
"LAVENDER TOWN",
|
|
"CELADON CITY",
|
|
"SILPH SCOPE",
|
|
"RAINBOW BADGE",
|
|
"POKE FLUTE",
|
|
"FUCHSIA CITY",
|
|
"SOUL BADGE",
|
|
"SILPH CO. FREED",
|
|
"MARSH BADGE",
|
|
"CINNABAR ISLAND",
|
|
"VOLCANO BADGE",
|
|
"EARTH BADGE",
|
|
"INDIGO PLATEAU",
|
|
"BEAT LORELEI",
|
|
"BEAT BRUNO",
|
|
"BEAT AGATHA",
|
|
"CHAMPION",
|
|
];
|
|
|
|
use maps::{PALLET_TOWN, REDS_HOUSE_1F};
|
|
|
|
/// Where each rung of [`RANK_LADDER`] is earned, for `GO OBJECTIVE`.
|
|
///
|
|
/// `docs/design/macros.md` section 9: "`GO OBJECTIVE` paths toward the next unreached ladder
|
|
/// rung's place where the catalog knows one (map id and, when known, a tile or warp) ... and
|
|
/// leave it unknown where it is not, in which case the plan falls through." Section 12 keeps the
|
|
/// places unchanged when the ranking goes away: a macro that knows where it is going is exactly
|
|
/// the "knowledge lives inside macros" the simplification asks for.
|
|
///
|
|
/// A place is a map, and where the game makes one derivable, an edge of that map. Tiles are still
|
|
/// absent everywhere: a tile would have to come from the map's `object_event`/`warp_event` data,
|
|
/// which is in a ROM bank this crate cannot reach, and `docs/design/ladder.md`'s own rule is that
|
|
/// a number nobody verified against the decomp does not go in. An edge needs no such number --
|
|
/// the connection bits are in WRAM -- which is why Oak's trigger can be named at all.
|
|
///
|
|
/// Where a rung is a badge or a flag inside a building this table names the building when its id
|
|
/// is one [`maps`] derives from a verified anchor (the three gyms the ladder's first half needs),
|
|
/// and the *town* otherwise: being in Celadon is the observable step toward a Rocket hideout the
|
|
/// fly cannot be routed into, and it costs nothing once the fly is there, because a place the fly
|
|
/// is standing on falls through to the next entry. Two rungs are conditional rather than constant
|
|
/// and live in [`PokemonRedReward::rung_place`], because the game moves them: Oak's own script and
|
|
/// the parcel.
|
|
const RUNG_PLACES: [Option<MapPlace>; RANK_LADDER.len()] = {
|
|
const fn at(map: u8) -> Option<MapPlace> {
|
|
Some(MapPlace::at(map))
|
|
}
|
|
/// A rung earned by talking to somebody on this map.
|
|
const fn person(map: u8) -> Option<MapPlace> {
|
|
Some(MapPlace::person(map))
|
|
}
|
|
/// A rung earned by pressing A at something on this map.
|
|
const fn object(map: u8) -> Option<MapPlace> {
|
|
Some(MapPlace::object(map))
|
|
}
|
|
[
|
|
None, // 0 BOOT: nowhere.
|
|
at(maps::REDS_HOUSE_2F), // 1 BEDROOM
|
|
at(maps::REDS_HOUSE_1F), // 2 DOWNSTAIRS
|
|
at(maps::PALLET_TOWN), // 3 PALLET TOWN
|
|
at(maps::OAKS_LAB), // 4 OAK'S LAB (until Oak's script: see `rung_place`)
|
|
// 5 GOT A STARTER: a Pokéball on a table, which is an object and not a person.
|
|
object(maps::OAKS_LAB),
|
|
// 6 OAK'S PARCEL, delivered to Oak: a conversation, so the place is the person (mart
|
|
// first, see `rung_place`).
|
|
person(maps::OAKS_LAB),
|
|
// 7 POKEDEX, out of the same script, so the same person.
|
|
person(maps::OAKS_LAB),
|
|
at(maps::VIRIDIAN_CITY), // 8 VIRIDIAN CITY
|
|
at(maps::VIRIDIAN_FOREST), // 9 VIRIDIAN FOREST
|
|
at(maps::PEWTER_CITY), // 10 PEWTER CITY
|
|
// 11 BOULDER BADGE: a badge is the gym leader, who is a person.
|
|
person(maps::PEWTER_GYM),
|
|
at(maps::MT_MOON_1F), // 12 MT. MOON
|
|
at(maps::CERULEAN_CITY), // 13 CERULEAN CITY
|
|
person(maps::CERULEAN_GYM), // 14 CASCADE BADGE, likewise
|
|
at(maps::ROUTE_24), // 15 NUGGET BRIDGE, north out of Cerulean
|
|
at(maps::ROUTE_25), // 16 MET BILL, whose house is on Route 25
|
|
at(maps::VERMILION_CITY), // 17 VERMILION CITY
|
|
at(maps::VERMILION_CITY), // 18 HM CUT, on the S.S. Anne at Vermilion's dock
|
|
None, // 19 THUNDER BADGE: Vermilion Gym, id not derived
|
|
at(maps::ROCK_TUNNEL_1F), // 20 ROCK TUNNEL
|
|
at(maps::LAVENDER_TOWN), // 21 LAVENDER TOWN
|
|
at(maps::CELADON_CITY), // 22 CELADON CITY
|
|
at(maps::CELADON_CITY), // 23 SILPH SCOPE, in the hideout under the Game Corner
|
|
at(maps::CELADON_CITY), // 24 RAINBOW BADGE: Celadon Gym, id not derived
|
|
at(maps::LAVENDER_TOWN), // 25 POKE FLUTE, from Mr. Fuji in Lavender
|
|
at(maps::FUCHSIA_CITY), // 26 FUCHSIA CITY
|
|
at(maps::FUCHSIA_CITY), // 27 SOUL BADGE: Fuchsia Gym, id not derived
|
|
at(maps::SAFFRON_CITY), // 28 SILPH CO. FREED
|
|
at(maps::SAFFRON_CITY), // 29 MARSH BADGE: Saffron Gym, id not derived
|
|
at(maps::CINNABAR_ISLAND), // 30 CINNABAR ISLAND
|
|
at(maps::CINNABAR_ISLAND), // 31 VOLCANO BADGE: Cinnabar Gym, id not derived
|
|
at(maps::VIRIDIAN_GYM), // 32 EARTH BADGE, back at Viridian
|
|
at(maps::INDIGO_PLATEAU_LOBBY), // 33 INDIGO PLATEAU
|
|
at(maps::INDIGO_PLATEAU_LOBBY), // 34 BEAT LORELEI, through the lobby
|
|
at(maps::INDIGO_PLATEAU_LOBBY), // 35 BEAT BRUNO
|
|
at(maps::INDIGO_PLATEAU_LOBBY), // 36 BEAT AGATHA
|
|
at(maps::INDIGO_PLATEAU_LOBBY), // 37 CHAMPION
|
|
]
|
|
};
|
|
|
|
/// Bytes per entry in the current map's warp table.
|
|
///
|
|
/// `wWarpEntries:: ds MAX_WARP_EVENTS * 4 ; Y, X, warp ID, map ID` (`ram/wram.asm` at
|
|
/// [`symbols::POKERED_COMMIT`]). Only the first two are read here: the destination is where the
|
|
/// door goes, and the reward is for finding the door.
|
|
const WARP_ENTRY_BYTES: u16 = 4;
|
|
|
|
/// `DEF MAX_WARP_EVENTS EQU 32` (`constants/map_data_constants.asm`). `wNumberOfWarps` is clamped
|
|
/// to it before the table is walked, so a garbage count cannot read past the array.
|
|
const MAX_WARP_EVENTS: u8 = 32;
|
|
|
|
/// The label every `boundary` payout carries (`docs/design/room-escape.md` section 2), uppercased
|
|
/// like every other label this adapter emits. Adjacent and on-exit share it deliberately: they are
|
|
/// one rule, and "found an exit" is the true thing to put on the screen for both.
|
|
const BOUNDARY_LABEL: &str = "FOUND AN EXIT";
|
|
|
|
/// `wCurMapConnections` bits: `shift_const EAST, WEST, SOUTH, NORTH` in
|
|
/// `constants/map_data_constants.asm`, i.e. 1, 2, 4 and 8.
|
|
mod connection {
|
|
pub const EAST: u8 = 1;
|
|
pub const WEST: u8 = 2;
|
|
pub const SOUTH: u8 = 4;
|
|
pub const NORTH: u8 = 8;
|
|
}
|
|
|
|
/// What earns one rung.
|
|
///
|
|
/// Every variant is evaluated against *lifetime* state, never against the current
|
|
/// frame, so no rung can be lost once earned — see [`PokemonRedReward::rank_from`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Rung {
|
|
/// Rank 0: the ladder's floor, satisfied by nothing.
|
|
Boot,
|
|
/// The map id has entered the lifetime map ledger (first visit, standing still
|
|
/// in the unscripted overworld -- not the current position).
|
|
Visited(u8),
|
|
/// At least this many bits set in `wObtainedBadges`, ever.
|
|
Badges(u32),
|
|
/// This event flag has been observed set at least once.
|
|
Flag(&'static str),
|
|
/// Entered into the Hall of Fame: `EVENT_BEAT_CHAMPION_RIVAL` was seen, or the
|
|
/// cartridge's own durable `wNumHoFTeams` counter is non-zero. Needs both halves
|
|
/// because the Hall of Fame script clears the flag; see
|
|
/// [`PokemonRedReward::rank_from`].
|
|
HallOfFame,
|
|
}
|
|
|
|
/// The condition for each rank, parallel to [`RANK_LADDER`].
|
|
///
|
|
/// Flag rungs name the flag by its pokered symbol, which is also the key the
|
|
/// adapter's lifetime `seen` ledger uses, so a rung is a ledger lookup.
|
|
const RUNGS: [Rung; RANK_LADDER.len()] = [
|
|
Rung::Boot,
|
|
Rung::Visited(maps::REDS_HOUSE_2F),
|
|
Rung::Visited(maps::REDS_HOUSE_1F),
|
|
Rung::Visited(maps::PALLET_TOWN),
|
|
Rung::Visited(maps::OAKS_LAB),
|
|
Rung::Flag("EVENT_GOT_STARTER"),
|
|
// The parcel *delivered*, not merely collected: it is what unlocks the Pokédex,
|
|
// so it sits directly below rung 7. EVENT_GOT_OAKS_PARCEL is the earlier beat
|
|
// and is implied by Viridian City (rung 8) anyway.
|
|
Rung::Flag("EVENT_OAK_GOT_PARCEL"),
|
|
Rung::Flag("EVENT_GOT_POKEDEX"),
|
|
Rung::Visited(maps::VIRIDIAN_CITY),
|
|
Rung::Visited(maps::VIRIDIAN_FOREST),
|
|
Rung::Visited(maps::PEWTER_CITY),
|
|
Rung::Badges(1),
|
|
Rung::Visited(maps::MT_MOON_1F),
|
|
Rung::Visited(maps::CERULEAN_CITY),
|
|
Rung::Badges(2),
|
|
Rung::Flag("EVENT_BEAT_CERULEAN_RIVAL"),
|
|
Rung::Flag("EVENT_GOT_SS_TICKET"),
|
|
Rung::Visited(maps::VERMILION_CITY),
|
|
Rung::Flag("EVENT_GOT_HM01"),
|
|
Rung::Badges(3),
|
|
Rung::Visited(maps::ROCK_TUNNEL_1F),
|
|
Rung::Visited(maps::LAVENDER_TOWN),
|
|
Rung::Visited(maps::CELADON_CITY),
|
|
// There is no EVENT_GOT_SILPH_SCOPE: the Scope is a toggleable pick-up that
|
|
// RocketHideoutB4FBeatGiovanniScript reveals, so beating Giovanni in the
|
|
// hideout is the flag that means "the Scope is obtainable".
|
|
Rung::Flag("EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI"),
|
|
Rung::Badges(4),
|
|
Rung::Flag("EVENT_GOT_POKE_FLUTE"),
|
|
Rung::Visited(maps::FUCHSIA_CITY),
|
|
Rung::Badges(5),
|
|
Rung::Flag("EVENT_BEAT_SILPH_CO_GIOVANNI"),
|
|
Rung::Badges(6),
|
|
Rung::Visited(maps::CINNABAR_ISLAND),
|
|
Rung::Badges(7),
|
|
Rung::Badges(8),
|
|
Rung::Visited(maps::INDIGO_PLATEAU_LOBBY),
|
|
Rung::Flag("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0"),
|
|
Rung::Flag("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0"),
|
|
Rung::Flag("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0"),
|
|
Rung::HallOfFame,
|
|
];
|
|
|
|
/// Where one rung of the ladder is earned, [`RUNG_PLACES`] plus the two places the *game*
|
|
/// moves.
|
|
///
|
|
/// Both are read from the lifetime `seen` ledger, which is the same evidence the rung
|
|
/// conditions themselves use, so neither is a guess about the script's state:
|
|
///
|
|
/// - **rungs 4 and 5 are at the town's north exit until Oak's script has fired.** Oak stops
|
|
/// the fly on the path out of Pallet Town and walks it into the lab; before that the lab
|
|
/// holds nothing to take, and aiming at it walks the fly to a door it will be carried
|
|
/// through anyway. `EVENT_FOLLOWED_OAK_INTO_LAB` is the moment it has happened.
|
|
/// - **rung 6 is Viridian's mart until the parcel is in the bag.** The rung is the parcel
|
|
/// *delivered* (`docs/design/ladder.md`), and the delivery is at the lab, but the errand
|
|
/// starts at the mart counter two maps north. `EVENT_GOT_OAKS_PARCEL` is the half-way
|
|
/// point, and without this the catalog answers "the lab" to a fly that is standing in the
|
|
/// lab with nothing to hand over -- which is the town loop this method was written for
|
|
/// (`infra/docs/macros-bench.md`, the plan hour on rung 5).
|
|
fn rung_place(seen: &OrderedSet, rung: usize) -> Option<MapPlace> {
|
|
match rung {
|
|
4 | 5 if !seen.contains("EVENT_FOLLOWED_OAK_INTO_LAB") => {
|
|
Some(MapPlace::edge(maps::PALLET_TOWN, MapEdge::North))
|
|
}
|
|
6 if !seen.contains("EVENT_GOT_OAKS_PARCEL") => {
|
|
Some(MapPlace::at(maps::VIRIDIAN_MART))
|
|
}
|
|
_ => RUNG_PLACES.get(rung).copied().flatten(),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct Battle {
|
|
key: String,
|
|
wild: bool,
|
|
saw_living: bool,
|
|
ko: bool,
|
|
}
|
|
|
|
/// Immutable per-sample byte cache. Each address requested during one sample
|
|
/// crosses the FFI boundary at most once, across all flag and bit loops
|
|
/// including the 19 Pokédex bytes.
|
|
struct SampleCache<'a> {
|
|
source: &'a mut dyn MemoryReader,
|
|
bytes: HashMap<u16, u8>,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// Straight through, uncached: a ROM byte cannot change, so there is nothing
|
|
/// for a per-sample cache to save, and the caller that reads a blockset
|
|
/// (`docs/design/macros.md` section 15) caches the decoded map instead.
|
|
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
|
self.source.read_rom(bank, address)
|
|
}
|
|
}
|
|
|
|
fn word(memory: &mut impl MemoryReader, address: u16) -> u32 {
|
|
let high = u32::from(memory.read8(address));
|
|
high * 256 + u32::from(memory.read8(address + 1))
|
|
}
|
|
|
|
fn flag(memory: &mut impl MemoryReader, bit: u16) -> bool {
|
|
memory.read8(ram::wEventFlags + (bit >> 3)) & (1 << (bit & 7)) != 0
|
|
}
|
|
|
|
pub struct PokemonRedReward {
|
|
rom_hash: String,
|
|
milestones: HashSet<&'static str>,
|
|
|
|
seen: OrderedSet,
|
|
tiles: OrderedSet,
|
|
tile_counts: BTreeMap<u8, u64>,
|
|
wild_wins: BTreeMap<String, u64>,
|
|
replay_blocked: OrderedSet,
|
|
counts: Counts,
|
|
total: f64,
|
|
recent: Vec<RewardEvent>,
|
|
last: LastEvents,
|
|
initialized: bool,
|
|
saw_boot: bool,
|
|
location: String,
|
|
stable: u64,
|
|
battle: Option<Battle>,
|
|
mode: String,
|
|
|
|
/// Transient, recomputed every sample and never checkpointed.
|
|
safe: bool,
|
|
progress: u32,
|
|
badges: u32,
|
|
}
|
|
|
|
/// What the stream page shows about reward history.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Statistics {
|
|
pub counts: BTreeMap<&'static str, u64>,
|
|
pub total: f64,
|
|
pub recent: Vec<RewardEvent>,
|
|
pub last: Vec<(&'static str, RewardEvent)>,
|
|
pub mode: String,
|
|
/// Legacy name for unique player-coordinate locations, kept because the
|
|
/// prototype's protocol field is called `uniqueTiles`.
|
|
pub unique_tiles: usize,
|
|
}
|
|
|
|
impl Default for PokemonRedReward {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl PokemonRedReward {
|
|
pub fn new() -> Self {
|
|
Self::with_rom_hash(SUPPORTED_ROM)
|
|
}
|
|
|
|
pub fn with_rom_hash(rom_hash: &str) -> Self {
|
|
Self {
|
|
rom_hash: rom_hash.to_string(),
|
|
milestones: symbols::MILESTONES.into_iter().collect(),
|
|
seen: OrderedSet::new(),
|
|
tiles: OrderedSet::new(),
|
|
tile_counts: BTreeMap::new(),
|
|
wild_wins: BTreeMap::new(),
|
|
replay_blocked: OrderedSet::new(),
|
|
counts: Counts::default(),
|
|
total: 0.0,
|
|
recent: Vec::new(),
|
|
last: LastEvents::default(),
|
|
initialized: false,
|
|
saw_boot: false,
|
|
location: String::new(),
|
|
stable: 0,
|
|
battle: None,
|
|
mode: "BOOT".to_string(),
|
|
safe: false,
|
|
progress: 0,
|
|
badges: 0,
|
|
}
|
|
}
|
|
|
|
/// Whether the last sample was a state a snapshot can be restored into:
|
|
/// stable controllable overworld coordinates with no dialogue font loaded.
|
|
pub fn safe(&self) -> bool {
|
|
self.safe
|
|
}
|
|
|
|
/// Rank on [`RANK_LADDER`], 0 to 37.
|
|
pub fn rank(&self) -> u32 {
|
|
self.progress
|
|
}
|
|
|
|
/// The highest rung whose condition is satisfied, i.e. the max over [`RUNGS`].
|
|
///
|
|
/// Rungs may be earned out of order — a fly that stumbles into Viridian Forest
|
|
/// before Viridian City still gets the higher rung — and the maximum is what
|
|
/// makes that harmless.
|
|
///
|
|
/// ## Why every condition reads the ledger, not the cartridge
|
|
///
|
|
/// `docs/design/ladder.md` assumed "event flags never clear in normal play".
|
|
/// That is false for exactly the rungs at the top of the ladder. In pokered at
|
|
/// [`symbols::POKERED_COMMIT`], `IndigoPlateauLobby_Script` runs
|
|
/// `ResetEventRange INDIGO_PLATEAU_EVENTS_START, EVENT_LANCES_ROOM_LOCK_DOOR`
|
|
/// on every entry to the lobby once the player has started an Elite Four run,
|
|
/// so Lorelei's, Bruno's, Agatha's and Lance's beat flags are all cleared the
|
|
/// moment the fly walks back into the lobby — which is precisely what happens
|
|
/// after a blackout. `HallOfFame.asm` then resets the *whole* range,
|
|
/// `EVENT_BEAT_CHAMPION_RIVAL` included, as the player is entered into the Hall
|
|
/// of Fame: the flag for the ladder's last rung is destroyed by the event that
|
|
/// earns it. Those two `ResetEventRange` calls are the only ones in the game, so
|
|
/// the other eleven flag rungs are genuinely sticky in WRAM.
|
|
///
|
|
/// Rather than special-case four rungs, every flag rung is answered from the
|
|
/// adapter's lifetime `seen` ledger, which already records each flag under its
|
|
/// pokered name the first sample it is observed set (`sample` walks
|
|
/// [`symbols::EVENTS`] on every playable frame, battles included, and `once`
|
|
/// inserts whether or not it pays). The ledger survives a rollback —
|
|
/// [`PokemonRedReward::clear_transient`] does not touch it — and round-trips
|
|
/// through the checkpoint, so it needs no new state. `wNumHoFTeams` is the
|
|
/// belt-and-braces half of the Champion rung: it is the counter `HallOfFamePC`
|
|
/// increments, it is saved, and it is the only durable "has been Champion"
|
|
/// signal for a cartridge whose ledger starts empty.
|
|
///
|
|
/// Map rungs read the same ledger's `map:<id>` keys, which are first-visit by
|
|
/// construction: they are only inserted from the unscripted, stable, overworld
|
|
/// branch, so the archive for a map rung is taken standing still a few seconds
|
|
/// after arrival, exactly as the safe-snapshot rules require.
|
|
fn rank_from(&self, badges: u32, hall_of_fame: bool) -> u32 {
|
|
let mut rank = 0;
|
|
for index in 0..RUNGS.len() {
|
|
if self.rung_satisfied(index, badges, hall_of_fame) {
|
|
rank = index as u32;
|
|
}
|
|
}
|
|
rank
|
|
}
|
|
|
|
/// Whether one rung's own condition holds, from the lifetime ledger and the two counters.
|
|
///
|
|
/// Factored out of [`PokemonRedReward::rank_from`] so that "the highest rung satisfied" (the
|
|
/// rank, which is what the stream shows and what the ratchet measures) and "the lowest rung
|
|
/// *not* satisfied" (the objective, which is where the fly should be going) are the same
|
|
/// evidence asked two different ways. They are not the same question:
|
|
/// `docs/design/macros.md` section 12.4 has the save that proved it.
|
|
fn rung_satisfied(&self, rung: usize, badges: u32, hall_of_fame: bool) -> bool {
|
|
let Some(rung) = RUNGS.get(rung) else { return false };
|
|
match rung {
|
|
Rung::Boot => true,
|
|
Rung::Visited(map) => {
|
|
let mut key = String::with_capacity(8);
|
|
let _ = write!(key, "map:{map}");
|
|
self.seen.contains(&key)
|
|
}
|
|
Rung::Badges(count) => badges >= *count,
|
|
Rung::Flag(name) => self.seen.contains(name),
|
|
Rung::HallOfFame => hall_of_fame || self.seen.contains("EVENT_BEAT_CHAMPION_RIVAL"),
|
|
}
|
|
}
|
|
|
|
/// The lowest rung of the ladder this run has *not* satisfied.
|
|
///
|
|
/// Not `rank + 1`. The rank is the maximum over the satisfied rungs, so a rung earned out of
|
|
/// order carries it past every rung skipped on the way — and the ladder's rungs are not a
|
|
/// chain: "VIRIDIAN CITY" is a map the fly can walk to, while the two rungs under it are Oak's
|
|
/// parcel *delivered* and the Pokédex, which are a script two maps south. The live save of
|
|
/// 2026-09-17 was exactly that shape: `EVENT_GOT_OAKS_PARCEL` set and `EVENT_OAK_GOT_PARCEL`
|
|
/// clear, rungs 6 and 7 unearned, rank 8 because Viridian City had been stood on — and
|
|
/// `rank + 1` therefore aimed `GO OBJECTIVE` at Viridian Forest, north, through a gate the
|
|
/// cartridge keeps shut until the parcel is delivered. The fly walked into it once per hold
|
|
/// for eight hours (`infra/docs/macros-traps.md`).
|
|
///
|
|
/// `None` once every rung is satisfied, which is the champion.
|
|
fn next_unreached(&self, badges: u32, hall_of_fame: bool) -> Option<usize> {
|
|
(0..RUNGS.len()).find(|rung| !self.rung_satisfied(*rung, badges, hall_of_fame))
|
|
}
|
|
|
|
/// `wCurMap` as of the last overworld sample, or `None` before one.
|
|
///
|
|
/// Read back out of `location` (`"<map>:<x>:<y>"`), which is the only place the adapter
|
|
/// keeps it. Cleared by [`PokemonRedReward::clear_transient`], as the coordinates are.
|
|
pub fn map(&self) -> Option<u32> {
|
|
self.location.split(':').next()?.parse().ok()
|
|
}
|
|
|
|
/// `(wCurMap, wXCoord, wYCoord)` as of the last overworld sample, or `None` before one.
|
|
///
|
|
/// Read straight back out of `location` (`"<map>:<x>:<y>"`), the only place the adapter keeps
|
|
/// any of the three, and cleared by [`PokemonRedReward::clear_transient`] with the rest of the
|
|
/// transient state.
|
|
pub fn area_and_tile(&self) -> Option<(u32, u32, u32)> {
|
|
let mut parts = self.location.split(':');
|
|
Some((
|
|
parts.next()?.parse().ok()?,
|
|
parts.next()?.parse().ok()?,
|
|
parts.next()?.parse().ok()?,
|
|
))
|
|
}
|
|
|
|
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(),
|
|
unique_tiles: self.tiles.len(),
|
|
}
|
|
}
|
|
|
|
/// Forget observations a rollback invalidates. Lifetime novelty survives,
|
|
/// and every wild-KO key paid so far is blocked from paying again, because
|
|
/// after a rollback the same battle could otherwise be replayed for reward.
|
|
pub fn clear_transient(&mut self) {
|
|
self.location.clear();
|
|
self.stable = 0;
|
|
self.battle = None;
|
|
self.safe = false;
|
|
let keys: Vec<String> = self.wild_wins.keys().cloned().collect();
|
|
for key in keys {
|
|
self.replay_blocked.insert(&key);
|
|
}
|
|
}
|
|
|
|
/// Sample WRAM after one completed frame and return this frame's payouts.
|
|
pub fn sample(&mut self, source: &mut dyn MemoryReader, brain_ms: f64) -> Vec<RewardEvent> {
|
|
self.safe = false;
|
|
if self.rom_hash != SUPPORTED_ROM {
|
|
self.mode = "UNSUPPORTED ROM · SEMANTIC REWARDS OFF".to_string();
|
|
return Vec::new();
|
|
}
|
|
let mut cache = SampleCache { source, bytes: HashMap::new() };
|
|
let memory = &mut cache;
|
|
|
|
let active = memory.read8(ram::wStatusFlags6) & 1 != 0;
|
|
let map = memory.read8(ram::wCurMap);
|
|
let width = u32::from(memory.read8(ram::wCurMapWidth)) * 2;
|
|
let height = u32::from(memory.read8(ram::wCurMapHeight)) * 2;
|
|
let x = u32::from(memory.read8(ram::wXCoord));
|
|
let y = u32::from(memory.read8(ram::wYCoord));
|
|
|
|
if !active {
|
|
self.saw_boot = true;
|
|
self.mode = "BOOT".to_string();
|
|
self.stable = 0;
|
|
self.battle = None;
|
|
return Vec::new();
|
|
}
|
|
if map > 0xf7
|
|
|| width == 0
|
|
|| height == 0
|
|
|| x >= width
|
|
|| y >= height
|
|
|| memory.read8(ram::wPartyCount) > 6
|
|
{
|
|
self.mode = "TRANSITION".to_string();
|
|
self.stable = 0;
|
|
return Vec::new();
|
|
}
|
|
if memory.read8(ram::wBattleType) != 0 || memory.read8(ram::wStatusFlags7) & 1 != 0 {
|
|
self.mode = "DEMO / SAFARI".to_string();
|
|
self.battle = None;
|
|
self.stable = 0;
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut emitted: Vec<RewardEvent> = Vec::new();
|
|
let first = !self.initialized;
|
|
|
|
// Persistent bitsets, in pokered declaration order.
|
|
for (name, bit) in symbols::EVENTS {
|
|
if !flag(memory, bit) {
|
|
continue;
|
|
}
|
|
let kind =
|
|
if self.milestones.contains(name) { kind::MILESTONE } else { kind::TRAINER };
|
|
let label = name.strip_prefix("EVENT_").unwrap_or(name).replace('_', " ");
|
|
self.once(&mut emitted, name, kind, label, first, brain_ms);
|
|
}
|
|
for i in 0..151u16 {
|
|
if memory.read8(ram::wPokedexOwned + (i >> 3)) & (1 << (i & 7)) != 0 {
|
|
self.once(
|
|
&mut emitted,
|
|
&format!("dex:{i}"),
|
|
kind::SPECIES,
|
|
format!("OWNED #{}", i + 1),
|
|
first,
|
|
brain_ms,
|
|
);
|
|
}
|
|
}
|
|
for i in 0..8u32 {
|
|
if memory.read8(ram::wObtainedBadges) & (1 << i) != 0 {
|
|
self.once(
|
|
&mut emitted,
|
|
&format!("badge:{i}"),
|
|
kind::BADGE,
|
|
BADGE_NAMES[i as usize].to_string(),
|
|
first,
|
|
brain_ms,
|
|
);
|
|
}
|
|
}
|
|
if first {
|
|
self.seen.insert(&format!("map:{map}"));
|
|
// A migrated save already in an early area is a baseline, not a new
|
|
// arrival.
|
|
if map == REDS_HOUSE_1F {
|
|
self.seen.insert("early:downstairs");
|
|
}
|
|
if map == PALLET_TOWN {
|
|
self.seen.insert("early:outside");
|
|
}
|
|
if self.saw_boot && !self.seen.contains("adventure") {
|
|
self.emit(
|
|
&mut emitted,
|
|
kind::MILESTONE,
|
|
"ADVENTURE STARTED".to_string(),
|
|
1.0,
|
|
brain_ms,
|
|
);
|
|
}
|
|
self.seen.insert("adventure");
|
|
// The exits of wherever this sample happens to be, baselined for the same reason the
|
|
// map id above is: a restored save standing in a doorway has not just found it.
|
|
// `first` makes `boundary` write its ledger keys without paying for any of them.
|
|
self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms);
|
|
self.initialized = true;
|
|
}
|
|
|
|
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;
|
|
if self.battle.is_none() && in_battle != 255 {
|
|
self.battle = Some(Battle {
|
|
key: battle_key(memory, map),
|
|
wild: in_battle == 1,
|
|
saw_living: false,
|
|
ko: false,
|
|
});
|
|
}
|
|
if let Some(battle) = &mut self.battle {
|
|
let hp = word(memory, ram::wEnemyMonHP);
|
|
let max = word(memory, ram::wEnemyMonMaxHP);
|
|
if hp > 0 && hp <= max && max > 0 && max < 1000 {
|
|
battle.saw_living = true;
|
|
// Enemy identity isn't initialized at the first frame of
|
|
// battle entry.
|
|
battle.key = battle_key(memory, map);
|
|
}
|
|
if battle.saw_living && hp == 0 {
|
|
battle.ko = true;
|
|
}
|
|
}
|
|
} else if in_battle == 0 {
|
|
self.mode = "OVERWORLD".to_string();
|
|
if let Some(battle) = self.battle.take() {
|
|
let result = memory.read8(ram::wBattleResult);
|
|
if battle.wild && battle.ko && result == 0 {
|
|
let count = self.wild_wins.get(&battle.key).copied().unwrap_or(0);
|
|
if count < 3 && !self.replay_blocked.contains(&battle.key) {
|
|
self.emit(
|
|
&mut emitted,
|
|
kind::BATTLE,
|
|
format!("WILD KO {}", battle.key),
|
|
1.0 / (count as f64 + 1.0),
|
|
brain_ms,
|
|
);
|
|
}
|
|
self.wild_wins.insert(battle.key.clone(), (count + 1).min(3));
|
|
}
|
|
}
|
|
let location = format!("{map}:{x}:{y}");
|
|
self.stable = if self.location == location { self.stable + 1 } else { 1 };
|
|
self.location = location.clone();
|
|
// Short-circuits exactly as the TypeScript `||` chain did, so a
|
|
// scripted sample stops reading further gate bytes.
|
|
let scripted = memory.read8(ram::wStatusFlags5) & 0xa1 != 0
|
|
|| memory.read8(ram::wJoyIgnore) != 0
|
|
|| memory.read8(ram::wMovementFlags) & 0xc7 != 0
|
|
|| memory.read8(ram::wStatusFlags6) & 0x5c != 0;
|
|
if !scripted && self.stable >= 3 {
|
|
self.safe = memory.read8(ram::wFontLoaded) & 1 == 0;
|
|
if map == REDS_HOUSE_1F {
|
|
self.once(
|
|
&mut emitted,
|
|
"early:downstairs",
|
|
kind::MILESTONE,
|
|
"DOWNSTAIRS".to_string(),
|
|
first,
|
|
brain_ms,
|
|
);
|
|
}
|
|
if map == PALLET_TOWN {
|
|
self.once(
|
|
&mut emitted,
|
|
"early:outside",
|
|
kind::MILESTONE,
|
|
"OUTSIDE".to_string(),
|
|
first,
|
|
brain_ms,
|
|
);
|
|
}
|
|
self.once(
|
|
&mut emitted,
|
|
&format!("map:{map}"),
|
|
kind::MAP,
|
|
format!("AREA {map}"),
|
|
first,
|
|
brain_ms,
|
|
);
|
|
// After the ledger insert above, so arriving somewhere earns its rung on
|
|
// the same sample it is first recorded on.
|
|
self.badges =
|
|
self.badges.max(memory.read8(ram::wObtainedBadges).count_ones());
|
|
let hall_of_fame = memory.read8(ram::wNumHoFTeams) > 0;
|
|
self.progress = self.rank_from(self.badges, hall_of_fame);
|
|
if !self.tiles.contains(&location) {
|
|
self.tiles.insert(&location);
|
|
let count = self.tile_counts.get(&map).copied().unwrap_or(0) + 1;
|
|
self.tile_counts.insert(map, count);
|
|
if count % 8 == 0 && count <= 200 {
|
|
self.emit(
|
|
&mut emitted,
|
|
kind::EXPLORATION,
|
|
format!("AREA {map}: {count} UNIQUE LOCATIONS"),
|
|
1.0,
|
|
brain_ms,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// The boundary rule keeps its own gate, because the state it pays for is a state the
|
|
// gate above deliberately rejects. `wMovementFlags` bits 0, 1 and 2 are
|
|
// BIT_STANDING_ON_DOOR, BIT_EXITING_DOOR and BIT_STANDING_ON_WARP
|
|
// (`constants/ram_constants.asm` at [`symbols::POKERED_COMMIT`]), all three inside
|
|
// that mask's 0xc7 -- and standing on a door is precisely the thing
|
|
// `docs/design/room-escape.md` section 2 wants to pay for, so under the shared gate
|
|
// the on-exit half of the rule could never fire at all. Everything else the gate
|
|
// above rejects is rejected here too: a ledge hop or a spin tile (0xc0), an ignored
|
|
// joypad, and both status-flag masks. Nothing else in the adapter reads this gate, so
|
|
// no other payout, the `safe` flag included, changes behaviour.
|
|
if self.stable >= 3
|
|
&& memory.read8(ram::wStatusFlags5) & 0xa1 == 0
|
|
&& memory.read8(ram::wJoyIgnore) == 0
|
|
&& memory.read8(ram::wMovementFlags) & 0xc0 == 0
|
|
&& memory.read8(ram::wStatusFlags6) & 0x5c == 0
|
|
{
|
|
self.boundary(&mut emitted, memory, map, x, y, width, height, first, brain_ms);
|
|
}
|
|
}
|
|
|
|
// Newest first, capped at eight, as the prototype's
|
|
// `[...emitted, ...this.recent].slice(0, 8)` did.
|
|
let mut recent = emitted.clone();
|
|
recent.append(&mut self.recent);
|
|
recent.truncate(8);
|
|
self.recent = recent;
|
|
emitted
|
|
}
|
|
|
|
fn emit(
|
|
&mut self,
|
|
emitted: &mut Vec<RewardEvent>,
|
|
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);
|
|
}
|
|
|
|
fn once(
|
|
&mut self,
|
|
emitted: &mut Vec<RewardEvent>,
|
|
key: &str,
|
|
kind: &'static str,
|
|
label: String,
|
|
first: bool,
|
|
brain_ms: f64,
|
|
) {
|
|
self.once_scaled(emitted, key, kind, label, 1.0, first, brain_ms);
|
|
}
|
|
|
|
/// [`PokemonRedReward::once`] with a multiplier on the catalog value, for a rule whose one
|
|
/// kind pays two amounts.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn once_scaled(
|
|
&mut self,
|
|
emitted: &mut Vec<RewardEvent>,
|
|
key: &str,
|
|
kind: &'static str,
|
|
label: String,
|
|
scale: f64,
|
|
first: bool,
|
|
brain_ms: f64,
|
|
) {
|
|
if self.seen.contains(key) {
|
|
return;
|
|
}
|
|
self.seen.insert(key);
|
|
if !first {
|
|
self.emit(emitted, kind, label, scale, brain_ms);
|
|
}
|
|
}
|
|
|
|
/// Pay the first step next to, and the first step onto, each of this map's exits.
|
|
///
|
|
/// `docs/design/room-escape.md` section 2. Exits come from two places in WRAM, both verified
|
|
/// against the disassembly at [`symbols::POKERED_COMMIT`]:
|
|
///
|
|
/// - **the warp table.** `wNumberOfWarps` entries of [`WARP_ENTRY_BYTES`] at
|
|
/// `wWarpEntries`, each `Y, X, destination warp id, destination map id`
|
|
/// (`ram/wram.asm`'s own comment). `CheckWarpsCollision` and `CheckWarpsNoCollision`
|
|
/// compare those Y and X against `wYCoord` and `wXCoord` with no conversion, so warp
|
|
/// coordinates are in exactly the tile space this adapter already samples. Doors, stairs,
|
|
/// cave mouths and building entrances are all warps.
|
|
/// - **the connected edges.** `wCurMapConnections` is a bitmask over
|
|
/// [`connection`]. The crossing itself happens one step *outside* the map --
|
|
/// `CheckMapConnections` fires on `wXCoord == $ff` going west and on
|
|
/// `wXCoord == wCurrentMapWidth2` going east, and `wCurrentMapWidth2` is `wCurMapWidth`
|
|
/// doubled, which is this function's `width` -- so the exit tiles are column 0, column
|
|
/// `width - 1`, row 0 and row `height - 1`, and no further address is needed to find them.
|
|
///
|
|
/// Adjacent means the four-neighbourhood, Manhattan distance 1. Each `(map, exit)` pays its
|
|
/// adjacent tiles once and its own tile once, for the lifetime of the `seen` ledger, so
|
|
/// stepping on and off a doormat earns nothing the second time and a rollback cannot replay it
|
|
/// (`clear_transient` does not touch the ledger).
|
|
///
|
|
/// Consequences worth stating rather than discovering:
|
|
///
|
|
/// - arriving on a map *through* an exit lands on that exit, so the arrival pays it: on a
|
|
/// connection it is the connection's own edge row, and on a warp it is the warp tile the
|
|
/// destination put the player on. Measured on the live release run (`infra/docs/
|
|
/// room-escape.md`): the ledger holds `boundary:37:1:7:on` and `boundary:38:1:7:on`, both
|
|
/// halves of Red's staircase, paid for arriving down and up it rather than for finding it.
|
|
/// That is one payout of 0.10 per exit in a lifetime, not a farm, but it is not a discovery
|
|
/// either.
|
|
/// - the adapter's first playable sample baselines the exits at *that* position, the way it
|
|
/// baselines the map id, so a restored save standing in a doorway is not paid for where it
|
|
/// already is. Every other exit on the map is still there to be found.
|
|
/// - a warp that fires on the step onto it can have its on-exit half go unobserved in the
|
|
/// *outbound* direction, because the cartridge overwrites the coordinates as part of the
|
|
/// warp. Doors and map edges are ordinary standable tiles and pay both halves outbound.
|
|
///
|
|
/// 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.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn boundary(
|
|
&mut self,
|
|
emitted: &mut Vec<RewardEvent>,
|
|
memory: &mut impl MemoryReader,
|
|
map: u8,
|
|
x: u32,
|
|
y: u32,
|
|
width: u32,
|
|
height: u32,
|
|
first: bool,
|
|
brain_ms: f64,
|
|
) {
|
|
// 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 warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS);
|
|
for index in 0..u16::from(warps) {
|
|
let entry = ram::wWarpEntries + index * WARP_ENTRY_BYTES;
|
|
let warp_y = u32::from(memory.read8(entry));
|
|
let warp_x = u32::from(memory.read8(entry + 1));
|
|
let distance = x.abs_diff(warp_x) + y.abs_diff(warp_y);
|
|
if distance <= 1 {
|
|
hits.push((format!("boundary:{map}:{warp_y}:{warp_x}"), distance == 0));
|
|
}
|
|
}
|
|
|
|
let connections = memory.read8(ram::wCurMapConnections);
|
|
// (bit, key suffix, the exit row or column, this player's coordinate along that axis).
|
|
let edges = [
|
|
(connection::EAST, 'e', width - 1, x),
|
|
(connection::WEST, 'w', 0, x),
|
|
(connection::SOUTH, 's', height - 1, y),
|
|
(connection::NORTH, 'n', 0, y),
|
|
];
|
|
for (bit, name, edge, position) in edges {
|
|
if connections & bit == 0 {
|
|
continue;
|
|
}
|
|
let distance = position.abs_diff(edge);
|
|
if distance <= 1 {
|
|
hits.push((format!("boundary:{map}:edge:{name}"), distance == 0));
|
|
}
|
|
}
|
|
|
|
for (key, on_exit) in hits {
|
|
if first {
|
|
// Baseline *both* halves of every exit already within reach: a save restored in a
|
|
// doorway has found neither the door nor the tile beside it, and crediting one
|
|
// half would pay the other for a single step sideways.
|
|
self.seen.insert(&format!("{key}:near"));
|
|
self.seen.insert(&format!("{key}:on"));
|
|
continue;
|
|
}
|
|
let key = format!("{key}:{}", if on_exit { "on" } else { "near" });
|
|
self.once_scaled(
|
|
emitted,
|
|
&key,
|
|
kind::BOUNDARY,
|
|
BOUNDARY_LABEL.to_string(),
|
|
if on_exit { 2.0 } else { 1.0 },
|
|
false,
|
|
brain_ms,
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn export_state(&self) -> Value {
|
|
json!({
|
|
"version": STATE_VERSION,
|
|
"seen": self.seen.as_slice(),
|
|
"tiles": self.tiles.as_slice(),
|
|
"tileCounts": self.tile_counts,
|
|
"wildWins": self.wild_wins,
|
|
"replayBlocked": self.replay_blocked.as_slice(),
|
|
"counts": self.counts,
|
|
"total": self.total,
|
|
"recent": self.recent,
|
|
"last": self.last,
|
|
"initialized": self.initialized,
|
|
"sawBoot": self.saw_boot,
|
|
"location": self.location,
|
|
"stable": self.stable,
|
|
// Both are derived from the cartridge, but only inside the unscripted-overworld
|
|
// branch of `observe`, so a restore that lands in a battle or a script would
|
|
// otherwise report rank 0 and 0 badges until the fly next stands still outdoors —
|
|
// minutes, not frames. They are cheap to carry and self-correcting once sampled.
|
|
"progress": self.progress,
|
|
"badges": self.badges,
|
|
"battle": self.battle.as_ref().map(|battle| json!({
|
|
"key": battle.key,
|
|
"wild": battle.wild,
|
|
"sawLiving": battle.saw_living,
|
|
"ko": battle.ko,
|
|
})),
|
|
"mode": self.mode,
|
|
})
|
|
}
|
|
|
|
/// Restore lifetime history from [`PokemonRedReward::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
|
|
/// version-3 state is an error, with the prototype's three distinct
|
|
/// messages preserved so a failed restore reads the same in the log.
|
|
///
|
|
/// One deliberate difference: everything is validated before anything is
|
|
/// assigned. The prototype validated its replay ledger after it had already
|
|
/// overwritten `seen` and `tiles`, so a bad ledger left the adapter half
|
|
/// mutated. Nothing depended on that, and a failed restore is safer whole.
|
|
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");
|
|
const BAD_LEDGER: AdapterError = AdapterError("Invalid replay ledger");
|
|
|
|
let seen = string_array(input.get("seen")).ok_or(BAD_CHECKPOINT)?;
|
|
let tiles = string_array(input.get("tiles")).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 stable = input.get("stable").and_then(Value::as_u64).ok_or(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 location = input.get("location").and_then(Value::as_str).ok_or(BAD_CHECKPOINT)?;
|
|
let mode = input.get("mode").and_then(Value::as_str).ok_or(BAD_CHECKPOINT)?;
|
|
let recent_raw = input.get("recent").and_then(Value::as_array).ok_or(BAD_CHECKPOINT)?;
|
|
// Optional: a state written before these were carried restores at 0 and re-derives them
|
|
// at the next unscripted overworld sample, which is the old behaviour.
|
|
let progress = input.get("progress").and_then(Value::as_u64).unwrap_or(0) as u32;
|
|
let badges = input.get("badges").and_then(Value::as_u64).unwrap_or(0) as u32;
|
|
|
|
let counts_raw = counted_record(input.get("counts")).ok_or(BAD_CHECKPOINT)?;
|
|
let tile_counts_raw = counted_record(input.get("tileCounts")).ok_or(BAD_CHECKPOINT)?;
|
|
let wild_wins_raw = counted_record(input.get("wildWins")).ok_or(BAD_CHECKPOINT)?;
|
|
|
|
let recent = recent_raw
|
|
.iter()
|
|
.map(parse_event)
|
|
.collect::<Option<Vec<_>>>()
|
|
.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);
|
|
}
|
|
}
|
|
let battle = match input.get("battle") {
|
|
None | Some(Value::Null) => None,
|
|
Some(value) => Some(Battle {
|
|
key: value.get("key").and_then(Value::as_str).ok_or(BAD_HISTORY)?.to_string(),
|
|
wild: value.get("wild").and_then(Value::as_bool).ok_or(BAD_HISTORY)?,
|
|
saw_living: value.get("sawLiving").and_then(Value::as_bool).ok_or(BAD_HISTORY)?,
|
|
ko: value.get("ko").and_then(Value::as_bool).ok_or(BAD_HISTORY)?,
|
|
}),
|
|
};
|
|
let replay_blocked = match input.get("replayBlocked") {
|
|
None | Some(Value::Null) => Vec::new(),
|
|
Some(value) => string_array(Some(value)).ok_or(BAD_LEDGER)?,
|
|
};
|
|
|
|
let mut tile_counts = BTreeMap::new();
|
|
for (key, count) in &tile_counts_raw {
|
|
let map: u8 = key.parse().map_err(|_| BAD_CHECKPOINT)?;
|
|
tile_counts.insert(map, *count);
|
|
}
|
|
|
|
self.seen = seen.iter().map(String::as_str).collect();
|
|
self.tiles = tiles.iter().map(String::as_str).collect();
|
|
// Both keys are aliases of the same arrival, and only one of them was
|
|
// recorded by older builds.
|
|
if self.seen.contains("map:37") {
|
|
self.seen.insert("early:downstairs");
|
|
}
|
|
if self.seen.contains("map:0") {
|
|
self.seen.insert("early:outside");
|
|
}
|
|
self.tile_counts = tile_counts;
|
|
self.wild_wins = wild_wins_raw;
|
|
self.replay_blocked = replay_blocked.iter().map(String::as_str).collect();
|
|
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.location = location.to_string();
|
|
self.stable = stable;
|
|
self.battle = battle;
|
|
self.mode = mode.to_string();
|
|
self.progress = progress;
|
|
self.badges = badges;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn battle_key(memory: &mut impl MemoryReader, map: u8) -> String {
|
|
format!(
|
|
"{}:{}:{}",
|
|
map,
|
|
memory.read8(ram::wEnemyMonSpecies),
|
|
memory.read8(ram::wEnemyMonLevel)
|
|
)
|
|
}
|
|
|
|
/// An array whose every element is a string, or `None`.
|
|
fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
|
|
let array = value?.as_array()?;
|
|
array.iter().map(|item| item.as_str().map(str::to_string)).collect()
|
|
}
|
|
|
|
/// `Number.MAX_SAFE_INTEGER`, so a checkpoint the TypeScript build could not
|
|
/// have written is rejected here too.
|
|
const MAX_SAFE_INTEGER: u64 = (1u64 << 53) - 1;
|
|
|
|
/// An object whose every value is a non-negative safe integer, or `None`.
|
|
/// The port of `Object.values(record).some(v => !Number.isSafeInteger(v) || v < 0)`.
|
|
fn counted_record(value: Option<&Value>) -> Option<BTreeMap<String, u64>> {
|
|
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()
|
|
}
|
|
|
|
fn parse_event(value: &Value) -> Option<RewardEvent> {
|
|
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 PokemonRedReward {
|
|
fn id(&self) -> &'static str {
|
|
REWARD_ADAPTER
|
|
}
|
|
|
|
fn rom_allowed(&self, sha256: &str) -> bool {
|
|
sha256 == SUPPORTED_ROM
|
|
}
|
|
|
|
fn sample(&mut self, memory: &mut dyn MemoryReader, ms: f64) -> Vec<RewardEvent> {
|
|
PokemonRedReward::sample(self, memory, ms)
|
|
}
|
|
|
|
fn mode(&self) -> &str {
|
|
&self.mode
|
|
}
|
|
|
|
fn map_id(&self) -> Option<u32> {
|
|
PokemonRedReward::map(self)
|
|
}
|
|
|
|
fn location(&self) -> Option<(u32, u32, u32)> {
|
|
PokemonRedReward::area_and_tile(self)
|
|
}
|
|
|
|
fn progress(&self) -> ProgressSnapshot {
|
|
ProgressSnapshot {
|
|
rank: self.progress,
|
|
rank_max: RANK_LADDER.len() as u32 - 1,
|
|
rank_label: RANK_LADDER[(self.progress as usize).min(RANK_LADDER.len() - 1)],
|
|
counter: self.badges,
|
|
counter_label: "BADGES",
|
|
unique_locations: self.tiles.len(),
|
|
reward_total: self.total,
|
|
counts: self.counts.to_map(),
|
|
}
|
|
}
|
|
|
|
fn safe_for_snapshot(&self) -> bool {
|
|
self.safe
|
|
}
|
|
|
|
/// Whether the `boundary` ledger has already recorded this exit.
|
|
///
|
|
/// The keys are [`PokemonRedReward::boundary`]'s own, built by the same `format!` and read out
|
|
/// of the same lifetime `seen` set, so "visited" here means precisely what the reward rule
|
|
/// means by it and the two cannot drift: `boundary:<map>:<y>:<x>` for a warp and
|
|
/// `boundary:<map>:edge:<n|s|e|w>` for a map edge, each with an `:on` and a `:near` half.
|
|
///
|
|
/// **Either half counts**, and that is the semantics rather than a convenience. `:on` is what
|
|
/// an interior warp records — arriving through a staircase lands on it, which is why the live
|
|
/// run's ledger holds both halves of Red's — but a town door fires on the step *onto* it, so
|
|
/// `sample` never sees a stable frame there and the `:on` half is unobservable in both
|
|
/// directions. On `:on` alone every house door in the game would read as unvisited forever,
|
|
/// which is the behaviour this accessor exists to fix. So the question it answers is "has this
|
|
/// run been paid for finding this exit" — found, not used — and that is what the "unvisited"
|
|
/// in `GO OUT`, `GO WARP` and `GO ROUTE` is asking.
|
|
///
|
|
/// One consequence worth stating: the adapter baselines both halves of every exit within one
|
|
/// tile of the player on its first playable sample, so a run restored standing in a doorway
|
|
/// starts with that one door already recorded. That is the same baseline the reward rule uses,
|
|
/// and it is why `palette::ways` falls back to every exit when the unvisited set is empty
|
|
/// rather than binding nothing.
|
|
fn exit_visited(&self, exit: MapExit) -> bool {
|
|
let key = match exit {
|
|
MapExit::Warp { map, x, y } => format!("boundary:{map}:{y}:{x}"),
|
|
MapExit::Edge { map, edge } => {
|
|
let name = match edge {
|
|
MapEdge::North => 'n',
|
|
MapEdge::South => 's',
|
|
MapEdge::East => 'e',
|
|
MapEdge::West => 'w',
|
|
};
|
|
format!("boundary:{map}:edge:{name}")
|
|
}
|
|
};
|
|
self.seen.contains(&format!("{key}:on")) || self.seen.contains(&format!("{key}:near"))
|
|
}
|
|
|
|
/// Whether this run has stood on `tile`, from the `exploration` rule's own ledger.
|
|
///
|
|
/// The same read accessor shape as [`GameAdapter::exit_visited`], over the same `OrderedSet`
|
|
/// the `exploration` payout counts: `sample` inserts `"{map}:{x}:{y}"` for every distinct
|
|
/// player coordinate on a playable frame, lifetime, surviving `clear_transient` and round-
|
|
/// tripping through the checkpoint. So "never stood on" is answerable without one byte of new
|
|
/// state, and `GO FRONTIER` is its only caller.
|
|
///
|
|
/// One consequence worth naming: the ledger is *lifetime*, so a restored run starts with the
|
|
/// ground it has already covered marked. That is the honest answer -- this run has stood
|
|
/// there -- and it is why the frontier moves outward over a run instead of resetting.
|
|
fn tile_visited(&self, tile: MapTile) -> bool {
|
|
self.tiles.contains(&format!("{}:{}:{}", tile.map, tile.x, tile.y))
|
|
}
|
|
|
|
/// Whether this run has ever been on `map`, from the same lifetime ledger the `map` payout
|
|
/// and the ladder's map rungs read (`"map:{id}"`).
|
|
fn map_visited(&self, map: u8) -> bool {
|
|
self.seen.contains(&format!("map:{map}"))
|
|
}
|
|
|
|
/// The place of the lowest rung this run has not earned, when [`RUNG_PLACES`] knows one.
|
|
///
|
|
/// [`PokemonRedReward::next_unreached`] rather than `rank + 1`, and its doc comment has the
|
|
/// save that made the difference matter. Past the top of the ladder there is no next rung and
|
|
/// this is `None`.
|
|
fn objective(&self) -> Option<MapPlace> {
|
|
// `hall_of_fame` is a WRAM byte and this accessor reads no memory, so the champion rung is
|
|
// asked from the ledger alone (`EVENT_BEAT_CHAMPION_RIVAL`, the other half of its own
|
|
// condition). It can only matter once every other rung is earned, and that rung's place is
|
|
// the one the catalog does not know.
|
|
let next = self.next_unreached(self.badges, false)?;
|
|
rung_place(&self.seen, next)
|
|
}
|
|
|
|
fn next_rung(&self) -> Option<u32> {
|
|
self.next_unreached(self.badges, false).and_then(|rung| u32::try_from(rung).ok())
|
|
}
|
|
|
|
fn rank_ladder(&self) -> &'static [&'static str] {
|
|
&RANK_LADDER
|
|
}
|
|
|
|
fn decoder_preset(&self) -> DecoderPresetId {
|
|
DecoderPresetId::GameBoy
|
|
}
|
|
|
|
fn clear_transient(&mut self) {
|
|
PokemonRedReward::clear_transient(self);
|
|
}
|
|
|
|
fn export_state(&self) -> Value {
|
|
PokemonRedReward::export_state(self)
|
|
}
|
|
|
|
fn import_state(&mut self, state: &Value) -> Result<(), AdapterError> {
|
|
PokemonRedReward::import_state(self, state)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|