From c8ecf06db59170ce6fdfe7331d8896bf1147d533 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:17:52 +0000 Subject: [PATCH 1/7] symbols: the stat stages and the enemy's status, type and stats, resolved from wram.asm resolve_wram.py now follows the decomp's const and _RS counters and a struct macro's field labels, so battle_struct fields resolve and 77 of 81 pinned addresses are re-derived with no disagreement. --- .../flybrain-gb/src/pokemon_red/symbols.rs | 7 + services/flysim/tools/resolve_wram.py | 142 +++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs index e62c245..dc8d753 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs @@ -23,6 +23,8 @@ pub mod ram { pub const wMaxMenuItem: u16 = 0xcc28; // 52264 pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265 pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271 + pub const wPlayerMonStatMods: u16 = 0xcd1a; // 52506 + pub const wEnemyMonStatMods: u16 = 0xcd2e; // 52526 pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536 pub const wMiscFlags: u16 = 0xcd60; // 52576 pub const wJoyIgnore: u16 = 0xcd6b; // 52587 @@ -35,19 +37,24 @@ pub mod ram { pub const wWalkCounter: u16 = 0xcfc5; // 53189 pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221 pub const wEnemyMonHP: u16 = 0xcfe6; // 53222 + pub const wEnemyMonStatus: u16 = 0xcfe9; // 53225 + pub const wEnemyMonType1: u16 = 0xcfea; // 53226 pub const wEnemyMonLevel: u16 = 0xcff3; // 53235 pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236 + pub const wEnemyMonAttack: u16 = 0xcff6; // 53238 pub const wBattleMonSpecies: u16 = 0xd014; // 53268 pub const wBattleMonHP: u16 = 0xd015; // 53269 pub const wBattleMonStatus: u16 = 0xd018; // 53272 pub const wBattleMonMoves: u16 = 0xd01c; // 53276 pub const wBattleMonLevel: u16 = 0xd022; // 53282 pub const wBattleMonMaxHP: u16 = 0xd023; // 53283 + pub const wBattleMonAttack: u16 = 0xd025; // 53285 pub const wBattleMonPP: u16 = 0xd02d; // 53293 pub const wTrainerClass: u16 = 0xd031; // 53297 pub const wIsInBattle: u16 = 0xd057; // 53335 pub const wBattleType: u16 = 0xd05a; // 53338 pub const wTrainerNo: u16 = 0xd05d; // 53341 + pub const wEnemyBattleStatus2: u16 = 0xd068; // 53352 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 diff --git a/services/flysim/tools/resolve_wram.py b/services/flysim/tools/resolve_wram.py index 1572a84..ad0c2ca 100644 --- a/services/flysim/tools/resolve_wram.py +++ b/services/flysim/tools/resolve_wram.py @@ -73,6 +73,22 @@ WANTED = { 'wToggleableObjectList': "this map's toggleable sprites and their global indices", # One bit per hidden item, set by FoundHiddenItemText once GiveItem succeeded. 'wObtainedHiddenItemsFlags': 'hidden items already found', + # Row 60 (`docs/design/macros.md` 12.23): a move the cartridge will answer + # with "Nothing happened!" is not dealt. StatModifierUpEffect and + # StatModifierDownEffect (engine/battle/effects.asm) refuse on the stage byte + # (1 is -6, 7 normal, 13 is +6) and on the modified stat itself (1 or 999); + # SleepEffect, PoisonEffect and ParalyzeEffect on the target's status byte and + # type. Each is the battle_struct field or stage array those routines read. + 'wPlayerMonStatMods': "the active Pokemon's six stat stages, 7 is normal", + 'wEnemyMonStatMods': "the enemy's six stat stages, 7 is normal", + 'wEnemyMonStatus': "the enemy's status condition byte", + 'wEnemyMonType1': "the enemy's first type (wEnemyMonType2 follows it)", + 'wEnemyMonAttack': "the enemy's modified Attack, Defense, Speed, Special", + 'wBattleMonAttack': "the active Pokemon's modified Attack, Defense, Speed, Special", + # Mist and a substitute turn a stat-lowering move away, a substitute a + # poisoning one, and a target that must recharge is put to sleep whatever + # its status (MoveHitTest, CheckTargetSubstitute, SleepEffect). + 'wEnemyBattleStatus2': "the enemy's Mist, substitute and recharge bits", } @@ -141,10 +157,14 @@ def constants(root: Path) -> dict[str, int]: (root / 'constants').glob('*.inc') ) for path in sources: + text = path.read_text() for name, value in re.findall( - r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M + r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', text, re.M ): pending.setdefault(name, value.strip()) + # A name counted by `const` or `rb` is an expression over the running + # counter at its own line, so it overrides the raw `EQU const_value - 1`. + pending.update(enumerated(text)) out: dict[str, int] = dict(counted) while pending: progressed = False @@ -166,6 +186,91 @@ def constants(root: Path) -> dict[str, int]: return out +def enumerated(text: str) -> dict[str, str]: + """The names one constants file defines by counting, as expressions. + + rgbasm keeps two running counters the decomp enumerates with: `const_value` + (`const_def`, `const`, `const_skip`, `const_next` in macros/const.asm) and + `_RS` (`rsreset`, `rsset`, `DEF NAME rb/rw n`, `rb_skip`). This follows both + in file order and writes each name down as the *expression* the counter held + at its line, never as a number: [`constants`]' passes evaluate it with the + rest, so a count over a constant this tool cannot resolve stays unresolved + rather than becoming a guess. A counter form it does not know (a + `shift_const`, a non-literal step) kills that counter until the next reset. + """ + out: dict[str, str] = {} + value: str | None = None + step = '1' + rs: str | None = None + for raw in text.splitlines(): + line = raw.split(';')[0].strip() + if not line: + continue + match = re.fullmatch(r'const_def(?:\s+([^,]+?))?(?:\s*,\s*(.+))?', line) + if match: + value = f'({match.group(1) or "0"})' + step = f'({match.group(2) or "1"})' + continue + match = re.fullmatch(r'(?:const|const_export)\s+(\w+)', line) + if match: + if value is not None: + out[match.group(1)] = value + value = f'({value} + {step})' + continue + match = re.fullmatch(r'const_skip(?:\s+(.+))?', line) + if match: + if value is not None: + value = f'({value} + {step} * ({match.group(1) or "1"}))' + continue + match = re.fullmatch(r'const_next\s+(.+)', line) + if match: + value = f'({match.group(1)})' + continue + if line.startswith(('shift_const', 'dw_const')): + value = None + continue + if line == 'rsreset': + rs = '(0)' + continue + match = re.fullmatch(r'rsset\s+(.+)', line) + if match: + rs = f'({match.group(1)})' if '_RS' not in match.group(1) else None + continue + match = re.fullmatch(r'(rb|rw)_skip(?:\s+(.+))?', line) + if match: + if rs is not None: + unit = 1 if match.group(1) == 'rb' else 2 + rs = f'({rs} + {unit} * ({match.group(2) or "1"}))' + continue + match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(rb|rw)(?:\s+(.+))?', line) + if match: + if rs is not None: + out[match.group(1)] = rs + unit = 1 if match.group(2) == 'rb' else 2 + rs = f'({rs} + {unit} * ({match.group(3) or "1"}))' + continue + match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+(.+)', line) + if match is None: + # Anything else may be a macro that moves a counter this does not + # follow (`add_tm` advances `const_value`), so both stop here. + if not line.startswith(('ASSERT', 'assert', 'EXPORT', 'export')): + value = None + rs = None + continue + if re.search(r'\b(?:_RS|const_value)\b', match.group(2)): + expression = match.group(2) + if '_RS' in expression: + if rs is None: + continue + expression = re.sub(r'\b_RS\b', rs, expression) + if 'const_value' in expression: + if value is None: + continue + expression = re.sub(r'\bconst_value\b', value, expression) + out[match.group(1)] = expression + return out + + def number(token: str) -> int: token = token.strip() if token.startswith('$'): @@ -213,16 +318,27 @@ def size_of(expression: str, known: dict[str, int]) -> int: return value * scale -def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: - """Sizes of the RAM struct macros, counted from their own declarations.""" +def macro_sizes( + root: Path, known: dict[str, int], fields: dict[str, list[tuple[str, int]]] | None = None +) -> dict[str, int]: + """Sizes of the RAM struct macros, counted from their own declarations. + + With `fields`, also each macro's `\\1Name::` field labels and their offsets, + for a macro whose whole body was sized: `battle_struct wEnemyMon` declares + `wEnemyMonStatus` at the offset its own lines put it. + """ out: dict[str, int] = {} for path in sorted((root / 'macros').glob('*.asm')): text = path.read_text() for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S): name, body = match.group(1), match.group(2) total = 0 + offsets: list[tuple[str, int]] = [] for line in body.splitlines(): line = line.split(';')[0].strip() + field = re.match(r'^\\1(\w+)::', line) + if field is not None and total is not None: + offsets.append((field.group(1), total)) # A struct macro labels each field with its argument # (`\\1YCoord:: db`), so the label is stripped and the # declaration after it is what reserves the bytes. @@ -246,6 +362,8 @@ def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: break if total is not None: out[name] = total + if fields is not None: + fields[name] = offsets return out @@ -253,7 +371,8 @@ def walk( root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False ) -> tuple[dict[str, int], list[str], int]: """Resolve every symbol of wram.asm the anchored cursor can reach exactly.""" - macros = macro_sizes(root, known) + fields: dict[str, list[tuple[str, int]]] = {} + macros = macro_sizes(root, known, fields) lines = (root / 'ram/wram.asm').read_text().splitlines() cursor: int | None = None resolved: dict[str, int] = {} @@ -370,6 +489,21 @@ def walk( continue if line.endswith('::') or re.fullmatch(r'\.\w+', line): continue + # A struct macro's own field labels, at the offsets its body puts them: + # held back like any other label until the next pinned address agrees. + invocation = re.fullmatch(r'(\w+)\s+(w\w+)', line) + if invocation is not None and cursor is not None: + for field, offset in fields.get(invocation.group(1), []): + name = invocation.group(2) + field + if name not in table: + pending_run[name] = cursor + offset + elif table[name] != cursor + offset: + problems.append( + f'{name}: wram.asm gives ${cursor + offset:04x}, ' + f'symbols.rs pins ${table[name]:04x}' + ) + else: + checked += 1 try: if cursor is not None: cursor += declaration_size(line, known, macros) From 438b2d8540e9a4c2ee1014fdd27122684bb82c6f Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:38:54 +0000 Subject: [PATCH 2/7] 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)); +} From f3e3e9cf983fb9990802a94ee20be605bd558463 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:38:55 +0000 Subject: [PATCH 3/7] palette: a move the cartridge answers with nothing is not dealt beside one it does not Row 60: TAIL WHIP against a Pidgey at DEFENSE -6 was MOVE 2 183 times. The rule is PP's: a refused move leaves the pad while another move is usable, and with none usable the moves stay as PP deals them, so an open list never comes down to BACK alone. --- .../src/pokemon_red/macros/palette.rs | 33 +++++-- .../src/pokemon_red/macros/tests.rs | 96 +++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index c0bdae3..588fc86 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -1893,7 +1893,8 @@ pub const fn move_index(kind: MacroKind) -> Option { } } -/// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14). +/// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with +/// nothing: the four buttons' precondition (section 14, row 60). /// /// Three things it is *not*, each of them a bug this palette has had: /// @@ -1921,23 +1922,41 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool { // all, so the button is bound there whatever the seam can make of the battler. That is the // backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has // a button that ends the turn, and it is never one that merely reopens a list. - if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) { - return true; - } + let main = matches!(battle.menu, BattleMenu::Main { .. }); // And the same backstop over an **open move list** whose battler the seam cannot read // (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the // list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in // `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so // it reads no move either, and confirming a move is what ends a turn. let Some(own) = battle.own else { - return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }); + return index == 0 + && (main || matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. })); }; let holds = |slot: usize| -> Option<&Move> { own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) }; - let Some(entry) = holds(usize::from(index)) else { return false }; + // **A move the cartridge will answer with nothing is not dealt beside one it will not** + // (row 60, section 12.23). Live on Route 1: Squirtle's TAIL WHIP against a Pidgey whose + // DEFENSE was already at -6 was `MOVE 2` 183 times, "Nothing happened!" every time, and no + // battle ended by the fly's hand. What the move does is the move table's and the effect + // routine's answer ([`MacroState::move_without_effect`]), read the same way for every move, + // and it is PP's rule over again: a spent move is not offered beside a usable one, and when + // nothing is usable what was dealt stays dealt -- taking the last moves away would leave a list + // whose only button is `BACK`, which is 12.11's pair. + let mut useful = [false; 4]; + for (slot, flag) in useful.iter_mut().enumerate() { + if let Some(entry) = holds(slot).copied() { + *flag = entry.pp > 0 && !state.move_without_effect(entry.id); + } + } + let any_useful = useful.iter().any(|flag| *flag); + let entry = holds(usize::from(index)).copied(); + if index == 0 && main { + return !any_useful || useful[0] || entry.is_none_or(|entry| entry.pp == 0); + } + let Some(entry) = entry else { return false }; if entry.pp > 0 { - return true; + return useful[usize::from(index)] || !any_useful; } // Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the // fly would be offered a spent move beside a usable one. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index 097ba80..fc7d051 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -111,6 +111,8 @@ struct World { money: u32, bag: Vec<(u8, u8)>, stock: Vec, + /// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60). + no_effect: BTreeSet, /// Tiles the game lets the player talk *over*: a mart's or a centre's counter. counters: BTreeSet, /// Errands this run has discharged (`docs/design/macros.md` section 13). @@ -242,6 +244,7 @@ impl World { pushes: BTreeSet::new(), exhausted: BTreeSet::new(), stock: Vec::new(), + no_effect: BTreeSet::new(), visited: BTreeSet::new(), stood: BTreeSet::new(), seen_maps: BTreeSet::new(), @@ -696,6 +699,10 @@ impl MacroState for World { self.prompt && self.scene == Scene::Dialog } + fn move_without_effect(&mut self, id: u8) -> bool { + self.no_effect.contains(&id) + } + fn shop_stock(&mut self) -> Vec { self.stock.clone() } @@ -3939,6 +3946,95 @@ fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() { assert!(pad_of(&mut world).contains(&"MOVE 1")); } +/// `constants/move_constants.asm`: the two moves a level-5 Squirtle knows. +const TACKLE: u8 = 0x21; +const TAIL_WHIP: u8 = 0x27; + +#[test] +fn a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not() { + // Row 60, live on Route 1: Squirtle L5 with TACKLE and TAIL WHIP, a Pidgey whose DEFENSE is + // already at -6, and `MOVE 2` chosen 183 times to "Nothing happened!". Over the menu and over + // the open list, TAIL WHIP leaves the pad and TACKLE stays. + let mut world = World::battle(); + world.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])]; + world.active = Some(0); + world.list = List::BattleMain; + assert!(on_the_pad(&mut world, MacroKind::Move2), "before the stage is at its limit"); + + world.no_effect.insert(TAIL_WHIP); + for list in [List::BattleMain, List::Moves(2)] { + world.list = list; + world.grid = list == List::BattleMain; + let pad = pad_of(&mut world); + assert!(!pad.contains(&"MOVE 2"), "{list:?} deals {pad:?}"); + assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}"); + } + // Nothing presses for the fly: the button is gone, and nothing is chosen in its place. + world.list = List::BattleMain; + world.grid = true; + assert!(!move_slot_bound(&mut world, MacroKind::Move2)); + + // Slot one is read the same way: FIGHT's backstop over the menu is not a way to deal a move + // that does nothing beside one that does. + let mut swapped = World::battle(); + swapped.mons = vec![mon(0, 8, 20, &[(TAIL_WHIP, 30), (TACKLE, 35)])]; + swapped.active = Some(0); + swapped.list = List::BattleMain; + swapped.no_effect.insert(TAIL_WHIP); + assert_eq!( + pad_of(&mut swapped).iter().filter(|name| name.starts_with("MOVE")).collect::>(), + [&"MOVE 2"] + ); +} + +#[test] +fn with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them() { + // PP's own rule (section 12.8, row 30a): with nothing usable, what ends the turn stays on the + // pad. Taking every move away over an open list would leave `BACK` alone, which closes what + // `MOVE 1` on the menu underneath opened -- 12.11's pair. + let mut world = World::battle(); + world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30)])]; + world.active = Some(0); + world.no_effect.insert(TAIL_WHIP); + world.list = List::Moves(2); + world.grid = false; + world.cursor_max = 1; + let pad = pad_of(&mut world); + assert!(pad.contains(&"MOVE 2"), "the one move with PP still ends the turn: {pad:?}"); + assert_ne!(pad, ["BACK"]); + + world.list = List::BattleMain; + world.grid = true; + let pad = pad_of(&mut world); + assert!(pad.contains(&"MOVE 1") && pad.contains(&"MOVE 2"), "{pad:?}"); + + // And both moves without effect: nothing changes from what PP alone deals. + let mut both = World::battle(); + both.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])]; + both.active = Some(0); + both.list = List::BattleMain; + let before = pad_of(&mut both); + both.no_effect.extend([TACKLE, TAIL_WHIP]); + assert_eq!(pad_of(&mut both), before); +} + +#[test] +fn a_spent_move_and_a_move_without_effect_leave_the_one_that_works() { + // The two readings together: slot one spent, slot two refused, slot three usable. Only + // `MOVE 3` is a move; `MOVE 1` over the menu is FIGHT's backstop only while slot one is the + // thing that can end the turn, and it is spent -- row 34's behaviour, unchanged. + let mut world = World::battle(); + world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30), (0x2d, 40)])]; + world.active = Some(0); + world.no_effect.insert(TAIL_WHIP); + world.list = List::Moves(3); + world.grid = false; + world.cursor_max = 2; + let moves: Vec<&str> = + pad_of(&mut world).into_iter().filter(|name| name.starts_with("MOVE")).collect(); + assert_eq!(moves, ["MOVE 3"]); +} + /// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped. fn pad_of(world: &mut World) -> Vec<&'static str> { let scene = world.scene(); From a02d5f7995b6071ec294437f394dc68e41943786 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:50:01 +0000 Subject: [PATCH 4/7] trap_hunt: payouts by kind, battle lengths and wins, and MOVE n on a move without effect --- .../crates/flysim/examples/trap_hunt.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/services/flysim/crates/flysim/examples/trap_hunt.rs b/services/flysim/crates/flysim/examples/trap_hunt.rs index 1f48569..704dcfd 100644 --- a/services/flysim/crates/flysim/examples/trap_hunt.rs +++ b/services/flysim/crates/flysim/examples/trap_hunt.rs @@ -247,6 +247,12 @@ struct Trace { /// The pad's composition, measured rather than read off the table: "which sub-state offers /// `BACK`" is a question about the build under test and not about the document. battle_pads: BTreeMap<&'static str, BTreeSet>, + /// Row 60: every battle as `(frames, macro starts, won)`, the one running, payouts by kind, + /// and `MOVE n` starts against how many chose a move the cartridge answers with nothing. + battles: Vec<(u64, u64, bool)>, + battle_now: Option<(u64, u64, bool)>, + payouts_by_kind: BTreeMap<&'static str, (u64, f64)>, + move_starts: (u64, u64), wall_seconds: f64, } @@ -423,6 +429,10 @@ fn run( battle_frames: BTreeMap::new(), battle_starts: BTreeMap::new(), battle_pads: BTreeMap::new(), + battles: Vec::new(), + battle_now: None, + payouts_by_kind: BTreeMap::new(), + move_starts: (0, 0), wall_seconds: 0.0, seeded: seeded_note, refusals: BTreeMap::new(), @@ -507,6 +517,23 @@ fn run( if let Some(sub) = battle_sub { *trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1; } + if let Some(battle) = trace.battle_now.as_mut() { + battle.1 += 1; + } + if let Some(slot) = event.name.strip_prefix("MOVE ") { + trace.move_starts.0 += 1; + let id = flybrain_gb::pokemon_red::state::battle(&mut emulator) + .and_then(|battle| battle.own) + .zip(slot.parse::().ok()) + .and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten()) + .map(|entry| entry.id); + if id.is_some_and(|id| { + flybrain_gb::pokemon_red::state::move_without_effect(&mut emulator, id) + == Some(true) + }) { + trace.move_starts.1 += 1; + } + } running = Some(Running { name: event.name, from: location, @@ -588,6 +615,29 @@ fn run( frame.copy_from_slice(emulator.framebuffer()); payouts = adapter.sample(&mut emulator, ms); + for payout in &payouts { + let entry = trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0)); + *entry = (entry.0 + 1, entry.1 + payout.value); + } + { + use flybrain_gb::MemoryReader; + let fighting = + emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0; + let won = payouts.iter().any(|payout| matches!(payout.kind, "battle" | "trainer")); + match (fighting, trace.battle_now.as_mut()) { + (true, Some(battle)) => { + battle.0 += 1; + battle.2 |= won; + } + (true, None) => trace.battle_now = Some((1, 0, won)), + (false, Some(_)) => { + let mut battle = trace.battle_now.take().expect("a battle"); + battle.2 |= won; + trace.battles.push(battle); + } + (false, None) => {} + } + } if let Some(layer) = macros.as_mut() { let ledger = AdapterLedger(&adapter); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); @@ -987,6 +1037,7 @@ fn main() { println!("| {name} | {sub} | {n} |"); } } + battle_report(&trace); println!("\n| scene | frames | longest run | run began (brain min) |"); println!("| --- | ---: | ---: | ---: |"); for (scene, frames) in &trace.scenes { @@ -1017,3 +1068,43 @@ fn main() { WINDOW_MS / 1000.0 ); } + +/// Row 60's numbers: payouts by kind, every battle's length and whether it paid a win, and the +/// `MOVE n` starts that chose a move the cartridge answers with nothing. +fn battle_report(trace: &Trace) { + println!("\n| payout kind | n | total |"); + println!("| --- | ---: | ---: |"); + for (kind, (n, total)) in &trace.payouts_by_kind { + println!("| {kind} | {n} | {total:.2} |"); + } + let mut lengths: Vec = trace.battles.iter().map(|battle| battle.0).collect(); + lengths.sort_unstable(); + let at = |q: f64| -> u64 { + if lengths.is_empty() { + return 0; + } + lengths[((lengths.len() - 1) as f64 * q).round() as usize] + }; + println!("\n| battles | n |"); + println!("| --- | ---: |"); + println!("| ended | {} |", trace.battles.len()); + println!("| won (a battle or trainer payout) | {} |", trace.battles.iter().filter(|b| b.2).count()); + println!( + "| still running at the end | {} |", + trace.battle_now.map_or("no".to_string(), |b| format!("{} frames, {} macros", b.0, b.1)) + ); + println!( + "| frames, median / p90 / max | {} / {} / {} |", + at(0.5), + at(0.9), + lengths.last().copied().unwrap_or(0) + ); + println!( + "| macros per battle, max | {} |", + trace.battles.iter().map(|b| b.1).max().unwrap_or(0) + ); + println!( + "| `MOVE n` starts / on a move without effect | {} / {} |", + trace.move_starts.0, trace.move_starts.1 + ); +} From b33af9e772b5c5ceec3466764f7f208eddef3e96 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 16:22:22 +0000 Subject: [PATCH 5/7] scene_probe: the route survey prints the battle bytes a MOVE n button's effect rests on --- .../crates/flysim/examples/scene_probe.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 28a3d33..1850775 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -1434,6 +1434,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) let pad = format!("{:?} {names:?}", observed.scene); if pad != last_pad { println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y))); + if let Some(line) = battle_line(gb) { + println!(" {line}"); + } last_pad = pad; } let mut mask = 0u8; @@ -2064,3 +2067,36 @@ fn main() { ), } } + +/// Row 60: the battle bytes a `MOVE n` button's effect rests on, in one line -- the fly's moves +/// with PP and whether the cartridge would answer each with nothing, both sides' stat stages +/// (7 is normal, 1 is -6), the enemy's stats, status and HP. +fn battle_line(gb: &mut Emulator) -> Option { + let battle = state::battle(gb)?; + let own = battle.own?; + let moves: Vec = own + .moves + .iter() + .flatten() + .map(|entry| { + let nothing = state::move_without_effect(gb, entry.id); + let row = state::move_data(gb, entry.id).map(|data| (data.effect, data.power)); + format!("{:#04x} pp{} row{row:?} nothing={nothing:?}", entry.id, entry.pp) + }) + .collect(); + let stages = |gb: &mut Emulator, base: u16| -> Vec { (0..6).map(|i| gb.read8(base + i)).collect() }; + let own_stages = stages(gb, ram::wPlayerMonStatMods); + let enemy_stages = stages(gb, ram::wEnemyMonStatMods); + let enemy_stats: Vec = (0..4) + .map(|i| u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i)) * 256 + u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i + 1))) + .collect(); + Some(format!( + "menu={:?} own hp {}/{} moves [{}] stages {own_stages:?} | enemy {:?} stages {enemy_stages:?} stats {enemy_stats:?} status {:#04x}", + battle.menu, + own.hp, + own.max_hp, + moves.join(", "), + battle.enemy.map(|enemy| (enemy.species, enemy.level, enemy.hp, enemy.max_hp)), + gb.read8(ram::wEnemyMonStatus), + )) +} From 07f88c443a9d5a0a33291e23a13f31db1f8d86db Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 16:43:29 +0000 Subject: [PATCH 6/7] tests: the ROM proof from the Route 1 checkpoint --- .../crates/flysim/tests/rom_macros_mode.rs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index b128344..8377f7d 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -3290,3 +3290,159 @@ fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() { "the fly never went up the room past the doormat rows: highest row {highest_row:?}" ); } + +fn row60_checkpoint() -> Option { + std::env::var_os("FLY_ROW60_CHECKPOINT").map(|path| { + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope") + }) +} + +/// Route 1, from the checkpoint taken mid-trap: Squirtle L5 (TACKLE, TAIL WHIP) against wild +/// Pidgey and Rattata. +/// +/// **What was live** (2026-09-23, v0.5.5, rank 9 after the reset to milestone 1): `MOVE 2` 183 +/// times and `MOVE 1` once, "Nothing happened!" on the screen, twenty-five brain minutes with no +/// reward. TAIL WHIP took the Pidgey's DEFENSE to where the cartridge refuses it (the stat at 1, +/// or the stage at -6) and the pad kept dealing it beside TACKLE; every battle ended with Squirtle +/// fainted and the fly back home (`infra/docs/macros-traps.md` row 60). The checkpoint itself is +/// the frame Squirtle fainted. +/// +/// The driver is the real palette with the live readout's measured favourite: `MOVE 2` whenever +/// the pad deals it, otherwise a uniform choice per hold -- a harness choice, not the fly's. The +/// claims: +/// +/// - **no `MOVE n` is dealt for a move the cartridge answers with nothing while another move +/// would do something**, on any frame of the fly's own turn, and the run does reach that state; +/// - **a wild battle is won** inside the budget, which the base never does with this driver. +/// +/// ```sh +/// FLY_ROM=/path/to/pokemon-red.gb \ +/// FLY_ROW60_CHECKPOINT=.local/checkpoints/release-rank9-row60.checkpoint \ +/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture tail_whip +/// ``` +#[test] +fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() { + use flybrain_gb::pokemon_red::macros::PokemonPalette; + use flybrain_gb::pokemon_red::state; + use flybrain_gb::pokemon_red::symbols::ram; + use flybrain_gb::{MacroPalette, MemoryReader, Started}; + let rom = skip_without_rom!(); + let Some(checkpoint) = row60_checkpoint() else { + eprintln!("skipped: no FLY_ROW60_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint); + assert_ne!(run.gb.read8(ram::wIsInBattle), 0, "the checkpoint is inside the Route 1 battle"); + + let budget = 72_000u32; + let hold_frames = 48u32; + let mut palette = PokemonPalette::new(SEED); + let mut rng = 20_260_923u32; + let mut running = false; + let mut since_decision = hold_frames; + let mut ms = run.ms; + let mut at_a_limit = 0u32; + let mut dealt_without_effect = 0u32; + let mut starts = std::collections::BTreeMap::<&'static str, u32>::new(); + // (frames, the enemy fainted, the fly's Pokemon standing at the end) + let mut battles: Vec<(u32, bool, bool)> = Vec::new(); + let mut current: Option<(u32, bool, bool)> = Some((0, false, false)); + for _ in 0..budget { + palette.clock(ms); + let observed = { + let ledger = AdapterLedger(&run.adapter); + palette.observe(&mut run.gb, &ledger) + }; + let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect(); + if let Some(battle) = state::battle(&mut run.gb) + && battle.own_turn + && let Some(own) = battle.own + { + let mut nothing = [false; 4]; + let mut useful = [false; 4]; + for (slot, entry) in own.moves.iter().enumerate() { + if let Some(entry) = entry.filter(|entry| entry.id != 0) { + nothing[slot] = state::move_without_effect(&mut run.gb, entry.id) == Some(true); + useful[slot] = entry.pp > 0 && !nothing[slot]; + } + } + if nothing.iter().any(|flag| *flag) && useful.iter().any(|flag| *flag) { + at_a_limit += 1; + for (slot, name) in ["MOVE 1", "MOVE 2", "MOVE 3", "MOVE 4"].iter().enumerate() { + if nothing[slot] && names.contains(name) { + dealt_without_effect += 1; + } + } + } + } + let mut mask = 0u8; + { + let ledger = AdapterLedger(&run.adapter); + if running { + match palette.step(&mut run.gb, &ledger) { + Some(held) => mask = held, + None => running = false, + } + } else if since_decision >= hold_frames && !observed.bindings.is_empty() { + since_decision = 0; + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + let binding = observed + .bindings + .iter() + .find(|binding| binding.name == "MOVE 2") + .unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]); + if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) { + *starts.entry(binding.name).or_default() += 1; + running = true; + match palette.step(&mut run.gb, &ledger) { + Some(held) => mask = held, + None => running = false, + } + } + } + } + since_decision += 1; + run.gb.set_buttons(mask); + run.gb.run_frame().expect("a frame should complete"); + ms += MS_PER_FRAME; + run.adapter.sample(&mut run.gb, ms); + + let fighting = run.gb.read8(ram::wIsInBattle) != 0; + match (&mut current, fighting) { + (Some((frames, fainted, standing)), true) => { + *frames += 1; + if let Some(battle) = state::battle(&mut run.gb) { + *fainted |= battle.enemy.is_some_and(|enemy| enemy.hp == 0); + if let Some(own) = battle.own { + *standing = own.hp > 0; + } + } + } + (None, true) => current = Some((1, false, true)), + (Some(battle), false) => { + battles.push(*battle); + current = None; + } + (None, false) => {} + } + } + let won = battles.iter().filter(|battle| battle.1 && battle.2).count(); + eprintln!( + "{:.1} brain minutes: battles ended {} (won {won}), (frames, enemy fainted, standing) \ + {battles:?}, still in one {current:?}; own-turn frames with a move without effect beside a \ + useful one {at_a_limit}, the refused move dealt on {dealt_without_effect}; starts \ + {starts:?}; rank {}", + (ms - run.ms) / 60_000.0, + battles.len(), + run.adapter.progress().rank, + ); + assert!(at_a_limit > 0, "the run never reached the trap's own state"); + assert_eq!( + dealt_without_effect, 0, + "a MOVE n the cartridge answers with nothing was dealt beside one it does not" + ); + assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}"); +} From 4591e7907a6c0dfe4c92166ed88554287247ca0c Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 17:22:21 +0000 Subject: [PATCH 7/7] docs: macros.md 12.23, macros-wram.md section 13, the row 60 audit --- docs/design/macros-wram.md | 42 ++++++++++++++++++ docs/design/macros.md | 34 +++++++++++++++ infra/docs/macros-traps.md | 87 +++++++++++++++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 13d0ae8..04e2acd 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -953,3 +953,45 @@ where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserte neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the 219-frame transition and the battle. `controllable` reads it as zero. + +## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23) + +Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the +tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which +is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no +disagreement. + +| symbol | address | what reads it | +| --- | --- | --- | +| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 | +| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy | +| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) | +| `wEnemyMonType1` | `$cfea` | and `Type2` after it | +| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words | +| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon | +| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge | + +**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places +first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`: +animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row +through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the +id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL +WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0. + +**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no +power, from `engine/battle/effects.asm` and `MoveHitTest`: + +- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999; +- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL) + the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!" + then, so a low-level target reaches it before -6; +- `SLEEP_EFFECT`: any status, unless the target must recharge; +- `POISON_EFFECT`: a substitute, any status, or a Poison type; +- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type. + +`None` outside a battle, without a cartridge, or with a stage byte outside 1..13. + +**Not covered** (the reading would be the same kind, and nothing early in the game reaches it): +Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus +Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging +move the type chart makes "doesn't affect" (the chart is another ROM table). diff --git a/docs/design/macros.md b/docs/design/macros.md index 00fc5a8..8f7ef85 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1505,6 +1505,40 @@ has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own ru frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter version, the roles and the compatibility string are untouched. +### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60) + +Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL +WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183 +times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in +the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on +two probes), which is right, and it is unchanged. + +- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a + move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the + point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the + stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is + the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat + reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2` + until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the + run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all + being on the pad is a macro that knows nothing about its own effect, section 12.2's trap. +- **What a move does is the cartridge's, read the same way for every move.** `state::move_data` + reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against + its own id) and `state::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 or 999, Mist or a substitute in + front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has + a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a + roll and is not answered. `macros-wram.md` section 13 has the bytes. +- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does + not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's + backstop only in that case. When no move would do anything the moves stay as PP deals them: + taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair, + and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched. + +Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change +anything and comes back when it can (a new battle resets the stages). The decoder, the reward +catalog, the adapter version, the roles and the compatibility string are untouched. + ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## priority to visit the shop at least once per area; make shop macros item purchases. same ## for the Pokécenter. heal should be a macro.") diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index f711144..3f464b9 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -2757,5 +2757,88 @@ route survey above is the reproduction; the hunt is reported, not smoothed. - `cargo clippy --all-targets`: **0 warnings**. - `npm test` 663 passed; `npm run typecheck` clean. - `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included. -- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the - base. Decoder, reward catalog, adapter version and roles untouched. +- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had: + 648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` + on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version + and roles untouched. + +## 2026-09-23, row 60: TAIL WHIP after it stopped working + +### What was live + +Map 12 (Route 1), rank 9, v0.5.5, after the operator reset the run to milestone 1: Squirtle L5 +(TACKLE, TAIL WHIP) in wild battles with Pidgey, "Nothing happened!" on the screen. Since the reset +`MOVE 2` 183 starts and `MOVE 1` one; the last reward 25 brain minutes before the checkpoint, +1,600 decisions since, one wild win in the whole run. Check 10 flagged `unrewarded` -- correctly: +100+ decisions, no reward event, no new ground on two probes. It is unchanged. + +### The survey: the pad on the fly's own turn + +The route probe from the checkpoint (`FLY_PROBE_CATCH=route`, `FLY_PROBE_PREFER="MOVE 2"`, the +live readout's favourite; 72,000 frames) now prints the battle bytes on every pad change. The +checkpoint is the frame Squirtle fainted: Pidgey L3, DEFENSE stage 1 (-6), DEFENSE 2, TAIL WHIP's +row `$27`: effect `$13` power 0. In the next battle DEFENSE reached **1 at stage -5**, which +`StatModifierDownEffect` refuses as well. From then on the own-turn pad was `MOVE 1, MOVE 2, RUN` +over the menu and `BACK, MOVE 1, MOVE 2` over the list: **every own-turn pad with TAIL WHIP +refused dealt `MOVE 2`** (72 of 72; a 73rd frame was a list with no placeable cursor, `NEXT` +alone), TACKLE beside it every time. `MOVE 1` was never missing; `MOVE 2` was a +button that could change nothing. + +| # | trap | trigger | test | fix, or why it is left | +| --- | --- | --- | --- | --- | +| 60 | `MOVE n` is bound by PP alone, so a move the cartridge refuses ("Nothing happened!", "didn't affect") stays on the pad beside one that works | any stat move at its stage or stat limit, any status move at a target it cannot affect; Route 1, TAIL WHIP at a Pidgey's DEFENSE 1, every battle lost | `a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not`, `a_spent_move_and_a_move_without_effect_leave_the_one_that_works`, `with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them`, six `state` tests, `tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won` (ROM) | **fixed**: `state::move_data` reads the move's row of `Moves` (`$0E:$4000`) from the cartridge image; `state::move_without_effect` answers the refusals decided before the roll (stage 1/13, stat 1/999, Mist or substitute, a statused, Poison or Ground target); the palette treats such a move as it treats a spent one. `docs/design/macros.md` 12.23, `macros-wram.md` section 13 | + +### Before and after + +The ROM-gated run, 72,000 frames (20.1 brain minutes) from the checkpoint, the real palette +driven with `MOVE 2` preferred and a uniform choice otherwise; base is this branch with the +palette commit reverted: + +| measure | base | branch | +| --- | ---: | ---: | +| own-turn frames with a refused move beside a useful one | 2,657 | 568 | +| ... of them dealing the refused move | **2,657** | **0** | +| battles ended / won | 12 / **0** | 14 / **3** | +| battles ended with the fly's Pokémon fainted | 12 | 8 | +| battle length, frames, median / max | 4,970 / 8,994 | 3,594 / 4,793 | +| `MOVE 2` / `MOVE 1` / `RUN` starts | 94 / 0 / 0 | 56 / 5 / 4 | + +The route survey, same driver and checkpoint: base never leaves Pallet Town, Red's house and +Route 1 (637 tiles at the end); the branch reaches Viridian City, its Pokémon Center and mart and +Route 2 (748). + +The survey itself, same driver: own-turn pads dealing a refused TAIL WHIP 72 -> **0** (of 11 at +the limit); `MOVE 2` / `MOVE 1` / `RUN` done 94 / 0 / 0 -> 56 / 4 / 4; maps with a macro done 4 -> 10. + +**The real-brain trap hunt was not run to the end.** Both 20-minute arms (`trap_hunt` now reports +payouts by kind, battle lengths and wins, and `MOVE n` starts on a move without effect) were +started from the checkpoint and stopped after 1 h 48 min wall at about 19 CPU-minutes each: the box +sat at load 25-40 and a stub arm ticks the same brain. The ROM-gated run and the survey above are +the before/after; they drive the real palette with the live readout's measured preference instead +of the brain, which is the deviation. + +### Residuals, named rather than worked around + +- **The fly still spends TAIL WHIP while it works.** Six presses at stage 7 to 1 are the fly's + choice, and a Squirtle at 8/20 can faint doing it; eight of fourteen battles on the branch + still ended that way. What changed is that the seventh is not on the pad. +- **Not covered, same kind, nothing early reaches it:** Confuse Ray and Supersonic on a confused + target, Leech Seed on a seeded or Grass target, Focus Energy, Mist, Reflect and Light Screen + already up, Disable on a disabled target, and a damaging move the type chart makes "doesn't + affect" (the chart is another ROM table). `macros-wram.md` section 13. +- **With no move that would do anything, the moves stay as PP deals them.** Taking them away would + leave an open list with `BACK` alone (12.11); a turn that ends on "Nothing happened!" still ends. + +### Gates + +- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 426 + 27 passed, 0 failed. +- `cargo test --release -p flysim --no-fail-fast` with `FLY_ROM` and the row-60 checkpoint: + all passed but `integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`, + the known load failure (`total` 1 against 38, or the feed at 14-18 Hz, on a box at load 30), + failing the same two ways on the base. +- `cargo clippy --workspace --all-targets -- -D warnings`: clean. +- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED. +- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had: + 648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` + on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version + and roles untouched.