Merge fix/loop-row60: a move the cartridge would refuse is not dealt beside one that works
This commit is contained in:
commit
fa021918b4
14 changed files with 1069 additions and 13 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1893,7 +1893,8 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ struct World {
|
|||
money: u32,
|
||||
bag: Vec<(u8, u8)>,
|
||||
stock: Vec<u8>,
|
||||
/// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60).
|
||||
no_effect: BTreeSet<u8>,
|
||||
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
|
||||
counters: BTreeSet<Tile>,
|
||||
/// 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<u8> {
|
||||
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::<Vec<_>>(),
|
||||
[&"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();
|
||||
|
|
|
|||
|
|
@ -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<EnemyMon> {
|
|||
})
|
||||
}
|
||||
|
||||
/// One row of the cartridge's move table (`data/moves/moves.asm`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MoveData {
|
||||
pub id: u8,
|
||||
pub effect: u8,
|
||||
pub power: u8,
|
||||
pub kind: u8,
|
||||
pub accuracy: u8,
|
||||
pub pp: u8,
|
||||
}
|
||||
|
||||
/// Move `id`'s row of the move table, read from the cartridge image.
|
||||
///
|
||||
/// `None` when the seam has no cartridge behind it, when `id` is not a move, or when the row does
|
||||
/// not open with its own id -- every row of `Moves` does (`move`'s first byte is the animation,
|
||||
/// "interchangeable with move id"), so a table that is not where the disassembly puts it answers
|
||||
/// nothing rather than a neighbour's effect.
|
||||
pub fn move_data(memory: &mut dyn MemoryReader, id: u8) -> Option<MoveData> {
|
||||
use poke::moves::{LAST_MOVE, ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
|
||||
if id == 0 || id > LAST_MOVE {
|
||||
return None;
|
||||
}
|
||||
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
|
||||
let mut row = [0u8; 6];
|
||||
for (offset, byte) in row.iter_mut().enumerate() {
|
||||
*byte = memory.read_rom(TABLE_BANK, base + offset as u16)?;
|
||||
}
|
||||
if row[0] != id {
|
||||
return None;
|
||||
}
|
||||
Some(MoveData {
|
||||
id,
|
||||
effect: row[1],
|
||||
power: row[2],
|
||||
kind: row[3],
|
||||
accuracy: row[4],
|
||||
pp: row[5],
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the battle engine will answer the fly's move `id` with nothing at all, on this frame.
|
||||
///
|
||||
/// Row 60 (`docs/design/macros.md` 12.23): Squirtle's TAIL WHIP against a Pidgey whose DEFENSE
|
||||
/// was already at -6 printed "Nothing happened!" 183 times on Route 1. These are the refusals the
|
||||
/// effect routines in `engine/battle/effects.asm` make on bytes that are already in WRAM when the
|
||||
/// move is chosen, for a move that deals no damage (a move with power always does something):
|
||||
///
|
||||
/// - a stat-raising effect (`StatModifierUpEffect`): the user's stage is already +6, or the stat
|
||||
/// itself is already 999;
|
||||
/// - a stat-lowering effect (`StatModifierDownEffect`, `MoveHitTest`): the target has a
|
||||
/// substitute or Mist, its stage is already -6, or the stat itself is already 1;
|
||||
/// - `SleepEffect`: the target already has a status and is not recharging;
|
||||
/// - `PoisonEffect`: a substitute, a status, or a Poison type;
|
||||
/// - `ParalyzeEffect`: a status, or an Electric move against a Ground type.
|
||||
///
|
||||
/// `Some(false)` for every other move, which the cartridge may still miss -- a miss is a roll,
|
||||
/// and this answers only what is already decided. `None` outside a battle this module
|
||||
/// understands, when the move table cannot be read, or when a stage byte is out of its range:
|
||||
/// a refusal this module cannot read is not one it reports.
|
||||
pub fn move_without_effect(memory: &mut dyn MemoryReader, id: u8) -> Option<bool> {
|
||||
use poke::moves::*;
|
||||
in_battle(memory)?;
|
||||
let data = move_data(memory, id)?;
|
||||
if data.power != 0 {
|
||||
return Some(false);
|
||||
}
|
||||
let stage = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u8> {
|
||||
Some(read(memory, base + u16::from(stat))).filter(|stage| (MIN_STAGE..=MAX_STAGE).contains(stage))
|
||||
};
|
||||
let value = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u16> {
|
||||
(stat < STATS_WITH_VALUES).then(|| word_be(memory, base + 2 * u16::from(stat)))
|
||||
};
|
||||
let effect = data.effect;
|
||||
let raised = match effect {
|
||||
ATTACK_UP1..=EVASION_UP1 => Some(effect - ATTACK_UP1),
|
||||
ATTACK_UP2..=EVASION_UP2 => Some(effect - ATTACK_UP2),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(stat) = raised {
|
||||
let at = stage(memory, ram::wPlayerMonStatMods, stat)?;
|
||||
return Some(at >= MAX_STAGE || value(memory, ram::wBattleMonAttack, stat) == Some(MAX_STAT));
|
||||
}
|
||||
let target = read(memory, ram::wEnemyBattleStatus2);
|
||||
let lowered = match effect {
|
||||
ATTACK_DOWN1..=EVASION_DOWN1 => Some(effect - ATTACK_DOWN1),
|
||||
ATTACK_DOWN2..=EVASION_DOWN2 => Some(effect - ATTACK_DOWN2),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(stat) = lowered {
|
||||
let at = stage(memory, ram::wEnemyMonStatMods, stat)?;
|
||||
return Some(
|
||||
target & (SUBSTITUTE | MIST) != 0
|
||||
|| at <= MIN_STAGE
|
||||
|| value(memory, ram::wEnemyMonAttack, stat) == Some(MIN_STAT),
|
||||
);
|
||||
}
|
||||
let status = read(memory, ram::wEnemyMonStatus);
|
||||
let types = [read(memory, ram::wEnemyMonType1), read(memory, ram::wEnemyMonType1 + 1)];
|
||||
Some(match effect {
|
||||
SLEEP => status != 0 && target & RECHARGE == 0,
|
||||
POISON => target & SUBSTITUTE != 0 || status != 0 || types.contains(&TYPE_POISON),
|
||||
PARALYZE => {
|
||||
status != 0 || (data.kind == TYPE_ELECTRIC && types.contains(&TYPE_GROUND))
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
|
||||
///
|
||||
/// `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).
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1067,3 +1067,158 @@ fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
|
|||
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
let battle = state::battle(gb)?;
|
||||
let own = battle.own?;
|
||||
let moves: Vec<String> = 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<u8> { (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<u16> = (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),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>>,
|
||||
/// 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::<usize>().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<u64> = 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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<flysim::store::Checkpoint> {
|
||||
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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue