a battle decided is the cartridge's

Between a trainer's challenge closing and the battle screen the
transition runs 219 frames with every joypad and script bit clear, so
the scene read overworld and a pad was dealt: a walk toward the leader
pressed into the animation, gave up after three refused steps and put
him in the blocked ledger, and the trainer's conversation read as over.

wCurOpponent is set when a battle is decided and cleared by EndOfBattle
with wIsInBattle. It is not in the generated table; it is the byte
between wIsInBattle's flag byte and wBattleType, both neighbours
checked against the table, and controllable() reads it.
This commit is contained in:
acamilo 2026-09-23 10:34:21 +00:00
parent 484cc075cc
commit 5fd16536db
3 changed files with 42 additions and 3 deletions

View file

@ -105,7 +105,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
format!(
"started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \
list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \
flags6={:#04x} move={:#04x} \
flags6={:#04x} move={:#04x} opp={:#04x} \
corners=({:#04x},{:#04x},{:#04x},{:#04x})",
state::started(memory),
state::map_size(memory).map(|size| (size.width, size.height)),
@ -126,6 +126,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
memory.read8(ram::wStatusFlags5),
memory.read8(ram::wStatusFlags6),
memory.read8(ram::wMovementFlags),
memory.read8(state::poke::CUR_OPPONENT),
box_corners[0],
box_corners[1],
box_corners[2],

View file

@ -410,3 +410,18 @@ fn the_start_menus_box_is_read_the_same_way() {
.cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START);
assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu");
}
#[test]
fn a_battle_decided_and_not_yet_begun_is_the_cartridges() {
// Row 58. Between a trainer's challenge closing and the battle screen the transition runs for
// 219 frames with every joypad and script bit clear; `wCurOpponent` is what says a battle has
// been decided. The byte is derived, not generated: it sits between two generated ones.
assert_eq!(poke::CUR_OPPONENT, ram::wIsInBattle + 2, "after wIsInBattle and one flag byte");
assert_eq!(poke::CUR_OPPONENT, ram::wTrainerNo - 4, "and four before wTrainerNo");
let mut wram = Wram::overworld();
assert_eq!(detect(&mut wram), Scene::Overworld);
wram.set(poke::CUR_OPPONENT, 0xcd);
assert_eq!(detect(&mut wram), Scene::Unknown, "OPP_JR_TRAINER_M, decided");
wram.set(poke::CUR_OPPONENT, 0x00);
assert_eq!(detect(&mut wram), Scene::Overworld, "and `EndOfBattle` clears it");
}

View file

@ -132,6 +132,19 @@ pub mod poke {
/// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the
/// one `docs/design/room-escape.md` cares most about.
pub const SCRIPTED_MOVEMENT: u8 = 0xc0;
/// `wCurOpponent` (row 58): the species of a wild opponent or `OPP_ID_OFFSET` plus a
/// trainer's class, written when a battle is *decided* -- `home/trainers.asm` for a trainer,
/// the encounter check for a wild one -- and cleared by `EndOfBattle` together with
/// `wIsInBattle`. Not in the generated table, so it is derived rather than pinned:
/// `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
/// `wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
/// `wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db` in
/// that order, and the table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and
/// `wTrainerNo` (`$d05d`) sit exactly where that layout puts them, so the byte between is
/// `wBattleType - 1` with both neighbours checked. Measured on the cartridge in the Pewter Gym:
/// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of
/// the battle, including the 219 frames of the battle transition in between.
pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1;
/// `constants/battle_constants.asm`: the non-volatile status byte.
pub const SLP_MASK: u8 = 0b111;
@ -293,12 +306,22 @@ pub fn started(memory: &mut dyn MemoryReader) -> bool {
}
/// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no
/// scripted movement, no warp in flight, not mid-ledge-hop.
/// scripted movement, no warp in flight, not mid-ledge-hop, no battle decided and not yet begun.
///
/// The masks are the reward adapter's own scripted gate, minus the door bits — see
/// [`poke::SCRIPTED_MOVEMENT`].
///
/// **A battle decided is the cartridge's** (row 58). Between a trainer's challenge closing and the
/// battle screen, the battle transition runs for 219 frames with every joypad and script bit
/// clear, so the seam read an overworld the fly could walk in: the pad was dealt, a walk toward
/// the gym leader pressed into an animation, gave up after three refused steps, and put the
/// leader into the blocked ledger for ten brain minutes -- and the Jr. Trainer's conversation read
/// as over, so the trainer the fly was about to lose to went into the talked ledger for the
/// session. [`poke::CUR_OPPONENT`] is set on the frame the battle is decided and cleared with the
/// battle's own end.
pub fn controllable(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wJoyIgnore) == 0
read(memory, poke::CUR_OPPONENT) == 0
&& read(memory, ram::wJoyIgnore) == 0
&& read(memory, ram::wSimulatedJoypadStatesIndex) == 0
&& read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0
&& read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0