Measured on the cartridge from the rung-10 checkpoint: `wXCoord` and `wYCoord` change at the *end* of a sixteen-frame step while the background scrolls throughout it, so for fifteen frames of every sixteen the screen buffer is centred one tile ahead of the coordinates. The cross-check compared the decode of the fly's own tile with the screen's reading of the tile ahead and refused. Pewter City decoded on 118 of 120 standing frames and on none of the moving ones, so every walk the fly actually took was re-planned over the ten-by-nine window, which is the oscillation of row 23. Nothing in the pinned symbol table says "a step is in progress" and a new address cannot be pinned without the disassembly `gen_symbols.py` reads, so the anchor is measured rather than named: the screen is centred on the fly's tile or on one of its four neighbours, and the one it is centred on is the one whose whole neighbourhood agrees with the decode. A decode with a wrong stride, a wrong quadrant or a half-loaded map agrees with none of the five, and neither does the mid-warp tear the per-serve check was added for, so both refusals stand. `state::step_destination` names the tile the step is landing on, for the ledger the next commit writes. The town fixture gains two landmarks beside the fly, because a neighbourhood that is the same tile id in every direction cannot tell one anchor from another.
837 lines
34 KiB
Rust
837 lines
34 KiB
Rust
//! Synthetic WRAM traces for every accessor.
|
|
//!
|
|
//! The encodings under test are the ones `docs/design/macros-wram.md` records: big-endian HP,
|
|
//! packed PP, a BCD wallet, sprite coordinates biased by four, a one-based move list, and a
|
|
//! passable-tile list walked out of ROM bank 0.
|
|
|
|
use super::*;
|
|
use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram};
|
|
use crate::pokemon_red::macros::cartridge::MacroState;
|
|
use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign};
|
|
|
|
/// A walkable tile id from `RedsHouse1_Coll`.
|
|
const FLOOR: u8 = 0x01;
|
|
|
|
#[test]
|
|
fn the_player_reads_its_map_tile_and_facing() {
|
|
let mut wram = Wram::overworld();
|
|
let here = player(&mut wram).expect("a loaded map");
|
|
assert_eq!((here.map, here.x, here.y), (REDS_HOUSE_1F, 3, 6));
|
|
assert_eq!(here.facing, Facing::Down);
|
|
|
|
for (byte, facing) in [
|
|
(0x00, Facing::Down),
|
|
(0x04, Facing::Up),
|
|
(0x08, Facing::Left),
|
|
(0x0c, Facing::Right),
|
|
] {
|
|
wram.facing(byte);
|
|
assert_eq!(player(&mut wram).unwrap().facing, facing, "sprite facing {byte:#04x}");
|
|
assert_eq!(facing.delta(), match facing {
|
|
Facing::Down => (0, 1),
|
|
Facing::Up => (0, -1),
|
|
Facing::Left => (-1, 0),
|
|
Facing::Right => (1, 0),
|
|
});
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_map_is_measured_in_tiles_not_blocks() {
|
|
let mut wram = Wram::overworld();
|
|
assert_eq!(map_size(&mut wram), Some(MapSize { width: 8, height: 8 }));
|
|
|
|
// Pallet Town is 10 by 9 blocks.
|
|
wram.map(0x00, 10, 9, 5, 6);
|
|
assert_eq!(map_size(&mut wram), Some(MapSize { width: 20, height: 18 }));
|
|
|
|
// An unloaded header is no size at all, rather than a zero-sized map.
|
|
wram.set(ram::wCurMapWidth, 0);
|
|
assert_eq!(map_size(&mut wram), None);
|
|
assert_eq!(player(&mut wram), None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_player_is_none_when_its_coordinates_are_outside_the_map() {
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wYCoord, 8);
|
|
assert_eq!(player(&mut wram), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_party_member_reads_species_level_hp_status_and_moves() {
|
|
let mut wram = Wram::overworld();
|
|
// Charmander, level 7, 14 of 22 HP, asleep for 3 turns, SCRATCH with 34 PP and one PP Up,
|
|
// GROWL with 40.
|
|
wram.party_mon(0, 4, 7, 14, 22, 0b011, &[(10, 0b01_100010), (45, 40)]);
|
|
|
|
let team = party(&mut wram);
|
|
assert_eq!(team.mons.len(), 1);
|
|
let mon = team.mons[0];
|
|
assert_eq!(mon.slot, 0);
|
|
assert_eq!(mon.species, 4);
|
|
assert_eq!(mon.level, 7);
|
|
assert_eq!(mon.hp, 14);
|
|
assert_eq!(mon.max_hp, 22);
|
|
assert_eq!(mon.status, Status::Sleep(3));
|
|
assert_eq!(mon.moves[0], Some(Move { id: 10, pp: 34, pp_up: 1 }));
|
|
assert_eq!(mon.moves[1], Some(Move { id: 45, pp: 40, pp_up: 0 }));
|
|
assert_eq!(mon.moves[2], None);
|
|
assert_eq!(mon.moves[3], None);
|
|
assert!(!mon.fainted());
|
|
assert!((mon.hp_fraction() - 14.0 / 22.0).abs() < 1e-12);
|
|
|
|
// Outside a battle there is no active Pokémon.
|
|
assert_eq!(team.active, None);
|
|
}
|
|
|
|
#[test]
|
|
fn hp_is_big_endian() {
|
|
let mut wram = Wram::overworld();
|
|
// 0x0102 = 258: a byte-swapped read would say 513.
|
|
wram.party_mon(0, 6, 36, 258, 300, 0, &[]);
|
|
assert_eq!(party(&mut wram).mons[0].hp, 258);
|
|
assert_eq!(wram.peek(ram::wPartyMon1 + 1), 0x01);
|
|
assert_eq!(wram.peek(ram::wPartyMon1 + 2), 0x02);
|
|
}
|
|
|
|
#[test]
|
|
fn every_status_byte_reads_as_its_ailment() {
|
|
let mut wram = Wram::overworld();
|
|
for (byte, status) in [
|
|
(0x00, Status::Healthy),
|
|
(0x01, Status::Sleep(1)),
|
|
(0x07, Status::Sleep(7)),
|
|
(1 << 3, Status::Poison),
|
|
(1 << 4, Status::Burn),
|
|
(1 << 5, Status::Freeze),
|
|
(1 << 6, Status::Paralysis),
|
|
] {
|
|
wram.party_mon(0, 4, 5, 19, 19, byte, &[]);
|
|
assert_eq!(party(&mut wram).mons[0].status, status, "status byte {byte:#04x}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_party_reads_six_members_in_slot_order() {
|
|
let mut wram = Wram::overworld();
|
|
for slot in 0..6u8 {
|
|
wram.party_mon(slot, 10 + slot, 5 + slot, u16::from(slot) + 1, 20, 0, &[(33, 35)]);
|
|
}
|
|
let team = party(&mut wram);
|
|
assert_eq!(team.mons.len(), 6);
|
|
for slot in 0..6usize {
|
|
assert_eq!(team.mons[slot].slot, slot as u8);
|
|
assert_eq!(team.mons[slot].species, 10 + slot as u8);
|
|
}
|
|
// A count the cartridge cannot produce reads as no party at all, not as garbage.
|
|
wram.set(ram::wPartyCount, 9);
|
|
assert!(party(&mut wram).mons.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_healthiest_reserve_is_the_highest_fraction_that_is_not_out_and_not_fainted() {
|
|
let mut wram = Wram::overworld();
|
|
wram.party_mon(0, 4, 10, 30, 30, 0, &[]); // out, full
|
|
wram.party_mon(1, 7, 10, 0, 30, 0, &[]); // fainted
|
|
wram.party_mon(2, 16, 10, 10, 40, 0, &[]); // 0.25
|
|
wram.party_mon(3, 19, 10, 15, 30, 0, &[]); // 0.50
|
|
wram.set(ram::wIsInBattle, 1).set(ram::wPlayerMonNumber, 0);
|
|
|
|
let team = party(&mut wram);
|
|
assert_eq!(team.active, Some(0));
|
|
assert_eq!(team.active_mon().map(|mon| mon.species), Some(4));
|
|
assert_eq!(team.healthiest_reserve().map(|mon| mon.slot), Some(3));
|
|
|
|
// Ties go to the lower slot, which is `macros.md` section 3's "ties by index".
|
|
let mut wram = Wram::overworld();
|
|
wram.party_mon(0, 4, 10, 30, 30, 0, &[]);
|
|
wram.party_mon(1, 7, 10, 20, 40, 0, &[]);
|
|
wram.party_mon(2, 16, 10, 10, 20, 0, &[]);
|
|
wram.set(ram::wIsInBattle, 1).set(ram::wPlayerMonNumber, 0);
|
|
assert_eq!(party(&mut wram).healthiest_reserve().map(|mon| mon.slot), Some(1));
|
|
|
|
// A party of one has no reserve.
|
|
let mut wram = Wram::overworld();
|
|
wram.party_mon(0, 4, 10, 30, 30, 0, &[]);
|
|
wram.set(ram::wIsInBattle, 1);
|
|
assert!(party(&mut wram).healthiest_reserve().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_battle_reports_its_kind_its_own_mon_and_the_enemy() {
|
|
let mut wram = Wram::overworld();
|
|
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)])
|
|
.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40)])
|
|
.enemy_mon(19, 3, 5, 11)
|
|
.battle(1);
|
|
|
|
let fight = battle(&mut wram).expect("a wild battle");
|
|
assert_eq!(fight.kind, BattleKind::Wild);
|
|
assert_eq!(fight.own.map(|mon| (mon.species, mon.hp, mon.max_hp)), Some((4, 14, 22)));
|
|
assert_eq!(fight.own.unwrap().moves[1], Some(Move { id: 45, pp: 40, pp_up: 0 }));
|
|
assert_eq!(
|
|
fight.enemy,
|
|
Some(EnemyMon { species: 19, level: 3, hp: 5, max_hp: 11 })
|
|
);
|
|
assert_eq!(fight.menu, BattleMenu::None);
|
|
assert!(!fight.own_turn);
|
|
|
|
wram.set(ram::wIsInBattle, 2);
|
|
assert_eq!(battle(&mut wram).unwrap().kind, BattleKind::Trainer);
|
|
|
|
// Outside a battle there is nothing to report.
|
|
wram.set(ram::wIsInBattle, 0);
|
|
assert!(battle(&mut wram).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_battle_menu_cursor_maps_to_fight_pkmn_item_run() {
|
|
for (right_column, current, expected) in
|
|
[(false, 0, 0u8), (false, 1, 1), (true, 0, 2), (true, 1, 3)]
|
|
{
|
|
let mut wram = Wram::overworld();
|
|
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]).battle(1).battle_menu(
|
|
right_column,
|
|
current,
|
|
);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(
|
|
fight.menu,
|
|
BattleMenu::Main { cursor: expected },
|
|
"right={right_column} current={current}"
|
|
);
|
|
assert!(fight.own_turn);
|
|
assert!(!fight.forced_switch);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_move_list_is_reported_zero_based() {
|
|
let mut wram = Wram::overworld();
|
|
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)]).battle(1);
|
|
|
|
for slot in 0..3u8 {
|
|
wram.move_menu(slot, 3);
|
|
assert_eq!(
|
|
battle(&mut wram).unwrap().menu,
|
|
BattleMenu::Moves { cursor: Some(slot), count: 3 },
|
|
"move slot {slot}"
|
|
);
|
|
}
|
|
|
|
// The game's list is one-based, so index 0 names no move and neither does one past the end.
|
|
wram.move_menu(0, 3).set(ram::wCurrentMenuItem, 0);
|
|
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::Moves { cursor: None, count: 3 });
|
|
wram.move_menu(0, 3).set(ram::wCurrentMenuItem, 4);
|
|
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::Moves { cursor: None, count: 3 });
|
|
}
|
|
|
|
#[test]
|
|
fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() {
|
|
let mut wram = Wram::overworld();
|
|
wram.party_mon(0, 4, 7, 0, 22, 0, &[(10, 35)]);
|
|
wram.party_mon(1, 16, 8, 24, 24, 0, &[(33, 35)]);
|
|
wram.battle_mon(0, 4, 7, 0, 22, 0, &[(10, 35)]).battle(2).party_list(1, true);
|
|
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(fight.menu, BattleMenu::Party { cursor: 1 });
|
|
assert!(fight.forced_switch);
|
|
assert!(!fight.own_turn);
|
|
|
|
let mut wram = Wram::overworld();
|
|
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]);
|
|
wram.party_mon(1, 16, 8, 24, 24, 0, &[(33, 35)]);
|
|
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]).battle(2).party_list(1, false);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(fight.menu, BattleMenu::Party { cursor: 1 });
|
|
assert!(!fight.forced_switch);
|
|
assert!(cursor(&mut wram).cancellable());
|
|
}
|
|
|
|
/// Section 12.10: **a battle frame with a cursor accepting input is the fly's turn.**
|
|
///
|
|
/// The four menus a battle waits on are the top-level one, the move list, the party list and the
|
|
/// bag, and each of them is the game asking the player to choose. `own_turn` answered `false` for
|
|
/// the bag, which put it on the between-turns row whose one button is the `NEXT` that advances
|
|
/// text -- and on an open bag that same A press uses whatever the cursor holds.
|
|
///
|
|
/// The two frames that are correctly *not* the fly's turn are here too: a move list whose cursor
|
|
/// the seam cannot place (row 30b, a battle's opening frames) and a frame with no menu at all.
|
|
#[test]
|
|
fn a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn() {
|
|
let battler = |wram: &mut Wram| {
|
|
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]);
|
|
wram.party_mon(1, 16, 8, 24, 24, 0, &[(33, 35)]);
|
|
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)])
|
|
.enemy_mon(19, 3, 5, 11)
|
|
.battle(1);
|
|
};
|
|
|
|
// The top-level menu.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.battle_menu(false, 0);
|
|
assert!(battle(&mut wram).unwrap().own_turn, "the top-level menu");
|
|
|
|
// The move list, with a cursor the seam can place.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu(1, 3);
|
|
assert!(battle(&mut wram).unwrap().own_turn, "the move list");
|
|
|
|
// The party list, chosen rather than forced.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.party_list(1, false);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert!(!fight.forced_switch);
|
|
assert!(fight.own_turn, "the party list outside a forced switch");
|
|
|
|
// The bag, which `DisplayListMenuID` opens from the menu's ITEM entry. It is the one menu that
|
|
// read as nobody's turn, and it is what section 12.10 is about.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.bag(&[(crate::pokemon_red::macros::cartridge::item::POTION, 2)]).set(ram::wListMenuID, poke::ITEM_LIST_MENU);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(fight.menu, BattleMenu::Bag { cursor: 0, count: 1 });
|
|
assert!(fight.own_turn, "the battle bag is a cursor accepting input");
|
|
|
|
// And the two frames that are not a choice. A move list whose cursor cannot be placed is a
|
|
// battle's opening frames (row 30b), and no menu at all is text, an animation or a turn
|
|
// resolving.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu(0, 3).set(ram::wCurrentMenuItem, 0);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(fight.menu, BattleMenu::Moves { cursor: None, count: 3 });
|
|
assert!(!fight.own_turn, "a cursor the seam cannot place is not accepting input");
|
|
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None);
|
|
assert!(!battle(&mut wram).unwrap().own_turn, "no menu, no turn");
|
|
|
|
// A forced switch is a cursor accepting input and it is *not* the own turn, because it has a
|
|
// pad of its own: the exception 12.6 named, kept here so the invariant reads honestly.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.party_list(1, true);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert!(fight.forced_switch);
|
|
assert!(!fight.own_turn, "a forced switch has its own pad");
|
|
}
|
|
|
|
#[test]
|
|
fn a_text_box_is_open_from_the_font_flag_and_waiting_from_the_box() {
|
|
let mut wram = Wram::overworld();
|
|
assert_eq!(text_box(&mut wram), TextBox { open: false, waiting: false });
|
|
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED);
|
|
assert_eq!(text_box(&mut wram), TextBox { open: true, waiting: false });
|
|
|
|
wram.dialogue_box();
|
|
assert_eq!(text_box(&mut wram), TextBox { open: true, waiting: true });
|
|
|
|
// One stray frame tile on the map is not a text box; four corners are.
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.screen_tile(0, 12, poke::frame::TOP_LEFT);
|
|
assert!(!text_box(&mut wram).waiting);
|
|
}
|
|
|
|
#[test]
|
|
fn the_start_menu_counts_its_items() {
|
|
let mut wram = Wram::overworld();
|
|
wram.start_menu();
|
|
let menu = start_menu(&mut wram).expect("the start menu");
|
|
assert_eq!(menu.items, 7, "the Pokédex entry is there");
|
|
assert_eq!(menu.cursor.top_x, 11);
|
|
assert!(menu.cursor.cancellable());
|
|
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.draw_box(10, 0, 19, 13)
|
|
.cursor(2, 11, 0, 6, poke::pad::A | poke::pad::B);
|
|
assert_eq!(start_menu(&mut wram).unwrap().items, 6);
|
|
|
|
// The box without the font flag is a repaint, not a menu.
|
|
let mut wram = Wram::overworld();
|
|
wram.draw_box(10, 0, 19, 15).cursor(2, 11, 0, 7, poke::pad::A);
|
|
assert!(start_menu(&mut wram).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_mart_reports_which_of_its_screens_is_up() {
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.set(ram::wTextBoxID, poke::BUY_SELL_QUIT_MENU);
|
|
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::BuySellQuit));
|
|
|
|
wram.set(ram::wListMenuID, poke::ITEM_LIST_MENU);
|
|
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Selling));
|
|
|
|
wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU);
|
|
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Buying));
|
|
|
|
// The bag list on its own is the start menu's, not a mart's.
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.set(ram::wListMenuID, poke::ITEM_LIST_MENU);
|
|
assert!(shop(&mut wram).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pc_is_the_generic_pc_bit() {
|
|
let mut wram = Wram::overworld();
|
|
assert!(pc(&mut wram).is_none());
|
|
wram.set(ram::wMiscFlags, poke::BIT_USING_GENERIC_PC).cursor(2, 4, 1, 3, poke::pad::A);
|
|
assert_eq!(pc(&mut wram).map(|pc| pc.cursor.current), Some(1));
|
|
// Another flag in the same byte is not a PC.
|
|
wram.set(ram::wMiscFlags, 1 << 4);
|
|
assert!(pc(&mut wram).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn money_is_three_bytes_of_bcd() {
|
|
let mut wram = Wram::overworld();
|
|
assert_eq!(money(&mut wram), 0);
|
|
|
|
for amount in [1u32, 9, 10, 99, 100, 3000, 12_345, 999_999] {
|
|
wram.money(amount);
|
|
assert_eq!(money(&mut wram), amount, "{amount}");
|
|
}
|
|
|
|
// The starting wallet, written the way the cartridge writes it.
|
|
wram.set(ram::wPlayerMoney, 0x00).set(ram::wPlayerMoney + 1, 0x30).set(
|
|
ram::wPlayerMoney + 2,
|
|
0x00,
|
|
);
|
|
assert_eq!(money(&mut wram), 3000);
|
|
|
|
// A nibble above 9 is not BCD: a half-written frame must not read as a plausible number.
|
|
wram.set(ram::wPlayerMoney + 1, 0xfa);
|
|
assert_eq!(money(&mut wram), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_bag_reads_pairs_up_to_its_terminator() {
|
|
let mut wram = Wram::overworld();
|
|
assert!(bag(&mut wram).is_empty());
|
|
|
|
wram.bag(&[(4, 1), (20, 3)]);
|
|
assert_eq!(
|
|
bag(&mut wram),
|
|
vec![BagItem { id: 4, count: 1 }, BagItem { id: 20, count: 3 }]
|
|
);
|
|
|
|
// The terminator wins over the count, which is what stops a stale slot being read as an item.
|
|
wram.set(ram::wNumBagItems, 4).set(ram::wBagItems + 2, 0xff);
|
|
assert_eq!(bag(&mut wram), vec![BagItem { id: 4, count: 1 }]);
|
|
|
|
// A count past BAG_ITEM_CAPACITY is not a bag.
|
|
wram.bag(&[(4, 1)]);
|
|
wram.set(ram::wNumBagItems, 21);
|
|
assert!(bag(&mut wram).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn an_npc_reads_its_map_tile_and_facing() {
|
|
let mut wram = Wram::overworld();
|
|
assert!(npcs(&mut wram).is_empty());
|
|
|
|
// Oak's aide at (4, 3) facing left, and a second sprite at (1, 7) facing down.
|
|
wram.npc(1, 0x0d, 4, 3, 0x08).npc(2, 0x02, 1, 7, 0x00);
|
|
let sprites = npcs(&mut wram);
|
|
assert_eq!(sprites.len(), 2);
|
|
assert_eq!((sprites[0].slot, sprites[0].x, sprites[0].y), (1, 4, 3));
|
|
assert_eq!(sprites[0].facing, Facing::Left);
|
|
assert_eq!(sprites[0].picture, 0x0d);
|
|
assert_eq!((sprites[1].x, sprites[1].y, sprites[1].facing), (1, 7, Facing::Down));
|
|
|
|
// The coordinates are stored plus four, so a reader that forgot the bias would say (8, 7).
|
|
assert_eq!(wram.peek(ram::wSpriteStateData2 + 16 + 4), 7);
|
|
assert_eq!(wram.peek(ram::wSpriteStateData2 + 16 + 5), 8);
|
|
|
|
// Slot 0 is the player and is never an NPC.
|
|
wram.npc(0, 0x01, 3, 6, 0x00);
|
|
assert_eq!(npcs(&mut wram).len(), 2);
|
|
|
|
// A slot the map does not use carries $ff in its image index.
|
|
wram.set(ram::wSpriteStateData1 + 16 + 2, 0xff);
|
|
assert_eq!(npcs(&mut wram).len(), 1);
|
|
|
|
// And wNumSprites bounds the walk.
|
|
wram.set(ram::wNumSprites, 0);
|
|
assert!(npcs(&mut wram).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_still_sprite_is_an_object_and_everything_below_it_is_a_person() {
|
|
let mut wram = Wram::overworld();
|
|
// SPRITE_OAK ($03), SPRITE_SEEL ($3c, the last person), SPRITE_POKE_BALL ($3d, the first
|
|
// still sprite) and SPRITE_GAMBLER_ASLEEP ($48, the last): FIRST_STILL_SPRITE is the line.
|
|
wram.npc(1, 0x03, 1, 1, 0).npc(2, 0x3c, 2, 1, 0).npc(3, 0x3d, 3, 1, 0).npc(4, 0x48, 4, 1, 0);
|
|
let people: Vec<u8> =
|
|
npcs(&mut wram).iter().filter(|npc| npc.person()).map(|npc| npc.picture).collect();
|
|
assert_eq!(people, vec![0x03, 0x3c]);
|
|
let objects: Vec<u8> =
|
|
npcs(&mut wram).iter().filter(|npc| !npc.person()).map(|npc| npc.picture).collect();
|
|
assert_eq!(objects, vec![0x3d, 0x48]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_sign_table_reads_y_then_x_with_no_bias() {
|
|
let mut wram = Wram::overworld();
|
|
assert!(signs(&mut wram).is_empty(), "a map with no bg_events has no signs");
|
|
|
|
// `bg_event 2, 0, TEXT_A` emits `db \2, \1, \3`: Y first, and — unlike `object_event` —
|
|
// with no +4, because the loader copies the two bytes straight across and
|
|
// IsSpriteOrSignInFrontOfPlayer compares them against raw tile coordinates.
|
|
wram.signs(&[(2, 0, 7), (5, 3, 8)]);
|
|
assert_eq!(wram.peek(ram::wSignCoords), 0, "the Y comes first");
|
|
assert_eq!(wram.peek(ram::wSignCoords + 1), 2, "and it is not biased by four");
|
|
assert_eq!(
|
|
signs(&mut wram),
|
|
vec![Sign { x: 2, y: 0, text_id: 7 }, Sign { x: 5, y: 3, text_id: 8 }]
|
|
);
|
|
|
|
// MAX_BG_EVENTS bounds the walk, so a garbage count cannot read past the array.
|
|
wram.set(ram::wNumSigns, 200);
|
|
assert_eq!(signs(&mut wram).len(), 16);
|
|
}
|
|
|
|
#[test]
|
|
fn the_warp_table_reads_y_then_x() {
|
|
let mut wram = Wram::overworld();
|
|
assert!(warps(&mut wram).is_empty());
|
|
|
|
// Red's bedroom declares `warp_event 7, 1, REDS_HOUSE_1F, 3`, which the macro emits as
|
|
// Y then X: the ROM-gated test asserts the same two bytes on a real cartridge.
|
|
wram.warps(&[(7, 1, 3, REDS_HOUSE_1F)]);
|
|
assert_eq!(wram.peek(ram::wWarpEntries), 1, "the Y comes first");
|
|
assert_eq!(wram.peek(ram::wWarpEntries + 1), 7);
|
|
assert_eq!(
|
|
warps(&mut wram),
|
|
vec![Warp { x: 7, y: 1, destination_warp: 3, destination_map: REDS_HOUSE_1F }]
|
|
);
|
|
|
|
// MAX_WARP_EVENTS bounds the walk, so a garbage count cannot read past the array.
|
|
wram.set(ram::wNumberOfWarps, 200);
|
|
assert_eq!(warps(&mut wram).len(), 32);
|
|
}
|
|
|
|
#[test]
|
|
fn the_connection_bitmask_names_the_four_edges() {
|
|
let mut wram = Wram::overworld();
|
|
assert_eq!(connections(&mut wram), Connections::default());
|
|
assert!(!connections(&mut wram).any());
|
|
|
|
for (bits, expected) in [
|
|
(poke::CONNECTION_EAST, Connections { east: true, ..Connections::default() }),
|
|
(poke::CONNECTION_WEST, Connections { west: true, ..Connections::default() }),
|
|
(poke::CONNECTION_SOUTH, Connections { south: true, ..Connections::default() }),
|
|
(poke::CONNECTION_NORTH, Connections { north: true, ..Connections::default() }),
|
|
] {
|
|
wram.set(ram::wCurMapConnections, bits);
|
|
assert_eq!(connections(&mut wram), expected, "bits {bits:#04b}");
|
|
}
|
|
|
|
wram.set(ram::wCurMapConnections, 0x0f);
|
|
assert_eq!(
|
|
connections(&mut wram),
|
|
Connections { north: true, south: true, east: true, west: true }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_walkable_tile_is_one_in_the_tilesets_passable_list() {
|
|
let mut wram = Wram::overworld();
|
|
// The tile the player stands on, opened.
|
|
wram.map_tile(3, 6, FLOOR);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Yes);
|
|
|
|
// Its neighbours are walls until they are opened.
|
|
assert_eq!(walkable(&mut wram, 3, 5), Walkable::No);
|
|
wram.map_tile(3, 5, FLOOR);
|
|
assert_eq!(walkable(&mut wram, 3, 5), Walkable::Yes);
|
|
|
|
// Every id in RedsHouse1_Coll is passable and one that is in no list is not.
|
|
for tile in crate::pokemon_red::fake_wram::REDS_HOUSE_COLL {
|
|
wram.map_tile(2, 6, tile);
|
|
assert_eq!(walkable(&mut wram, 2, 6), Walkable::Yes, "tile {tile:#04x}");
|
|
}
|
|
wram.map_tile(2, 6, WALL_TILE);
|
|
assert_eq!(walkable(&mut wram, 2, 6), Walkable::No);
|
|
}
|
|
|
|
#[test]
|
|
fn walkability_is_unknown_outside_the_screens_window() {
|
|
let mut wram = Wram::new();
|
|
// A map big enough to have tiles off screen: Pallet Town, 20 by 18 tiles, player at (5, 6).
|
|
wram.started().map(0x00, 10, 9, 5, 6).facing(0).house_collision().fill_screen(FLOOR);
|
|
|
|
// The window is x - 4 ..= x + 5 and y - 4 ..= y + 4.
|
|
assert_eq!(walkable(&mut wram, 1, 6), Walkable::Yes);
|
|
assert_eq!(walkable(&mut wram, 10, 6), Walkable::Yes);
|
|
assert_eq!(walkable(&mut wram, 0, 6), Walkable::Unknown);
|
|
assert_eq!(walkable(&mut wram, 11, 6), Walkable::Unknown);
|
|
assert_eq!(walkable(&mut wram, 5, 2), Walkable::Yes);
|
|
assert_eq!(walkable(&mut wram, 5, 10), Walkable::Yes);
|
|
assert_eq!(walkable(&mut wram, 5, 1), Walkable::Unknown);
|
|
assert_eq!(walkable(&mut wram, 5, 11), Walkable::Unknown);
|
|
|
|
// The window follows the player, it is not the map: the same 8 by 8 indoor map is answerable
|
|
// whole from its middle and only in part from a corner. An A* over it has to treat Unknown as
|
|
// impassable and re-plan as the player moves, which is what the per-step check in
|
|
// `docs/design/macros.md` section 4 already requires.
|
|
let mut wram = Wram::overworld();
|
|
wram.map(REDS_HOUSE_1F, 4, 4, 4, 4).fill_screen(FLOOR);
|
|
for y in 0..8u8 {
|
|
for x in 0..8u8 {
|
|
assert_eq!(walkable(&mut wram, x, y), Walkable::Yes, "centred: ({x}, {y})");
|
|
}
|
|
}
|
|
wram.map(REDS_HOUSE_1F, 4, 4, 3, 6);
|
|
assert_eq!(walkable(&mut wram, 3, 2), Walkable::Yes, "four rows up is in the window");
|
|
assert_eq!(walkable(&mut wram, 3, 1), Walkable::Unknown, "five is not");
|
|
assert_eq!(walkable(&mut wram, 3, 0), Walkable::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn walkability_is_no_off_the_map_and_unknown_when_the_screen_is_not_the_map() {
|
|
let mut wram = Wram::overworld();
|
|
wram.fill_screen(FLOOR);
|
|
// Off the map is not a tile to stand on: the tiles a connection leads through are the map's
|
|
// own edge rows, which are inside it.
|
|
assert_eq!(walkable(&mut wram, 8, 6), Walkable::No);
|
|
assert_eq!(walkable(&mut wram, 3, 8), Walkable::No);
|
|
|
|
// While a text box is up the screen buffer holds the box.
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Unknown);
|
|
|
|
// And in a battle it holds the battle.
|
|
let mut wram = Wram::overworld();
|
|
wram.fill_screen(FLOOR).set(ram::wIsInBattle, 1);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Unknown);
|
|
|
|
// An unloaded map answers nothing.
|
|
let mut wram = Wram::overworld();
|
|
wram.fill_screen(FLOOR).set(ram::wCurMapWidth, 0);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn a_collision_pointer_outside_rom_bank_zero_is_not_followed() {
|
|
let mut wram = Wram::overworld();
|
|
wram.fill_screen(FLOOR);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Yes);
|
|
|
|
// Banked ROM: a MemoryReader reads whichever bank is mapped, which is not an answer.
|
|
wram.set(ram::wTilesetCollisionPtr, 0x00).set(ram::wTilesetCollisionPtr + 1, 0x40);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Unknown);
|
|
|
|
// A null pointer, which is what an unloaded tileset header looks like.
|
|
wram.set(ram::wTilesetCollisionPtr, 0).set(ram::wTilesetCollisionPtr + 1, 0);
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Unknown);
|
|
|
|
// A list with no terminator inside the bound is unknown rather than an answer.
|
|
let mut wram = Wram::overworld();
|
|
wram.fill_screen(FLOOR);
|
|
for offset in 0..80u16 {
|
|
wram.set(0x1749 + offset, 0x42);
|
|
}
|
|
assert_eq!(walkable(&mut wram, 3, 6), Walkable::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn the_live_implementation_answers_the_whole_trait() {
|
|
// One pass over every method of `GameState`, through `PokeState`, so the trait's shape is
|
|
// exercised the way agent B's executor will hold it.
|
|
let mut wram = Wram::overworld();
|
|
wram.fill_screen(FLOOR)
|
|
.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)])
|
|
.money(3000)
|
|
.bag(&[(4, 1)])
|
|
.npc(1, 0x0d, 4, 3, 0x08)
|
|
.signs(&[(2, 0, 7)])
|
|
.warps(&[(7, 1, 3, REDS_HOUSE_1F)])
|
|
.set(ram::wCurMapConnections, poke::CONNECTION_SOUTH);
|
|
|
|
let mut state = PokeState::new(&mut wram);
|
|
assert_eq!(state.scene(), Scene::Overworld);
|
|
assert_eq!(state.player().map(|player| (player.x, player.y)), Some((3, 6)));
|
|
assert_eq!(state.map_size(), Some(MapSize { width: 8, height: 8 }));
|
|
assert_eq!(state.party().mons.len(), 1);
|
|
assert!(state.battle().is_none());
|
|
assert_eq!(state.text_box(), TextBox { open: false, waiting: false });
|
|
assert!(state.start_menu().is_none());
|
|
assert!(state.shop().is_none());
|
|
assert!(state.pc().is_none());
|
|
assert_eq!(state.money(), 3000);
|
|
assert_eq!(state.bag().len(), 1);
|
|
assert_eq!(state.npcs().len(), 1);
|
|
assert_eq!(state.signs().len(), 1);
|
|
assert_eq!(state.walkable(3, 6), Walkable::Yes);
|
|
assert_eq!(state.warps().len(), 1);
|
|
assert!(state.connections().south);
|
|
}
|
|
|
|
/// A map ten blocks by nine — twenty tiles by eighteen, wider than the ten-by-nine window — with
|
|
/// a wall down one column of blocks, and a screen buffer that agrees with it.
|
|
///
|
|
/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a
|
|
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
|
|
/// `wTilesetCollisionPtr` points.
|
|
fn town() -> (Wram, Vec<u8>, Vec<[u8; 16]>) {
|
|
Wram::town()
|
|
}
|
|
|
|
#[test]
|
|
fn the_whole_map_decodes_from_the_block_and_collision_tables() {
|
|
let (mut wram, _, _) = town();
|
|
let grid = map_grid(&mut wram).expect("a decodable map");
|
|
assert_eq!((grid.width(), grid.height()), (20, 18));
|
|
assert_eq!(grid.unknown_count(), 0);
|
|
// The far corner of the map, which the window predicate cannot answer for at all: the grid
|
|
// does, and that is the whole of section 15.
|
|
assert_eq!(walkable(&mut wram, 19, 17), Walkable::Unknown);
|
|
assert_eq!(grid.walkable(19, 17), Walkable::Yes);
|
|
// The wall column, again outside the window.
|
|
assert_eq!(grid.walkable(10, 17), Walkable::No);
|
|
assert_eq!(grid.walkable(11, 17), Walkable::No);
|
|
// Inside the window the two readings agree tile for tile, which is what the reader checks
|
|
// itself with before it trusts a decode.
|
|
for y in 0..18u8 {
|
|
for x in 0..20u8 {
|
|
if let Some(tile) = map_tile_id(&mut wram, x, y) {
|
|
assert_eq!(grid.tile_id(x, y), Some(tile), "({x}, {y})");
|
|
assert_eq!(grid.walkable(x, y), walkable(&mut wram, x, y), "({x}, {y})");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_mid_step_is_read_from_the_tile_the_screen_is_centred_on() {
|
|
// Row 54 of `infra/docs/macros-traps.md`, measured on the cartridge: `wXCoord` and `wYCoord`
|
|
// change at the *end* of a step, so for fifteen frames of every sixteen the screen buffer is
|
|
// centred one tile ahead of them. The old cross-check compared the decode of the fly's tile
|
|
// against the screen's reading of the tile ahead and refused; Pewter City decoded on 118 of
|
|
// 120 standing frames and on none of the moving ones, and every walk the fly actually took was
|
|
// planned over the ten-by-nine window instead.
|
|
let (mut wram, blocks, blockset) = town();
|
|
wram.mid_step(-1, 0, &blocks, &blockset);
|
|
|
|
// The trap itself, stated as a reading: the two answers for a tile beside the fly disagree.
|
|
let naive = map_tile_id(&mut wram, 2, 4);
|
|
let grid = map_grid(&mut wram).expect("a mid-step frame still decodes");
|
|
assert_ne!(grid.tile_id(2, 4), naive, "the screen is one column ahead of the coordinates");
|
|
assert_eq!(grid.tile_id(1, 4), naive, "and that column is the one the step is landing on");
|
|
|
|
// Which is what the reader now says out loud, for the stood ledger.
|
|
assert_eq!(step_destination(&mut wram, &grid), Some((2, 4)));
|
|
// Standing still there is no step to name.
|
|
let (mut still, _, _) = town();
|
|
let standing = map_grid(&mut still).expect("a decodable map");
|
|
assert_eq!(step_destination(&mut still, &standing), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_decode_the_screen_disagrees_with_is_refused_mid_step_too() {
|
|
// The check has to keep refusing a decode that is simply wrong, and the anchor search is what
|
|
// could have weakened it: five anchors instead of one. A wrong stride, a wrong quadrant or a
|
|
// half-loaded map agrees with none of them, because the whole neighbourhood has to agree under
|
|
// one anchor rather than each tile finding an anchor of its own.
|
|
let (mut wram, blocks, blockset) = town();
|
|
wram.mid_step(-1, 0, &blocks, &blockset);
|
|
wram.map_tile(3, 4, 0x77);
|
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
|
|
}
|
|
|
|
#[test]
|
|
fn a_decode_the_screen_disagrees_with_is_refused() {
|
|
let (mut wram, _, _) = town();
|
|
// One screen tile the block data does not account for: a wrong stride, a wrong quadrant or a
|
|
// half-loaded map all look like this, and all of them answer plausibly.
|
|
wram.map_tile(3, 4, 0x77);
|
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
|
|
}
|
|
|
|
#[test]
|
|
fn without_a_cartridge_behind_the_seam_there_is_no_grid() {
|
|
let (mut wram, blocks, _) = town();
|
|
// The same WRAM with no blockset in any bank: `read_rom` answers `None`, which is what every
|
|
// reader that is not the emulator answers, and the grid narrows to nothing rather than
|
|
// decoding the map out of whatever bytes were to hand.
|
|
let mut bare = Wram::new();
|
|
bare.started()
|
|
.map(fake_wram::PALLET_TOWN, 10, 9, 3, 4)
|
|
.facing(0)
|
|
.house_collision()
|
|
.map_blocks(&blocks)
|
|
.fill_screen(WALL_TILE);
|
|
assert_eq!(map_grid(&mut bare), Err(GridRefusal::NoBlockset));
|
|
// And the window predicate still answers, which is the fallback the whole thing rests on.
|
|
assert_eq!(walkable(&mut wram, 3, 4), Walkable::Yes);
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_that_is_not_showing_the_map_has_no_grid_to_check() {
|
|
let (mut wram, _, _) = town();
|
|
// `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own union), so a
|
|
// battle is exactly when the blocks under it belong to somebody else. Nothing can be
|
|
// cross-checked then, and a decode nothing can check is refused.
|
|
wram.battle(1);
|
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
|
|
|
|
let (mut wram, _, _) = town();
|
|
wram.dialogue_box();
|
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_with_no_map_header_has_no_grid() {
|
|
let mut wram = Wram::new();
|
|
wram.started();
|
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoHeader));
|
|
assert_eq!(GridRefusal::NoHeader.label(), "no map header");
|
|
}
|
|
|
|
#[test]
|
|
fn the_grid_is_decoded_once_per_map_and_dropped_on_arrival_somewhere_else() {
|
|
let (mut wram, blocks, blockset) = town();
|
|
let mut grids = MapGrids::default();
|
|
{
|
|
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
|
|
let first = state.map_grid().expect("a decodable map");
|
|
let again = state.map_grid().expect("the cached map");
|
|
assert!(std::sync::Arc::ptr_eq(&first, &again), "the second question is the same grid");
|
|
}
|
|
assert_eq!(grids.held(), Some(fake_wram::PALLET_TOWN));
|
|
|
|
// Walking through a door: another map id, so the cache is somebody else's and is dropped.
|
|
wram.map(fake_wram::OAKS_LAB, 10, 9, 3, 4)
|
|
.map_blocks(&blocks)
|
|
.screen_from_blocks(&blocks, &blockset);
|
|
{
|
|
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
|
|
let grid = state.map_grid().expect("a decodable map");
|
|
assert_eq!(grid.map(), fake_wram::OAKS_LAB);
|
|
}
|
|
assert_eq!(grids.held(), Some(fake_wram::OAKS_LAB));
|
|
}
|
|
|
|
#[test]
|
|
fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_none() {
|
|
let (mut wram, _, _) = town();
|
|
let mut state = PokeState::new(&mut wram);
|
|
assert!(state.map_grid().is_some(), "no cache is slower, not blinder");
|
|
|
|
let mut bare = Wram::overworld();
|
|
let mut state = PokeState::new(&mut bare);
|
|
assert!(state.map_grid().is_none(), "no blockset, no grid");
|
|
// Which is the frame the window predicate is for.
|
|
assert_eq!(state.walkable(3, 6), Walkable::No);
|
|
}
|