move_data reads a row of Moves from the cartridge image ($0E:$4000, each row checked against its own id); move_without_effect answers the refusals the effect routines make on bytes already in WRAM: a stat stage at its limit or a stat at 1/999, Mist or a substitute against a stat-lowering move, a status move against a statused, Poison-type or (Electric) Ground-type target. MacroState::move_without_effect defaults to false.
1224 lines
52 KiB
Rust
1224 lines
52 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 });
|
|
}
|
|
|
|
/// Row 50: the move list is the box on screen, not the cursor bytes it left behind.
|
|
///
|
|
/// `MoveSelectionMenu` writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game
|
|
/// clears them**, exactly as the two-option box's geometry outlives its box (`yes_no_prompt`).
|
|
/// `SelectMenuItem` then decrements `wCurrentMenuItem` back to the 0-based slot on its way out,
|
|
/// which lands straight back inside the one-based range this accessor reads -- so a frame of battle
|
|
/// text read as an open move list with a placeable cursor, the pad dealt `MOVE 1..4` on it, and the
|
|
/// cursor step pressed at a list nobody was reading until its budget ran out: `MOVE n` reported
|
|
/// `blocked` 890 times in 1,431 macros on the cartridge.
|
|
///
|
|
/// Surveyed with one rollback pulse per battle frame (`examples/scene_probe.rs`,
|
|
/// `FLY_PROBE_CATCH=accept`, 3,102 frames at the rung-9 forest checkpoint): by the cursor bytes
|
|
/// alone a real directional press moved the cursor on 264 frames, and by the cursor bytes **and**
|
|
/// the box on screen on 231 of 231.
|
|
#[test]
|
|
fn a_move_list_is_the_box_on_screen_and_not_the_cursor_bytes_it_left_behind() {
|
|
let battler = |wram: &mut Wram| {
|
|
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 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 list drawn: the fly's own turn, on the slot the cursor is on.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu(2, 3);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(fight.menu, BattleMenu::Moves { cursor: Some(2), count: 3 });
|
|
assert!(fight.own_turn, "a move list with its box on screen is the fly's turn");
|
|
|
|
// The same bytes with the box gone -- every frame of the turn's text, animation and reply.
|
|
// Not a move list at all, so not the own turn, so the pad is the between-turns `NEXT`.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu_stale(2, 3);
|
|
let fight = battle(&mut wram).unwrap();
|
|
assert_eq!(fight.menu, BattleMenu::None, "the cursor bytes alone are not a move list");
|
|
assert!(!fight.own_turn, "cursor bytes with no box are between turns");
|
|
|
|
// The exact shape row 50 was measured in: `SelectMenuItem` decrements on its way out, so the
|
|
// slot the fly chose reads back one lower and stays inside the one-based range for ever.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu(3, 3).set(ram::wCurrentMenuItem, 3);
|
|
assert!(battle(&mut wram).unwrap().own_turn, "with the box drawn this is a real slot");
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu_stale(3, 3).set(ram::wCurrentMenuItem, 3);
|
|
assert!(!battle(&mut wram).unwrap().own_turn, "the same byte, no box, no turn");
|
|
|
|
// Half a figure is not a box. The junction tile at (10, 12) is the one `MoveSelectionMenu`
|
|
// writes over its own border, and a run of horizontals there is an ordinary text box.
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu(1, 3).screen_tile(poke::MOVE_LIST_JOIN, 12, poke::frame::HORIZONTAL);
|
|
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None, "the junction tile is read");
|
|
|
|
// And the pads the two frames are dealt, which is what row 50 costs: the four move buttons and
|
|
// `BACK` where a list is open (section 13.1), and the one `NEXT` that advances text where the
|
|
// turn is resolving (section 12.10).
|
|
use crate::pokemon_red::macros::palette::{MacroKind, scene_set};
|
|
let pad = |wram: &mut Wram| {
|
|
let scene = crate::pokemon_red::scene::detect(wram);
|
|
let mut poke = PokeState::new(wram);
|
|
scene_set(scene, &mut poke)
|
|
};
|
|
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu(1, 3);
|
|
assert_eq!(
|
|
pad(&mut wram),
|
|
vec![
|
|
MacroKind::Move1,
|
|
MacroKind::Move2,
|
|
MacroKind::Move3,
|
|
MacroKind::Move4,
|
|
MacroKind::Back
|
|
],
|
|
"an open move list"
|
|
);
|
|
|
|
let mut wram = Wram::overworld();
|
|
battler(&mut wram);
|
|
wram.move_menu_stale(1, 3);
|
|
assert_eq!(pad(&mut wram), vec![MacroKind::Next], "the same bytes with no box on screen");
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// Row 56: a two-option box is the one Red drew, not the one row 41 pinned.
|
|
///
|
|
/// Row 41 read the border at (11, 6)-(19, 11) because that is where the Pokémon Center's script
|
|
/// puts it, and named the limit in its own residual. The Pewter Gym guide's "Let me take you to the
|
|
/// top!" draws the same menu at **(14, 7)-(19, 11)** with the cursor at column 15, so the pinned
|
|
/// reading answered `false` on all 260 frames of a surveyed conversation while the box was drawn on
|
|
/// ten of them (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=dialog`). The pad was therefore
|
|
/// `NEXT, YES, NO` on a box that was a choice -- two channels for one A press, 12.10's forbidden
|
|
/// pair -- and the reopened-prompt exclusion never armed, because it only judges an answer to a
|
|
/// prompt this crate can read.
|
|
///
|
|
/// What is the same in both boxes is a fact about `DisplayTwoOptionMenu` rather than about a
|
|
/// script: the cursor goes in the box's first interior column. The top is not, so it is found.
|
|
#[test]
|
|
fn a_yes_no_prompt_is_the_box_drawn_around_the_cursor_wherever_red_draws_it() {
|
|
// The centre's own box, which row 41 surveyed: still a prompt.
|
|
let mut wram = Wram::overworld();
|
|
wram.yes_no_prompt();
|
|
assert!(yes_no_prompt(&mut wram), "the box at (11, 6)-(19, 11)");
|
|
|
|
// The gym guide's, three columns over and one row shorter. This is row 56.
|
|
let mut wram = Wram::overworld();
|
|
wram.gym_yes_no_prompt();
|
|
assert!(yes_no_prompt(&mut wram), "the box at (14, 7)-(19, 11)");
|
|
|
|
// Both halves stay load-bearing: the cursor bytes outlive the box on every other frame of the
|
|
// conversation, and a frame with no box drawn is not a choice.
|
|
let mut wram = Wram::overworld();
|
|
wram.dialogue_box().yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X);
|
|
assert!(!yes_no_prompt(&mut wram), "the cursor bytes with no box are not a prompt");
|
|
|
|
// The font flag gates it, exactly as it gates `waiting`.
|
|
let mut wram = Wram::overworld();
|
|
wram.gym_yes_no_prompt().set(ram::wFontLoaded, 0);
|
|
assert!(!yes_no_prompt(&mut wram), "no text display, no prompt");
|
|
|
|
// A box the cursor is not parked in is not the cursor's box: the left edge is one column left
|
|
// of the first item, and that is the whole of what ties the two together.
|
|
let mut wram = Wram::overworld();
|
|
wram.dialogue_box()
|
|
.draw_box(4, 7, 9, 11)
|
|
.yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X);
|
|
assert!(!yes_no_prompt(&mut wram), "a box somewhere else on the screen");
|
|
|
|
// And a menu of more than two options is not this menu, box or no box.
|
|
let mut wram = Wram::overworld();
|
|
wram.gym_yes_no_prompt().set(ram::wMaxMenuItem, 2);
|
|
assert!(!yes_no_prompt(&mut wram), "three options is not a two-option box");
|
|
|
|
// The pads the two frames are dealt: a readable choice is the choice's own answers and `NEXT`
|
|
// is off it (12.10 in a dialog), and a plain box of the same conversation keeps all three.
|
|
use crate::pokemon_red::macros::palette::{MacroKind, scene_set};
|
|
let pad = |wram: &mut Wram| {
|
|
let scene = crate::pokemon_red::scene::detect(wram);
|
|
let mut poke = PokeState::new(wram);
|
|
scene_set(scene, &mut poke)
|
|
};
|
|
|
|
let mut wram = Wram::overworld();
|
|
wram.gym_yes_no_prompt();
|
|
assert_eq!(
|
|
pad(&mut wram),
|
|
vec![MacroKind::Yes, MacroKind::No],
|
|
"the guide's box is a choice, so the pad is its answers"
|
|
);
|
|
|
|
let mut wram = Wram::overworld();
|
|
wram.dialogue_box().yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X);
|
|
assert_eq!(
|
|
pad(&mut wram),
|
|
vec![MacroKind::Next, MacroKind::Yes, MacroKind::No],
|
|
"a plain box of the same conversation"
|
|
);
|
|
}
|
|
|
|
#[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));
|
|
|
|
// The priced list byte with the item window drawn is the buy list.
|
|
wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU)
|
|
.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A);
|
|
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());
|
|
}
|
|
|
|
/// Row 55: the byte that says "the priced buy list" outlives the list, so which screen is up is
|
|
/// read from what is drawn.
|
|
///
|
|
/// Surveyed in the Pewter mart from the live checkpoint: `wListMenuID` held
|
|
/// `PRICEDITEMLISTMENU` on **every frame of the visit** -- the counter menu and the clerk's own
|
|
/// text boxes included -- because the mart prints its text from inside
|
|
/// `DisplayPokemartDialogue_` rather than through `DisplayTextIDInit`. The two things that do
|
|
/// change are the figure on screen: the full-width dialogue box for the clerk, and the item
|
|
/// window for the list.
|
|
#[test]
|
|
fn the_clerks_text_box_is_not_the_marts_buy_list() {
|
|
// The frame the stream looped on: the counter open, the priced-list byte stale, and the
|
|
// clerk's "Here you are! Thank you!" waiting for a press.
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU)
|
|
.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A)
|
|
.dialogue_box();
|
|
assert_eq!(
|
|
shop(&mut wram).map(|shop| shop.screen),
|
|
Some(ShopScreen::Talking),
|
|
"a dialogue box waiting for a press is the clerk, whatever the list byte says"
|
|
);
|
|
|
|
// The same counter with the box gone and the item window drawn: the buy list.
|
|
let mut wram = Wram::overworld();
|
|
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
|
|
.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU)
|
|
.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A);
|
|
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Buying));
|
|
|
|
// And with the item window blank: the BUY / SELL / QUIT menu underneath it.
|
|
wram.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::frame::HORIZONTAL);
|
|
assert_eq!(
|
|
shop(&mut wram).map(|shop| shop.screen),
|
|
Some(ShopScreen::BuySellQuit),
|
|
"no item name in the window means the list is not the thing on screen"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
|
|
// Row 58, the Pewter Gym from its doormat at (4, 13). The cartridge draws the guide; BROCK at
|
|
// (4, 1) and the Jr. Trainer at (3, 6) are outside `CheckSpriteAvailability`'s window, so it
|
|
// writes `$ff` into their image index and `npcs` -- which reports what is drawn -- skips them.
|
|
const STAY: u8 = 0xff;
|
|
let mut wram = Wram::overworld();
|
|
wram.map(0x36, 5, 7, 4, 13)
|
|
.npc(3, 0x2b, 7, 10, 0x00)
|
|
.npc_undrawn(1, 0x1f, 4, 1, STAY)
|
|
.npc_undrawn(2, 0x0e, 3, 6, STAY)
|
|
// Undrawn *inside* the window: switched off, or under a text box -- not the screen's doing.
|
|
.npc_undrawn(4, 0x05, 5, 11, STAY)
|
|
// Undrawn outside it, but a scripted mover, which the window test never hides.
|
|
.npc_undrawn(5, 0x05, 8, 1, 0x00);
|
|
let drawn: Vec<u8> = npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
|
assert_eq!(drawn, vec![3]);
|
|
let off: Vec<(u8, u8, u8)> =
|
|
offscreen_npcs(&mut wram).iter().map(|npc| (npc.slot, npc.x, npc.y)).collect();
|
|
assert_eq!(off, vec![(1, 4, 1), (2, 3, 6)], "the leader and the trainer, where they stand");
|
|
|
|
// Walk up the room and the trainer is inside the window: a `$ff` there is not the screen's.
|
|
wram.map(0x36, 5, 7, 4, 8);
|
|
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
|
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
|
|
}
|
|
|
|
/// Row 60's cartridge rows, as `data/moves/moves.asm` has them: `(id, effect, power, type)`.
|
|
const TACKLE: (u8, u8, u8, u8) = (0x21, 0x00, 35, 0x00);
|
|
const TAIL_WHIP: (u8, u8, u8, u8) = (0x27, 0x13, 0, 0x00);
|
|
const GROWL: (u8, u8, u8, u8) = (0x2d, 0x12, 0, 0x00);
|
|
const SCREECH: (u8, u8, u8, u8) = (0x67, 0x3b, 0, 0x00);
|
|
const WITHDRAW: (u8, u8, u8, u8) = (0x6e, 0x0b, 0, 0x15);
|
|
const SAND_ATTACK: (u8, u8, u8, u8) = (0x1c, 0x16, 0, 0x00);
|
|
const SLEEP_POWDER: (u8, u8, u8, u8) = (0x4f, 0x20, 0, 0x16);
|
|
const POISONPOWDER: (u8, u8, u8, u8) = (0x4d, 0x42, 0, 0x03);
|
|
const THUNDER_WAVE: (u8, u8, u8, u8) = (0x56, 0x43, 0, 0x17);
|
|
/// `AURORA_BEAM`: a damaging move with a stat side effect, which always does something.
|
|
const AURORA_BEAM: (u8, u8, u8, u8) = (0x3e, 0x44, 65, 0x19);
|
|
|
|
fn stage_battle() -> Wram {
|
|
let mut wram = Wram::new();
|
|
wram.battle_mon(0, 0xb1, 5, 8, 20, 0, &[(0x21, 35), (0x27, 30)])
|
|
.enemy_mon(0x24, 2, 13, 13)
|
|
.battle(1)
|
|
.normal_stages()
|
|
.move_table(&[
|
|
TACKLE,
|
|
TAIL_WHIP,
|
|
GROWL,
|
|
SCREECH,
|
|
WITHDRAW,
|
|
SAND_ATTACK,
|
|
SLEEP_POWDER,
|
|
POISONPOWDER,
|
|
THUNDER_WAVE,
|
|
AURORA_BEAM,
|
|
]);
|
|
wram
|
|
}
|
|
|
|
#[test]
|
|
fn the_move_table_is_read_by_id_and_checked_against_its_own_first_byte() {
|
|
let mut wram = stage_battle();
|
|
let tail_whip = move_data(&mut wram, TAIL_WHIP.0).expect("a row");
|
|
assert_eq!((tail_whip.effect, tail_whip.power), (0x13, 0));
|
|
assert_eq!(move_data(&mut wram, 0), None, "no move zero");
|
|
assert_eq!(move_data(&mut wram, 0xa6), None, "past STRUGGLE");
|
|
assert_eq!(move_data(&mut wram, 0x22), None, "a row the cartridge image does not answer");
|
|
// A table that is not where the disassembly says: a row that does not open with its own id
|
|
// is somebody else's row, and it is not read as this one.
|
|
let mut shifted = Wram::new();
|
|
shifted.move_table(&[(0x28, 0x13, 0, 0)]);
|
|
let base = poke::moves::TABLE_ADDRESS + 0x26 * poke::moves::ROW_BYTES;
|
|
for offset in 0..6 {
|
|
let byte = shifted.read_rom(poke::moves::TABLE_BANK, base + 6 + offset).unwrap();
|
|
shifted.rom_byte(poke::moves::TABLE_BANK, base + offset, byte);
|
|
}
|
|
assert_eq!(move_data(&mut shifted, 0x27), None);
|
|
assert!(move_data(&mut shifted, 0x28).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn tail_whip_does_nothing_at_minus_six_and_something_before() {
|
|
// Row 60: the Pidgey's DEFENSE stage walked down from 7 to 1 by TAIL WHIP after TAIL WHIP.
|
|
let mut wram = stage_battle();
|
|
for stage in 2..=7 {
|
|
wram.set(ram::wEnemyMonStatMods + 1, stage);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false), "stage {stage}");
|
|
}
|
|
wram.set(ram::wEnemyMonStatMods + 1, 1);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
// The -2 variant reads the same stage.
|
|
assert_eq!(move_without_effect(&mut wram, SCREECH.0), Some(true));
|
|
// TACKLE deals damage whatever the stages say.
|
|
assert_eq!(move_without_effect(&mut wram, TACKLE.0), Some(false));
|
|
// And a damaging move with a stat side effect is never "nothing".
|
|
wram.set(ram::wEnemyMonStatMods + 3, 1);
|
|
assert_eq!(move_without_effect(&mut wram, AURORA_BEAM.0), Some(false));
|
|
// GROWL reads ATTACK's stage, not DEFENSE's.
|
|
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(false));
|
|
wram.set(ram::wEnemyMonStatMods, 1);
|
|
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(true));
|
|
// SAND-ATTACK reads ACCURACY's, which has no stat value behind it.
|
|
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(false));
|
|
wram.set(ram::wEnemyMonStatMods + 4, 1);
|
|
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn a_stat_already_at_one_or_nine_hundred_ninety_nine_refuses_before_the_stage_does() {
|
|
// `StatModifierDownEffect` restores the stage and prints "Nothing happened!" when the stat
|
|
// itself is already 1 -- a level-2 Pidgey's DEFENSE gets there before -6.
|
|
let mut wram = stage_battle();
|
|
wram.set(ram::wEnemyMonStatMods + 1, 4).set_word_be(ram::wEnemyMonAttack + 2, 1);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
wram.set_word_be(ram::wEnemyMonAttack + 2, 2);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false));
|
|
// Raising: +6, or a stat of 999.
|
|
wram.set(ram::wPlayerMonStatMods + 1, 12);
|
|
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
|
|
wram.set(ram::wPlayerMonStatMods + 1, 13);
|
|
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
|
|
wram.set(ram::wPlayerMonStatMods + 1, 9).set_word_be(ram::wBattleMonAttack + 2, 999);
|
|
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn mist_and_a_substitute_turn_a_stat_lowering_move_away() {
|
|
let mut wram = stage_battle();
|
|
wram.set(ram::wEnemyBattleStatus2, poke::moves::MIST);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
wram.set(ram::wEnemyBattleStatus2, poke::moves::SUBSTITUTE);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
assert_eq!(move_without_effect(&mut wram, POISONPOWDER.0), Some(true));
|
|
// A raise is the user's own business.
|
|
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_status_move_against_a_target_it_cannot_affect_does_nothing() {
|
|
let mut wram = stage_battle();
|
|
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
|
|
assert_eq!(move_without_effect(&mut wram, id), Some(false), "healthy target, {id:#04x}");
|
|
}
|
|
// Any status: already asleep, poisoned, paralysed.
|
|
wram.set(ram::wEnemyMonStatus, 1 << 6);
|
|
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
|
|
assert_eq!(move_without_effect(&mut wram, id), Some(true), "statused target, {id:#04x}");
|
|
}
|
|
// A target that must recharge is put to sleep whatever its status (`SleepEffect`).
|
|
wram.set(ram::wEnemyBattleStatus2, poke::moves::RECHARGE);
|
|
assert_eq!(move_without_effect(&mut wram, SLEEP_POWDER.0), Some(false));
|
|
// Types: a Poison type is not poisoned; a Ground type is not paralysed by an Electric move.
|
|
let mut typed = stage_battle();
|
|
typed.set(ram::wEnemyMonType1 + 1, poke::moves::TYPE_POISON);
|
|
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(true));
|
|
typed.set(ram::wEnemyMonType1 + 1, 0).set(ram::wEnemyMonType1, poke::moves::TYPE_GROUND);
|
|
assert_eq!(move_without_effect(&mut typed, THUNDER_WAVE.0), Some(true));
|
|
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_refusal_that_cannot_be_read_is_not_reported() {
|
|
// Out of battle, no cartridge behind the seam, or a stage byte out of its 1..=13 range.
|
|
let mut wram = stage_battle();
|
|
wram.set(ram::wIsInBattle, 0);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
|
|
let mut bare = Wram::new();
|
|
bare.battle(1).normal_stages().set(ram::wEnemyMonStatMods + 1, 1);
|
|
assert_eq!(move_without_effect(&mut bare, TAIL_WHIP.0), None, "no move table");
|
|
let mut wram = stage_battle();
|
|
wram.set(ram::wEnemyMonStatMods + 1, 0);
|
|
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
|
|
// And the seam's own answer is "leave the button where it was".
|
|
let mut unread = Wram::new();
|
|
assert!(!PokeState::new(&mut unread).move_without_effect(TAIL_WHIP.0));
|
|
let mut read = stage_battle();
|
|
read.set(ram::wEnemyMonStatMods + 1, 1);
|
|
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0));
|
|
}
|