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. 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/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(); 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)); +} 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/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), + )) +} 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 + ); +} 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:?}"); +} 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)