From 438b2d8540e9a4c2ee1014fdd27122684bb82c6f Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:38:54 +0000 Subject: [PATCH] state: the move table, and whether the battle engine will answer a move with nothing 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. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 32 ++++ .../src/pokemon_red/macros/cartridge.rs | 11 ++ .../flybrain-gb/src/pokemon_red/state.rs | 160 ++++++++++++++++++ .../src/pokemon_red/state/tests.rs | 155 +++++++++++++++++ 4 files changed, 358 insertions(+) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index b7b47c9..ce4fe31 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -416,6 +416,38 @@ impl Wram { pub const BLOCKSET_BANK: u8 = 0x11; pub const BLOCKSET_BASE: u16 = 0x4000; + /// Rows of the cartridge's move table, where `data/moves/moves.asm` puts it: `$0E:$4000`, + /// six bytes a row in move-id order, each opening with its own id. `(id, effect, power, + /// type)`; accuracy 100 and PP 35 stand in for the two bytes nothing here reads. + pub fn move_table(&mut self, rows: &[(u8, u8, u8, u8)]) -> &mut Self { + use super::state::poke::moves::{ROW_BYTES, TABLE_ADDRESS, TABLE_BANK}; + for (id, effect, power, kind) in rows { + let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES; + for (offset, byte) in [*id, *effect, *power, *kind, 0xff, 35].into_iter().enumerate() { + self.rom.insert((TABLE_BANK, base + offset as u16), byte); + } + } + self + } + + /// One byte of a fake cartridge bank. + pub fn rom_byte(&mut self, bank: u8, address: u16, byte: u8) -> &mut Self { + self.rom.insert((bank, address), byte); + self + } + + /// Every stage of both battlers at normal (7), as `InitBattleVariables`-era code leaves them. + pub fn normal_stages(&mut self) -> &mut Self { + for stat in 0..6 { + self.set(ram::wPlayerMonStatMods + stat, 7).set(ram::wEnemyMonStatMods + stat, 7); + } + for stat in 0..4 { + self.set_word_be(ram::wBattleMonAttack + 2 * stat, 12) + .set_word_be(ram::wEnemyMonAttack + 2 * stat, 9); + } + self + } + /// Which tileset the loaded map uses, for the tile-pair collision lists. pub fn tileset(&mut self, id: u8) -> &mut Self { self.set(ram::wCurMapTileset, id) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 27ae9cc..6704438 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -377,6 +377,17 @@ pub trait MacroState: GameState { false } + /// Whether the battle engine will answer the fly's move `id` with nothing on this frame: a + /// stat stage already at its limit, a status move against a target it cannot affect. + /// + /// Row 60 (`docs/design/macros.md` 12.23), read from the cartridge's own move table and the + /// bytes its effect routines test ([`crate::pokemon_red::state::move_without_effect`]). The + /// default is `false`: a state that cannot read the table has proved nothing, so the `MOVE n` + /// buttons stay where they were. + fn move_without_effect(&mut self, _id: u8) -> bool { + false + } + /// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable. /// /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 13e7582..254d52c 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -232,6 +232,54 @@ pub mod poke { /// this fixed point. pub const PLAYER_SCREEN_X: i32 = 8; pub const PLAYER_SCREEN_Y: i32 = 9; + + /// The cartridge's move table and the battle engine's answers to it (row 60, + /// `docs/design/macros.md` 12.23). + pub mod moves { + /// `data/moves/moves.asm`: `Moves` opens `SECTION "Battle Engine 7"`, which + /// `layout.link` places first in ROM bank `$0E`, so the table starts at `$0E:$4000`. + /// Six bytes a row (`MOVE_LENGTH`): animation (the move id itself), effect, power, + /// type, accuracy, PP, rows in move-id order from `POUND` (1). + pub const TABLE_BANK: u8 = 0x0e; + pub const TABLE_ADDRESS: u16 = 0x4000; + pub const ROW_BYTES: u16 = 6; + /// `constants/move_constants.asm`: `NUM_ATTACKS`, `STRUGGLE` (`$a5`) the last. + pub const LAST_MOVE: u8 = 0xa5; + + /// `constants/move_effect_constants.asm`: the stat-stage effects, each run in stage + /// order ATTACK, DEFENSE, SPEED, SPECIAL, ACCURACY, EVASION. + pub const ATTACK_UP1: u8 = 0x0a; + pub const EVASION_UP1: u8 = 0x0f; + pub const ATTACK_DOWN1: u8 = 0x12; + pub const EVASION_DOWN1: u8 = 0x17; + pub const SLEEP: u8 = 0x20; + pub const ATTACK_UP2: u8 = 0x32; + pub const EVASION_UP2: u8 = 0x37; + pub const ATTACK_DOWN2: u8 = 0x3a; + pub const EVASION_DOWN2: u8 = 0x3f; + pub const POISON: u8 = 0x42; + pub const PARALYZE: u8 = 0x43; + + /// `constants/battle_constants.asm`: a stage byte is 1 (-6) to `MAX_STAT_LEVEL` 13 (+6), + /// 7 normal; `MAX_STAT_VALUE` 999. The first four stages have a stat behind them + /// (`wBattleMonAttack` onwards, big-endian words); accuracy and evasion do not. + pub const MIN_STAGE: u8 = 1; + pub const MAX_STAGE: u8 = 13; + pub const STATS_WITH_VALUES: u8 = 4; + pub const MIN_STAT: u16 = 1; + pub const MAX_STAT: u16 = 999; + + /// `wEnemyBattleStatus2` bits: `PROTECTED_BY_MIST` 1, `HAS_SUBSTITUTE_UP` 4, + /// `NEEDS_TO_RECHARGE` 5. + pub const MIST: u8 = 1 << 1; + pub const SUBSTITUTE: u8 = 1 << 4; + pub const RECHARGE: u8 = 1 << 5; + + /// `constants/type_constants.asm`. + pub const TYPE_POISON: u8 = 0x03; + pub const TYPE_GROUND: u8 = 0x04; + pub const TYPE_ELECTRIC: u8 = 0x17; + } } fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 { @@ -625,6 +673,114 @@ fn enemy_mon(memory: &mut dyn MemoryReader) -> Option { }) } +/// One row of the cartridge's move table (`data/moves/moves.asm`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoveData { + pub id: u8, + pub effect: u8, + pub power: u8, + pub kind: u8, + pub accuracy: u8, + pub pp: u8, +} + +/// Move `id`'s row of the move table, read from the cartridge image. +/// +/// `None` when the seam has no cartridge behind it, when `id` is not a move, or when the row does +/// not open with its own id -- every row of `Moves` does (`move`'s first byte is the animation, +/// "interchangeable with move id"), so a table that is not where the disassembly puts it answers +/// nothing rather than a neighbour's effect. +pub fn move_data(memory: &mut dyn MemoryReader, id: u8) -> Option { + use poke::moves::{LAST_MOVE, ROW_BYTES, TABLE_ADDRESS, TABLE_BANK}; + if id == 0 || id > LAST_MOVE { + return None; + } + let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES; + let mut row = [0u8; 6]; + for (offset, byte) in row.iter_mut().enumerate() { + *byte = memory.read_rom(TABLE_BANK, base + offset as u16)?; + } + if row[0] != id { + return None; + } + Some(MoveData { + id, + effect: row[1], + power: row[2], + kind: row[3], + accuracy: row[4], + pp: row[5], + }) +} + +/// Whether the battle engine will answer the fly's move `id` with nothing at all, on this frame. +/// +/// Row 60 (`docs/design/macros.md` 12.23): Squirtle's TAIL WHIP against a Pidgey whose DEFENSE +/// was already at -6 printed "Nothing happened!" 183 times on Route 1. These are the refusals the +/// effect routines in `engine/battle/effects.asm` make on bytes that are already in WRAM when the +/// move is chosen, for a move that deals no damage (a move with power always does something): +/// +/// - a stat-raising effect (`StatModifierUpEffect`): the user's stage is already +6, or the stat +/// itself is already 999; +/// - a stat-lowering effect (`StatModifierDownEffect`, `MoveHitTest`): the target has a +/// substitute or Mist, its stage is already -6, or the stat itself is already 1; +/// - `SleepEffect`: the target already has a status and is not recharging; +/// - `PoisonEffect`: a substitute, a status, or a Poison type; +/// - `ParalyzeEffect`: a status, or an Electric move against a Ground type. +/// +/// `Some(false)` for every other move, which the cartridge may still miss -- a miss is a roll, +/// and this answers only what is already decided. `None` outside a battle this module +/// understands, when the move table cannot be read, or when a stage byte is out of its range: +/// a refusal this module cannot read is not one it reports. +pub fn move_without_effect(memory: &mut dyn MemoryReader, id: u8) -> Option { + use poke::moves::*; + in_battle(memory)?; + let data = move_data(memory, id)?; + if data.power != 0 { + return Some(false); + } + let stage = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option { + Some(read(memory, base + u16::from(stat))).filter(|stage| (MIN_STAGE..=MAX_STAGE).contains(stage)) + }; + let value = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option { + (stat < STATS_WITH_VALUES).then(|| word_be(memory, base + 2 * u16::from(stat))) + }; + let effect = data.effect; + let raised = match effect { + ATTACK_UP1..=EVASION_UP1 => Some(effect - ATTACK_UP1), + ATTACK_UP2..=EVASION_UP2 => Some(effect - ATTACK_UP2), + _ => None, + }; + if let Some(stat) = raised { + let at = stage(memory, ram::wPlayerMonStatMods, stat)?; + return Some(at >= MAX_STAGE || value(memory, ram::wBattleMonAttack, stat) == Some(MAX_STAT)); + } + let target = read(memory, ram::wEnemyBattleStatus2); + let lowered = match effect { + ATTACK_DOWN1..=EVASION_DOWN1 => Some(effect - ATTACK_DOWN1), + ATTACK_DOWN2..=EVASION_DOWN2 => Some(effect - ATTACK_DOWN2), + _ => None, + }; + if let Some(stat) = lowered { + let at = stage(memory, ram::wEnemyMonStatMods, stat)?; + return Some( + target & (SUBSTITUTE | MIST) != 0 + || at <= MIN_STAGE + || value(memory, ram::wEnemyMonAttack, stat) == Some(MIN_STAT), + ); + } + let status = read(memory, ram::wEnemyMonStatus); + let types = [read(memory, ram::wEnemyMonType1), read(memory, ram::wEnemyMonType1 + 1)]; + Some(match effect { + SLEEP => status != 0 && target & RECHARGE == 0, + POISON => target & SUBSTITUTE != 0 || status != 0 || types.contains(&TYPE_POISON), + PARALYZE => { + status != 0 || (data.kind == TYPE_ELECTRIC && types.contains(&TYPE_GROUND)) + } + _ => false, + }) +} + /// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn. /// /// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the @@ -1782,6 +1938,10 @@ impl MacroState for PokeState<'_> { yes_no_prompt(self.memory) } + fn move_without_effect(&mut self, id: u8) -> bool { + move_without_effect(self.memory, id).unwrap_or(false) + } + /// The whole loaded map's walkability, from the cache when it is for this map /// (`docs/design/macros.md` section 15). /// diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index f5a8bdd..751915c 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -1067,3 +1067,158 @@ fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() { let off: Vec = 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)); +}