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.
557 lines
22 KiB
Rust
557 lines
22 KiB
Rust
//! Integration tests that need a real cartridge.
|
|
//!
|
|
//! Gated on `FLY_ROM` pointing at a Game Boy ROM file. The ROM never enters
|
|
//! this repository (`.gitignore` excludes `*.gb`), so these skip cleanly when
|
|
//! the variable is unset:
|
|
//!
|
|
//! ```sh
|
|
//! FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
//! cargo test --release --test rom -- --nocapture
|
|
//! ```
|
|
//!
|
|
//! `FLY_CHECKPOINT` optionally points at a prototype `.checkpoint` file, which
|
|
//! probes whether a save state written by the Emscripten build imports into the
|
|
//! native build. See the crate README, "State format".
|
|
|
|
use std::time::Instant;
|
|
|
|
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, engage};
|
|
use flybrain_gb::{
|
|
DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter,
|
|
buttons,
|
|
};
|
|
|
|
/// The ROM under test, or `None` to skip.
|
|
fn rom() -> Option<Vec<u8>> {
|
|
let path = std::env::var_os("FLY_ROM")?;
|
|
match std::fs::read(&path) {
|
|
Ok(bytes) => Some(bytes),
|
|
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
|
|
}
|
|
}
|
|
|
|
fn boot() -> Option<Emulator> {
|
|
let rom = rom()?;
|
|
Some(
|
|
Emulator::new(&rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
|
.expect("binjgb should accept the ROM"),
|
|
)
|
|
}
|
|
|
|
macro_rules! skip_without_rom {
|
|
($emulator:expr) => {
|
|
match $emulator {
|
|
Some(emulator) => emulator,
|
|
None => {
|
|
eprintln!("skipped: FLY_ROM is not set");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
fn six_hundred_idle_frames_produce_moving_video_and_continuous_audio() {
|
|
let mut emulator = skip_without_rom!(boot());
|
|
emulator.set_buttons(buttons::NONE);
|
|
|
|
let first = emulator.framebuffer().to_vec();
|
|
assert_eq!(first.len(), FRAMEBUFFER_LEN);
|
|
|
|
let mut distinct = std::collections::HashSet::new();
|
|
let mut raw_min = u8::MAX;
|
|
let mut raw_max = u8::MIN;
|
|
let mut raw_total: u64 = 0;
|
|
let mut raw_count: u64 = 0;
|
|
let mut samples = 0usize;
|
|
let mut silent_frames = 0usize;
|
|
let ticks_before = emulator.ticks();
|
|
|
|
for frame in 0..600 {
|
|
emulator.run_frame().expect("a frame should complete");
|
|
distinct.insert(emulator.framebuffer().to_vec());
|
|
|
|
let raw = emulator.take_audio_u8();
|
|
if raw.is_empty() {
|
|
silent_frames += 1;
|
|
}
|
|
assert!(raw.len() % 2 == 0, "frame {frame}: audio must be stereo pairs");
|
|
for byte in &raw {
|
|
raw_min = raw_min.min(*byte);
|
|
raw_max = raw_max.max(*byte);
|
|
raw_total += u64::from(*byte);
|
|
raw_count += 1;
|
|
}
|
|
samples += raw.len();
|
|
}
|
|
|
|
assert!(
|
|
distinct.len() > 1,
|
|
"the framebuffer never changed over 600 frames: the LCD is stuck"
|
|
);
|
|
assert_ne!(emulator.framebuffer(), first.as_slice());
|
|
|
|
// Audio is produced per emulated tick, not per run_frame call, and a
|
|
// run_frame during the boot interval where the LCD is off consumes several
|
|
// frame periods. Expect samples for the ticks actually emulated.
|
|
let ticks = (emulator.ticks() - ticks_before) as f64;
|
|
let expected = ticks * f64::from(DEFAULT_AUDIO_FREQUENCY) * 2.0 / CPU_TICKS_PER_SECOND as f64;
|
|
let drift = (samples as f64 - expected).abs();
|
|
assert!(
|
|
drift < expected * 0.001,
|
|
"audio is {samples} samples against {expected:.0} expected over {ticks:.0} ticks: \
|
|
the buffer is dropping blocks"
|
|
);
|
|
assert_eq!(silent_frames, 0, "every frame must yield some audio");
|
|
// One Game Boy frame is 70,224 ticks of a 4,194,304 Hz clock, which at
|
|
// 48 kHz is 803.6 stereo frames, 1,607.2 interleaved samples.
|
|
assert!(
|
|
samples > 600 * 1_600,
|
|
"600 frames should carry at least 600 frames' worth of audio"
|
|
);
|
|
|
|
eprintln!(
|
|
"audio: {samples} interleaved u8 samples over 600 frames and {ticks:.0} ticks \
|
|
({expected:.0} expected, {:.1} per frame), raw range {raw_min}..={raw_max}, \
|
|
mean {:.1}, assumed silence level {AUDIO_SILENCE_LEVEL}",
|
|
samples as f64 / 600.0,
|
|
raw_total as f64 / raw_count as f64
|
|
);
|
|
eprintln!("video: {} distinct framebuffers over 600 frames", distinct.len());
|
|
}
|
|
|
|
#[test]
|
|
fn a_state_round_trip_replays_to_an_identical_framebuffer() {
|
|
let mut emulator = skip_without_rom!(boot());
|
|
for _ in 0..600 {
|
|
emulator.run_frame().unwrap();
|
|
}
|
|
|
|
let state = emulator.export_state().expect("state export");
|
|
assert_eq!(state.len(), Emulator::state_size());
|
|
let at_export = emulator.framebuffer().to_vec();
|
|
emulator.take_audio_u8();
|
|
|
|
for _ in 0..60 {
|
|
emulator.run_frame().unwrap();
|
|
}
|
|
let reference = emulator.framebuffer().to_vec();
|
|
let reference_audio = emulator.take_audio_u8();
|
|
assert_ne!(reference, at_export, "60 frames should change the picture");
|
|
|
|
emulator.import_state(&state).expect("state import");
|
|
// EmulatorState excludes the framebuffer, so the restored emulator still
|
|
// shows the frame it was on until the next frame completes.
|
|
emulator.take_audio_u8();
|
|
for _ in 0..60 {
|
|
emulator.run_frame().unwrap();
|
|
}
|
|
assert_eq!(
|
|
emulator.framebuffer(),
|
|
reference.as_slice(),
|
|
"a restored state must replay to the same picture"
|
|
);
|
|
// Audio is NOT bit-identical across a restore, and cannot be: the
|
|
// resampler's phase (AudioBuffer.freq_counter and .divisor) lives outside
|
|
// EmulatorState, so emulator_read_state does not restore it. The block
|
|
// length stays within a sample or two of the reference.
|
|
let restored_audio = emulator.take_audio_u8();
|
|
let difference = restored_audio.len().abs_diff(reference_audio.len());
|
|
assert!(
|
|
difference <= 2,
|
|
"restored audio is {} samples against {} in the reference",
|
|
restored_audio.len(),
|
|
reference_audio.len()
|
|
);
|
|
if restored_audio != reference_audio {
|
|
eprintln!(
|
|
"state: audio differs after restore by {difference} samples of length \
|
|
(the resampler phase is outside EmulatorState)"
|
|
);
|
|
}
|
|
|
|
let mut truncated = state.clone();
|
|
truncated.pop();
|
|
assert!(emulator.import_state(&truncated).is_err(), "a short state is refused");
|
|
|
|
eprintln!("state: {} bytes, round trip identical", state.len());
|
|
}
|
|
|
|
#[test]
|
|
fn the_reward_adapter_leaves_boot_once_the_game_starts() {
|
|
let mut emulator = skip_without_rom!(boot());
|
|
assert_eq!(
|
|
emulator.rom_sha256(),
|
|
SUPPORTED_ROM,
|
|
"FLY_ROM is not the cartridge the adapter is pinned to; \
|
|
semantic rewards would be disabled"
|
|
);
|
|
|
|
let mut adapter = PokemonRedReward::new();
|
|
let mut ms = 0.0;
|
|
|
|
// Hold Start, then A, on a slow alternation: the title screen wants Start,
|
|
// the menu and Oak's introduction want A, and a held button is ignored, so
|
|
// each press has to be released. 4,000 frames is about 67 seconds of game
|
|
// time, enough for the intro and the naming screens' default answers.
|
|
//
|
|
// Note that a cold boot's WRAM is pseudorandom (binjgb seeds it from
|
|
// EmulatorInit.random_seed), so the very first samples can read a garbage
|
|
// "game timer active" bit and report TRANSITION before the game clears
|
|
// WRAM. The assertion is therefore that BOOT is the settled early mode,
|
|
// not that frame 1 is BOOT.
|
|
const FRAMES: u32 = 4_000;
|
|
let mut timeline: Vec<(u32, String)> = Vec::new();
|
|
let mut last_boot = None;
|
|
let mut events = 0usize;
|
|
for frame in 0..FRAMES {
|
|
let mask = match frame % 32 {
|
|
0..=7 => buttons::START,
|
|
16..=23 => buttons::A,
|
|
_ => buttons::NONE,
|
|
};
|
|
emulator.set_buttons(mask);
|
|
emulator.run_frame().unwrap();
|
|
ms += 1000.0 / 59.7275;
|
|
events += adapter.sample(&mut emulator, ms).len();
|
|
if adapter.mode() == "BOOT" {
|
|
last_boot = Some(frame);
|
|
}
|
|
if timeline.last().map(|(_, mode)| mode.as_str()) != Some(adapter.mode()) {
|
|
timeline.push((frame, adapter.mode().to_string()));
|
|
}
|
|
}
|
|
|
|
eprintln!("reward: mode timeline (frame, mode) = {timeline:?}");
|
|
let last_boot = last_boot.expect("the adapter never reported BOOT");
|
|
assert!(
|
|
last_boot < FRAMES - 1,
|
|
"still in BOOT at frame {last_boot}: the game never started"
|
|
);
|
|
assert_ne!(adapter.mode(), "BOOT", "the run should end out of BOOT");
|
|
eprintln!("reward: left BOOT for good at frame {}, {events} payouts", last_boot + 1);
|
|
|
|
let progress = GameAdapter::progress(&adapter);
|
|
eprintln!(
|
|
"reward: rank {} ({}), {} unique locations, total {:.3}, counts {:?}",
|
|
progress.rank, progress.rank_label, progress.unique_locations, progress.reward_total,
|
|
progress.counts
|
|
);
|
|
// The intro ends standing in Red's bedroom, which is rung 1 of the 38-rung
|
|
// ladder (`docs/design/ladder.md`). The first playable sample baselines
|
|
// REDS_HOUSE_2F into the lifetime map ledger, so this is the ladder's floor
|
|
// for a real cold boot, not something the fly had to earn.
|
|
assert_eq!(progress.rank, 1, "the intro should end in the bedroom");
|
|
assert_eq!(progress.rank_label, "BEDROOM");
|
|
|
|
// Now walk out of the house, which is rung 2 (REDS_HOUSE_1F) and then rung 3
|
|
// (PALLET_TOWN). Both are map rungs, so what is being tested is the whole path
|
|
// from a real cartridge: the map id enters the adapter's lifetime ledger only
|
|
// from an unscripted, stable, overworld sample, and the rung follows from the
|
|
// ledger.
|
|
//
|
|
// The route is a fixed-seed random walk rather than steering, because steering
|
|
// needs a collision map to be any good and the house has furniture in it: the
|
|
// player spawns at (3, 6) in the bedroom with (3, 5) blocked, and the ground
|
|
// floor's table blocks the straight line from the stairs at (7, 1) to the front
|
|
// door at (2, 7). Both were measured stalling for the whole budget. A random
|
|
// walk needs no map knowledge, is exactly as deterministic (the PRNG is seeded
|
|
// and binjgb is deterministic), and clears the house in about 12,000 frames.
|
|
//
|
|
// Buttons are pulsed rather than held because a held button is ignored. B is
|
|
// pulsed too: the intro's A-mashing leaves a text box open, and until it is
|
|
// dismissed the player cannot move at all (measured: 3,000 frames of pure
|
|
// directions never left (3, 6) while `wFontLoaded` stayed set). B is the right
|
|
// button for it -- it advances text but, unlike A, never starts a new
|
|
// conversation with whatever the player happens to be facing.
|
|
const WALK_FRAMES: u32 = 24_000;
|
|
let mut seed: u64 = 0x5eed_1234_5678_9abc;
|
|
let mut maps: Vec<u32> = Vec::new();
|
|
let mut climbed: Vec<(u32, &'static str)> = Vec::new();
|
|
let mut best = progress.rank;
|
|
for frame in 0..WALK_FRAMES {
|
|
seed = seed
|
|
.wrapping_mul(6_364_136_223_846_793_005)
|
|
.wrapping_add(1_442_695_040_888_963_407);
|
|
let step = [buttons::UP, buttons::DOWN, buttons::LEFT, buttons::RIGHT]
|
|
[((seed >> 33) % 4) as usize];
|
|
let mask = match frame % 24 {
|
|
0..=7 => step,
|
|
16..=19 => buttons::B,
|
|
_ => buttons::NONE,
|
|
};
|
|
emulator.set_buttons(mask);
|
|
emulator.run_frame().unwrap();
|
|
ms += 1000.0 / 59.7275;
|
|
adapter.sample(&mut emulator, ms);
|
|
if let Some(map) = adapter.map()
|
|
&& maps.last() != Some(&map)
|
|
{
|
|
maps.push(map);
|
|
}
|
|
let progress = GameAdapter::progress(&adapter);
|
|
assert!(
|
|
progress.rank >= best,
|
|
"rank fell from {best} to {} at frame {frame}",
|
|
progress.rank
|
|
);
|
|
if progress.rank > best {
|
|
climbed.push((progress.rank, progress.rank_label));
|
|
}
|
|
best = progress.rank;
|
|
}
|
|
|
|
let progress = GameAdapter::progress(&adapter);
|
|
eprintln!(
|
|
"reward: after {WALK_FRAMES} walking frames, rank {} ({}), maps {maps:?}, \
|
|
climbed {climbed:?}",
|
|
progress.rank, progress.rank_label
|
|
);
|
|
assert!(
|
|
climbed.len() >= 2,
|
|
"walking out of the house should climb at least two rungs, got {climbed:?} \
|
|
having visited maps {maps:?}"
|
|
);
|
|
assert_eq!(
|
|
&climbed[..2],
|
|
&[(2, "DOWNSTAIRS"), (3, "PALLET TOWN")],
|
|
"the first two rungs off the bedroom are the ground floor and Pallet Town"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_emulator_alone_runs_at_a_reported_frame_rate() {
|
|
let mut emulator = skip_without_rom!(boot());
|
|
// Warm up past the boot sequence so the measurement covers real rendering.
|
|
for _ in 0..600 {
|
|
emulator.run_frame().unwrap();
|
|
emulator.take_audio_u8();
|
|
}
|
|
|
|
const FRAMES: u32 = 6_000;
|
|
let start = Instant::now();
|
|
for _ in 0..FRAMES {
|
|
emulator.run_frame().unwrap();
|
|
emulator.take_audio_u8();
|
|
}
|
|
let elapsed = start.elapsed();
|
|
let fps = f64::from(FRAMES) / elapsed.as_secs_f64();
|
|
eprintln!(
|
|
"emulator: {fps:.0} fps over {FRAMES} frames in {:.3} s ({:.1}x real time at 59.7275 fps){}",
|
|
elapsed.as_secs_f64(),
|
|
fps / 59.7275,
|
|
if cfg!(debug_assertions) { ", DEBUG BUILD" } else { ", release" },
|
|
);
|
|
assert!(fps > 30.0, "{fps:.0} fps is too slow to be believable");
|
|
}
|
|
|
|
#[test]
|
|
fn reading_wram_is_cached_within_a_frame_and_refreshed_across_frames() {
|
|
let mut emulator = skip_without_rom!(boot());
|
|
for _ in 0..600 {
|
|
emulator.run_frame().unwrap();
|
|
}
|
|
// 0xff04 is DIV, which increments every 256 ticks and so advances by about
|
|
// 274 over one frame: it must look constant within a frame and different
|
|
// across one.
|
|
const DIV: u16 = 0xff04;
|
|
let first = emulator.read_wram(DIV);
|
|
for _ in 0..1_000 {
|
|
assert_eq!(emulator.read_wram(DIV), first);
|
|
}
|
|
assert_eq!(emulator.read8(DIV), first, "the MemoryReader impl shares the cache");
|
|
|
|
let mut changed = false;
|
|
for _ in 0..8 {
|
|
emulator.run_frame().unwrap();
|
|
if emulator.read_wram(DIV) != first {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(changed, "the cache is not cleared at a frame boundary");
|
|
}
|
|
|
|
/// Probe: can a save state the Emscripten build wrote be imported here?
|
|
///
|
|
/// `emulator_write_state` memcpy's binjgb's `EmulatorState`, so this answers a
|
|
/// question about struct layout, not about binjgb's format. Reports and skips
|
|
/// rather than failing: an incompatible answer is expected and is what the
|
|
/// `statefmt:` compatibility segment exists for.
|
|
#[test]
|
|
fn a_prototype_wasm_checkpoint_state_is_probed_for_importability() {
|
|
let mut emulator = skip_without_rom!(boot());
|
|
let Some(path) = std::env::var_os("FLY_CHECKPOINT") else {
|
|
eprintln!("skipped: FLY_CHECKPOINT is not set");
|
|
return;
|
|
};
|
|
let bytes = std::fs::read(&path).expect("FLY_CHECKPOINT should be readable");
|
|
let Some(state) = prototype_chunk(&bytes, "emulator") else {
|
|
eprintln!("skipped: {path:?} has no `emulator` chunk");
|
|
return;
|
|
};
|
|
|
|
eprintln!(
|
|
"statefmt probe: prototype chunk is {} bytes, this build's EmulatorState is {} bytes",
|
|
state.len(),
|
|
Emulator::state_size()
|
|
);
|
|
match emulator.import_state(state) {
|
|
Ok(()) => eprintln!(
|
|
"statefmt probe: the Emscripten-era state IMPORTED; sizes agree and the header matched"
|
|
),
|
|
Err(error) => eprintln!(
|
|
"statefmt probe: the Emscripten-era state did NOT import ({error}); \
|
|
prototype milestone saves cannot be reused by the native build"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Minimal reader for the prototype's `FLYPOKE1` checkpoint container
|
|
/// (`fly-plays-pokemon/src/runtime/checkpoint.ts`): magic, a little-endian
|
|
/// manifest length, the JSON manifest naming the chunks in order, then each
|
|
/// chunk as a little-endian length followed by its bytes.
|
|
fn prototype_chunk<'a>(bytes: &'a [u8], want: &str) -> Option<&'a [u8]> {
|
|
const MAGIC: &[u8] = b"FLYPOKE1";
|
|
if !bytes.starts_with(MAGIC) {
|
|
return None;
|
|
}
|
|
let mut offset = MAGIC.len();
|
|
let manifest_len = read_u32(bytes, &mut offset)? as usize;
|
|
let manifest: serde_json::Value =
|
|
serde_json::from_slice(bytes.get(offset..offset + manifest_len)?).ok()?;
|
|
offset += manifest_len;
|
|
for name in manifest.get("chunks")?.as_array()? {
|
|
let len = read_u32(bytes, &mut offset)? as usize;
|
|
let chunk = bytes.get(offset..offset + len)?;
|
|
offset += len;
|
|
if name.as_str() == Some(want) {
|
|
return Some(chunk);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
|
|
let slice = bytes.get(*offset..*offset + 4)?;
|
|
*offset += 4;
|
|
Some(u32::from_le_bytes(slice.try_into().ok()?))
|
|
}
|
|
|
|
/// 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". 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,
|
|
/// `warp_event 7, 1, REDS_HOUSE_1F, 3`, and `MACRO warp_event` emits `db \2, \1, ...` -- so
|
|
/// the bytes at `wWarpEntries` must read Y = 1 then X = 7 on a running cartridge. If the
|
|
/// 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 -- 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_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();
|
|
let mut ms = 0.0;
|
|
|
|
// The intro, then B to dismiss the text box the A-mashing leaves open; the same pattern as
|
|
// `the_reward_adapter_leaves_boot_once_the_game_starts`, which explains why.
|
|
for frame in 0..6_000u32 {
|
|
let mask = match frame % 32 {
|
|
0..=7 => buttons::START,
|
|
16..=23 => buttons::A,
|
|
_ => buttons::NONE,
|
|
};
|
|
emulator.set_buttons(mask);
|
|
emulator.run_frame().unwrap();
|
|
ms += 1000.0 / 59.7275;
|
|
adapter.sample(&mut emulator, ms);
|
|
if frame > 3_000 && adapter.mode() == "OVERWORLD" {
|
|
break;
|
|
}
|
|
}
|
|
for frame in 0..12_000u32 {
|
|
emulator.set_buttons(if frame % 24 < 8 { buttons::B } else { buttons::NONE });
|
|
emulator.run_frame().unwrap();
|
|
ms += 1000.0 / 59.7275;
|
|
adapter.sample(&mut emulator, ms);
|
|
if adapter.safe_for_snapshot() {
|
|
break;
|
|
}
|
|
}
|
|
assert!(adapter.safe_for_snapshot(), "the intro never settled (mode {})", adapter.mode());
|
|
assert_eq!(adapter.map_id(), Some(0x26), "a cold boot ends in Red's bedroom");
|
|
|
|
// 1. The warp table, as the decomp declares it.
|
|
assert_eq!(emulator.read_wram(ram::wNumberOfWarps), 1, "the bedroom has one warp");
|
|
assert_eq!(emulator.read_wram(ram::wWarpEntries), 1, "the stairs' Y comes first");
|
|
assert_eq!(emulator.read_wram(ram::wWarpEntries + 1), 7, "then its X");
|
|
assert_eq!(
|
|
emulator.read_wram(ram::wCurMapConnections),
|
|
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!(
|
|
spawn_x.abs_diff(7) + spawn_y.abs_diff(1) > 1,
|
|
"the fly spawns at ({spawn_x}, {spawn_y}), which the first sample would baseline"
|
|
);
|
|
|
|
// 2. Walk, with no map knowledge, and watch where the boundary payouts land.
|
|
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)
|
|
.wrapping_add(1_442_695_040_888_963_407);
|
|
let step = [buttons::UP, buttons::DOWN, buttons::LEFT, buttons::RIGHT]
|
|
[((seed >> 33) % 4) as usize];
|
|
let mask = match frame % 24 {
|
|
0..=7 => step,
|
|
16..=19 => buttons::B,
|
|
_ => buttons::NONE,
|
|
};
|
|
emulator.set_buttons(mask);
|
|
emulator.run_frame().unwrap();
|
|
ms += 1000.0 / 59.7275;
|
|
let x = emulator.read_wram(ram::wXCoord);
|
|
let y = emulator.read_wram(ram::wYCoord);
|
|
for event in adapter.sample(&mut emulator, ms) {
|
|
kinds.push(event.kind);
|
|
if event.kind == "boundary" {
|
|
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) = found_at.expect("the stairs were never found");
|
|
assert!(
|
|
x.abs_diff(7) + y.abs_diff(1) <= 1,
|
|
"the stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)"
|
|
);
|
|
assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}");
|
|
}
|