From c8ecf06db59170ce6fdfe7331d8896bf1147d533 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 15:17:52 +0000 Subject: [PATCH] symbols: the stat stages and the enemy's status, type and stats, resolved from wram.asm resolve_wram.py now follows the decomp's const and _RS counters and a struct macro's field labels, so battle_struct fields resolve and 77 of 81 pinned addresses are re-derived with no disagreement. --- .../flybrain-gb/src/pokemon_red/symbols.rs | 7 + services/flysim/tools/resolve_wram.py | 142 +++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs index e62c245..dc8d753 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs @@ -23,6 +23,8 @@ pub mod ram { pub const wMaxMenuItem: u16 = 0xcc28; // 52264 pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265 pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271 + pub const wPlayerMonStatMods: u16 = 0xcd1a; // 52506 + pub const wEnemyMonStatMods: u16 = 0xcd2e; // 52526 pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536 pub const wMiscFlags: u16 = 0xcd60; // 52576 pub const wJoyIgnore: u16 = 0xcd6b; // 52587 @@ -35,19 +37,24 @@ pub mod ram { pub const wWalkCounter: u16 = 0xcfc5; // 53189 pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221 pub const wEnemyMonHP: u16 = 0xcfe6; // 53222 + pub const wEnemyMonStatus: u16 = 0xcfe9; // 53225 + pub const wEnemyMonType1: u16 = 0xcfea; // 53226 pub const wEnemyMonLevel: u16 = 0xcff3; // 53235 pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236 + pub const wEnemyMonAttack: u16 = 0xcff6; // 53238 pub const wBattleMonSpecies: u16 = 0xd014; // 53268 pub const wBattleMonHP: u16 = 0xd015; // 53269 pub const wBattleMonStatus: u16 = 0xd018; // 53272 pub const wBattleMonMoves: u16 = 0xd01c; // 53276 pub const wBattleMonLevel: u16 = 0xd022; // 53282 pub const wBattleMonMaxHP: u16 = 0xd023; // 53283 + pub const wBattleMonAttack: u16 = 0xd025; // 53285 pub const wBattleMonPP: u16 = 0xd02d; // 53293 pub const wTrainerClass: u16 = 0xd031; // 53297 pub const wIsInBattle: u16 = 0xd057; // 53335 pub const wBattleType: u16 = 0xd05a; // 53338 pub const wTrainerNo: u16 = 0xd05d; // 53341 + pub const wEnemyBattleStatus2: u16 = 0xd068; // 53352 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 diff --git a/services/flysim/tools/resolve_wram.py b/services/flysim/tools/resolve_wram.py index 1572a84..ad0c2ca 100644 --- a/services/flysim/tools/resolve_wram.py +++ b/services/flysim/tools/resolve_wram.py @@ -73,6 +73,22 @@ WANTED = { 'wToggleableObjectList': "this map's toggleable sprites and their global indices", # One bit per hidden item, set by FoundHiddenItemText once GiveItem succeeded. 'wObtainedHiddenItemsFlags': 'hidden items already found', + # Row 60 (`docs/design/macros.md` 12.23): a move the cartridge will answer + # with "Nothing happened!" is not dealt. StatModifierUpEffect and + # StatModifierDownEffect (engine/battle/effects.asm) refuse on the stage byte + # (1 is -6, 7 normal, 13 is +6) and on the modified stat itself (1 or 999); + # SleepEffect, PoisonEffect and ParalyzeEffect on the target's status byte and + # type. Each is the battle_struct field or stage array those routines read. + 'wPlayerMonStatMods': "the active Pokemon's six stat stages, 7 is normal", + 'wEnemyMonStatMods': "the enemy's six stat stages, 7 is normal", + 'wEnemyMonStatus': "the enemy's status condition byte", + 'wEnemyMonType1': "the enemy's first type (wEnemyMonType2 follows it)", + 'wEnemyMonAttack': "the enemy's modified Attack, Defense, Speed, Special", + 'wBattleMonAttack': "the active Pokemon's modified Attack, Defense, Speed, Special", + # Mist and a substitute turn a stat-lowering move away, a substitute a + # poisoning one, and a target that must recharge is put to sleep whatever + # its status (MoveHitTest, CheckTargetSubstitute, SleepEffect). + 'wEnemyBattleStatus2': "the enemy's Mist, substitute and recharge bits", } @@ -141,10 +157,14 @@ def constants(root: Path) -> dict[str, int]: (root / 'constants').glob('*.inc') ) for path in sources: + text = path.read_text() for name, value in re.findall( - r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M + r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', text, re.M ): pending.setdefault(name, value.strip()) + # A name counted by `const` or `rb` is an expression over the running + # counter at its own line, so it overrides the raw `EQU const_value - 1`. + pending.update(enumerated(text)) out: dict[str, int] = dict(counted) while pending: progressed = False @@ -166,6 +186,91 @@ def constants(root: Path) -> dict[str, int]: return out +def enumerated(text: str) -> dict[str, str]: + """The names one constants file defines by counting, as expressions. + + rgbasm keeps two running counters the decomp enumerates with: `const_value` + (`const_def`, `const`, `const_skip`, `const_next` in macros/const.asm) and + `_RS` (`rsreset`, `rsset`, `DEF NAME rb/rw n`, `rb_skip`). This follows both + in file order and writes each name down as the *expression* the counter held + at its line, never as a number: [`constants`]' passes evaluate it with the + rest, so a count over a constant this tool cannot resolve stays unresolved + rather than becoming a guess. A counter form it does not know (a + `shift_const`, a non-literal step) kills that counter until the next reset. + """ + out: dict[str, str] = {} + value: str | None = None + step = '1' + rs: str | None = None + for raw in text.splitlines(): + line = raw.split(';')[0].strip() + if not line: + continue + match = re.fullmatch(r'const_def(?:\s+([^,]+?))?(?:\s*,\s*(.+))?', line) + if match: + value = f'({match.group(1) or "0"})' + step = f'({match.group(2) or "1"})' + continue + match = re.fullmatch(r'(?:const|const_export)\s+(\w+)', line) + if match: + if value is not None: + out[match.group(1)] = value + value = f'({value} + {step})' + continue + match = re.fullmatch(r'const_skip(?:\s+(.+))?', line) + if match: + if value is not None: + value = f'({value} + {step} * ({match.group(1) or "1"}))' + continue + match = re.fullmatch(r'const_next\s+(.+)', line) + if match: + value = f'({match.group(1)})' + continue + if line.startswith(('shift_const', 'dw_const')): + value = None + continue + if line == 'rsreset': + rs = '(0)' + continue + match = re.fullmatch(r'rsset\s+(.+)', line) + if match: + rs = f'({match.group(1)})' if '_RS' not in match.group(1) else None + continue + match = re.fullmatch(r'(rb|rw)_skip(?:\s+(.+))?', line) + if match: + if rs is not None: + unit = 1 if match.group(1) == 'rb' else 2 + rs = f'({rs} + {unit} * ({match.group(2) or "1"}))' + continue + match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(rb|rw)(?:\s+(.+))?', line) + if match: + if rs is not None: + out[match.group(1)] = rs + unit = 1 if match.group(2) == 'rb' else 2 + rs = f'({rs} + {unit} * ({match.group(3) or "1"}))' + continue + match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+(.+)', line) + if match is None: + # Anything else may be a macro that moves a counter this does not + # follow (`add_tm` advances `const_value`), so both stop here. + if not line.startswith(('ASSERT', 'assert', 'EXPORT', 'export')): + value = None + rs = None + continue + if re.search(r'\b(?:_RS|const_value)\b', match.group(2)): + expression = match.group(2) + if '_RS' in expression: + if rs is None: + continue + expression = re.sub(r'\b_RS\b', rs, expression) + if 'const_value' in expression: + if value is None: + continue + expression = re.sub(r'\bconst_value\b', value, expression) + out[match.group(1)] = expression + return out + + def number(token: str) -> int: token = token.strip() if token.startswith('$'): @@ -213,16 +318,27 @@ def size_of(expression: str, known: dict[str, int]) -> int: return value * scale -def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: - """Sizes of the RAM struct macros, counted from their own declarations.""" +def macro_sizes( + root: Path, known: dict[str, int], fields: dict[str, list[tuple[str, int]]] | None = None +) -> dict[str, int]: + """Sizes of the RAM struct macros, counted from their own declarations. + + With `fields`, also each macro's `\\1Name::` field labels and their offsets, + for a macro whose whole body was sized: `battle_struct wEnemyMon` declares + `wEnemyMonStatus` at the offset its own lines put it. + """ out: dict[str, int] = {} for path in sorted((root / 'macros').glob('*.asm')): text = path.read_text() for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S): name, body = match.group(1), match.group(2) total = 0 + offsets: list[tuple[str, int]] = [] for line in body.splitlines(): line = line.split(';')[0].strip() + field = re.match(r'^\\1(\w+)::', line) + if field is not None and total is not None: + offsets.append((field.group(1), total)) # A struct macro labels each field with its argument # (`\\1YCoord:: db`), so the label is stripped and the # declaration after it is what reserves the bytes. @@ -246,6 +362,8 @@ def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: break if total is not None: out[name] = total + if fields is not None: + fields[name] = offsets return out @@ -253,7 +371,8 @@ def walk( root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False ) -> tuple[dict[str, int], list[str], int]: """Resolve every symbol of wram.asm the anchored cursor can reach exactly.""" - macros = macro_sizes(root, known) + fields: dict[str, list[tuple[str, int]]] = {} + macros = macro_sizes(root, known, fields) lines = (root / 'ram/wram.asm').read_text().splitlines() cursor: int | None = None resolved: dict[str, int] = {} @@ -370,6 +489,21 @@ def walk( continue if line.endswith('::') or re.fullmatch(r'\.\w+', line): continue + # A struct macro's own field labels, at the offsets its body puts them: + # held back like any other label until the next pinned address agrees. + invocation = re.fullmatch(r'(\w+)\s+(w\w+)', line) + if invocation is not None and cursor is not None: + for field, offset in fields.get(invocation.group(1), []): + name = invocation.group(2) + field + if name not in table: + pending_run[name] = cursor + offset + elif table[name] != cursor + offset: + problems.append( + f'{name}: wram.asm gives ${cursor + offset:04x}, ' + f'symbols.rs pins ${table[name]:04x}' + ) + else: + checked += 1 try: if cursor is not None: cursor += declaration_size(line, known, macros)