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.
This commit is contained in:
acamilo 2026-09-23 15:38:54 +00:00
parent c8ecf06db5
commit 438b2d8540
4 changed files with 358 additions and 0 deletions

View file

@ -416,6 +416,38 @@ impl Wram {
pub const BLOCKSET_BANK: u8 = 0x11; pub const BLOCKSET_BANK: u8 = 0x11;
pub const BLOCKSET_BASE: u16 = 0x4000; 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. /// Which tileset the loaded map uses, for the tile-pair collision lists.
pub fn tileset(&mut self, id: u8) -> &mut Self { pub fn tileset(&mut self, id: u8) -> &mut Self {
self.set(ram::wCurMapTileset, id) self.set(ram::wCurMapTileset, id)

View file

@ -377,6 +377,17 @@ pub trait MacroState: GameState {
false 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. /// 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 /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state

View file

@ -232,6 +232,54 @@ pub mod poke {
/// this fixed point. /// this fixed point.
pub const PLAYER_SCREEN_X: i32 = 8; pub const PLAYER_SCREEN_X: i32 = 8;
pub const PLAYER_SCREEN_Y: i32 = 9; 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 { fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
@ -625,6 +673,114 @@ fn enemy_mon(memory: &mut dyn MemoryReader) -> Option<EnemyMon> {
}) })
} }
/// 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<MoveData> {
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<bool> {
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<u8> {
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<u16> {
(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. /// 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 /// `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) 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 /// The whole loaded map's walkability, from the cache when it is for this map
/// (`docs/design/macros.md` section 15). /// (`docs/design/macros.md` section 15).
/// ///

View file

@ -1067,3 +1067,158 @@ fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect(); 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)"); 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));
}