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.
616 lines
28 KiB
Python
616 lines
28 KiB
Python
"""Resolve WRAM symbol addresses out of a pret/pokered checkout, and verify the pinned ones.
|
|
|
|
`gen_symbols.py` owns `symbols.rs` and takes its addresses from the prototype's
|
|
already-generated table; it refuses a hand-written address. Section 15 of
|
|
`docs/design/macros.md` needs four symbols that table does not carry
|
|
(`wOverworldMap`, `wCurMapTileset`, `wTilesetBank`, `wTilesetBlocksPtr`), and a
|
|
hand-computed address is exactly what neither script will take. So this tool
|
|
does the same job the disassembly's own `.sym` would, from `ram/wram.asm`:
|
|
|
|
* it walks the file in order, keeping a byte cursor;
|
|
* the cursor is **only ever live while it is anchored**: it is set from a symbol
|
|
`symbols.rs` already pins, and any declaration form this tool cannot evaluate
|
|
exactly kills it until the next pinned symbol revives it. An unanchored region
|
|
can therefore not produce a number at all, rather than producing a wrong one;
|
|
* every pinned symbol it reaches while live is checked against `symbols.rs`, and
|
|
a single disagreement is a failure with no output. A resolved symbol is only
|
|
reported when the run also re-derived the *next* pinned symbol after it, so
|
|
each answer is bracketed by two addresses the table already carries.
|
|
|
|
Usage (read-only; `--emit` rewrites the `ram` block of symbols.rs in place):
|
|
|
|
python3 services/flysim/tools/resolve_wram.py --pokered <checkout> [--emit]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import re
|
|
from pathlib import Path
|
|
|
|
SYMBOLS = (
|
|
Path(__file__).resolve().parents[1] / 'crates/flybrain-gb/src/pokemon_red/symbols.rs'
|
|
)
|
|
|
|
#: The symbols this run is for, with why the macro layer needs each one. Every
|
|
#: one is checked to be bracketed by two pinned addresses before it is emitted.
|
|
WANTED = {
|
|
# The current map's block ids, as LoadTileBlockMap copies them out of the
|
|
# map's ROM bank: rows of (width + MAP_BORDER * 2) bytes, the map itself
|
|
# offset by three rows and three columns. This is what makes a whole-map
|
|
# walkability grid a WRAM read rather than a ROM one.
|
|
'wOverworldMap': 'the loaded map, one byte per 4x4-tile block',
|
|
# Which tileset the loaded map uses: the tile-pair collision lists are keyed
|
|
# by it (CheckForTilePairCollisions).
|
|
'wCurMapTileset': 'the loaded map tileset id',
|
|
# The tileset header's blockset: a bank byte and a little-endian pointer at
|
|
# 16 bytes per block, four rows of four tile ids (DrawTileBlock). The bank is
|
|
# not bank 0, so this is the read the memory seam grew a bank for.
|
|
'wTilesetBank': 'the ROM bank the blockset lives in',
|
|
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block',
|
|
# The catch reward (`docs/rewards-learning.md`, `docs/design/macros-wram.md`
|
|
# section 10). ram/wram.asm's own comment is "0 if no mon was captured":
|
|
# ItemUseBall zeroes it before every throw and writes wEnemyMonSpecies into it
|
|
# only on the branch that keeps the caught Pokemon, and UseBagItem zeroes it
|
|
# again on the way out of the battle. It is the cartridge's own answer to "was
|
|
# this one caught", and the only signal that needs no second rule to tell a
|
|
# catch apart from a gift, a trade or an evolution.
|
|
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
|
|
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
|
|
# DisplayTextID stores its argument here -- a sprite slot, or a sign's text id --
|
|
# before it looks the text up, so it names what a conversation is with.
|
|
'wSpriteIndex': "DisplayTextID's argument, a sprite slot or a text id",
|
|
# Non-zero for the frames of a step; the overworld only reads an A press at zero.
|
|
'wWalkCounter': 'frames left in the step the player is taking',
|
|
# Two bytes per sprite slot: (item id, 0) for an ITEM-flagged object_event,
|
|
# (trainer class, trainer number) for a TRAINER one, zeroes otherwise.
|
|
'wMapSpriteExtraData': 'per sprite slot: item id, or trainer class and number',
|
|
# One bit per global toggleable object; PickUpItem's HideObject sets an item
|
|
# ball's bit once GiveItem has succeeded.
|
|
'wToggleableObjectFlags': 'global hidden bits of every toggleable object',
|
|
# The current map's (sprite slot, global toggleable index) pairs, $ff-terminated.
|
|
'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",
|
|
}
|
|
|
|
|
|
#: Constants the decomp defines through its `const` enumeration rather than with a
|
|
#: plain `EQU`, so `constants()` cannot evaluate their expressions. They matter here
|
|
#: because `NUM_TMS + NUM_HMS` is the size of `wMonHLearnset`, and that one
|
|
#: declaration is what kills the cursor on its way through the battle engine's
|
|
#: scratch bytes -- the region `wCapturedMonSpecies` lives in.
|
|
#:
|
|
#: Each is *counted* from the decomp rather than written out by hand, which is the
|
|
#: same rule the rest of this tool follows. `DEF NUM_HMS EQU const_value - HM01` is
|
|
#: by construction the number of `add_hm` definitions after `HM01`, and
|
|
#: `item_constants.asm`'s own `ASSERT NUM_TMS == const_value - TM01` ties `NUM_TMS`
|
|
#: to the number of `add_tm` definitions -- so `NUM_TMS` is counted *and* compared
|
|
#: against the literal the same file declares, and a decomp that moved one without
|
|
#: the other stops the run instead of producing an address.
|
|
COUNTED = {
|
|
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
|
|
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
|
|
# The engagement rewards need two more, for the same reason: each is an
|
|
# `EQU const_value` over an enumeration, and each is the size of a declaration
|
|
# the cursor has to cross. `ds NUM_STATS` sits between wFontLoaded and
|
|
# wTrainerClass, the bracket of wWalkCounter; `flag_array NUM_CITY_MAPS` sits
|
|
# between wNumHoFTeams and wStatusFlags5, the bracket of the toggleable-object
|
|
# and hidden-item flags. Both are counted only up to the line that defines
|
|
# them, which is where `const_value` is read.
|
|
'NUM_STATS': (
|
|
'constants/battle_constants.asm', r'^\s*const\s+STAT_\w+', r'^DEF NUM_STATS\b'
|
|
),
|
|
'NUM_CITY_MAPS': (
|
|
'constants/map_constants.asm', r'^\s*map_const\s+\w+', r'^DEF NUM_CITY_MAPS\b'
|
|
),
|
|
}
|
|
|
|
|
|
def count_in(root: Path, path: str, pattern: str, stop: str | None = None) -> int:
|
|
"""Lines of `path` matching `pattern`, up to the first line matching `stop`."""
|
|
text = (root / path).read_text()
|
|
if stop is not None:
|
|
match = re.search(stop, text, re.M)
|
|
if match is None:
|
|
raise SystemExit(f'{path}: no line matches {stop}')
|
|
text = text[: match.start()]
|
|
return len(re.findall(pattern, text, re.M))
|
|
|
|
|
|
def pinned(text: str) -> dict[str, int]:
|
|
"""Every address `symbols.rs` carries today, by symbol name."""
|
|
return {
|
|
name: int(value, 16)
|
|
for name, value in re.findall(r'pub const (w\w+): u16 = 0x([0-9a-f]{4});', text)
|
|
}
|
|
|
|
|
|
def constants(root: Path) -> dict[str, int]:
|
|
"""Every `DEF NAME EQU <expression>` the declarations below need.
|
|
|
|
Resolved by repeated passes rather than in one, because the decomp defines
|
|
constants in terms of each other (`SURROUNDING_WIDTH EQU SCREEN_BLOCK_WIDTH *
|
|
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
|
|
out, which kills the cursor at any declaration that uses it.
|
|
"""
|
|
counted = {name: count_in(root, *spec) for name, spec in COUNTED.items()}
|
|
pending: dict[str, str] = {}
|
|
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
|
|
(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]+)', 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
|
|
for name in list(pending):
|
|
try:
|
|
out[name] = size_of(pending[name], out)
|
|
except Unevaluable:
|
|
continue
|
|
del pending[name]
|
|
progressed = True
|
|
if not progressed:
|
|
break
|
|
for name, value in counted.items():
|
|
if out.get(name, value) != value:
|
|
raise SystemExit(
|
|
f'{name}: the decomp declares {out[name]} and defines {value} of them'
|
|
)
|
|
out[name] = value
|
|
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('$'):
|
|
return int(token[1:], 16)
|
|
if token.startswith('%'):
|
|
return int(token[1:], 2)
|
|
return int(token, 10)
|
|
|
|
|
|
class Unevaluable(Exception):
|
|
"""A declaration this tool will not guess the size of."""
|
|
|
|
|
|
def size_of(expression: str, known: dict[str, int]) -> int:
|
|
"""Bytes in a `ds`/`EQU` expression: the decomp's own arithmetic, nothing else.
|
|
|
|
`$`/`%` literals and the constants resolved so far are substituted, the
|
|
`tiles` unit is a factor of sixteen, and what is left must be plain
|
|
arithmetic over integers -- so a name this tool has not resolved, a function
|
|
call or anything else raises [`Unevaluable`] instead of becoming a guess.
|
|
"""
|
|
expression = expression.split(';')[0].strip()
|
|
if not expression:
|
|
raise Unevaluable('empty')
|
|
scale = 1
|
|
if expression.endswith('tiles'):
|
|
expression = expression[: -len('tiles')].strip()
|
|
scale = 16 # one 8x8 2bpp tile is 16 bytes
|
|
def substitute(match: re.Match[str]) -> str:
|
|
token = match.group(0)
|
|
if token[0] in '$%':
|
|
return str(number(token))
|
|
if token in known:
|
|
return str(known[token])
|
|
raise Unevaluable(token)
|
|
substituted = re.sub(r'\$[0-9A-Fa-f_]+|%[01_]+|[A-Za-z_]\w*', substitute, expression)
|
|
if not re.fullmatch(r'[\d\s()+\-*/]+', substituted):
|
|
raise Unevaluable(expression)
|
|
try:
|
|
value = eval(substituted, {'__builtins__': {}}, {}) # arithmetic only, checked above
|
|
except (SyntaxError, ZeroDivisionError, TypeError) as error:
|
|
raise Unevaluable(expression) from error
|
|
if not isinstance(value, int):
|
|
raise Unevaluable(expression)
|
|
return value * scale
|
|
|
|
|
|
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.
|
|
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
|
|
if not line or line.startswith(('IF', 'ELSE', 'ENDC', 'ASSERT')):
|
|
continue
|
|
if re.match(r'^\w+::?$', line):
|
|
continue
|
|
if line.startswith('db'):
|
|
total += max(1, len([part for part in line[2:].split(',') if part.strip()]))
|
|
elif line.startswith('dw'):
|
|
total += 2 * max(1, len([p for p in line[2:].split(',') if p.strip()]))
|
|
elif line.startswith('ds '):
|
|
try:
|
|
total += size_of(line[3:], known)
|
|
except Unevaluable:
|
|
total = None
|
|
break
|
|
else:
|
|
total = None
|
|
break
|
|
if total is not None:
|
|
out[name] = total
|
|
if fields is not None:
|
|
fields[name] = offsets
|
|
return out
|
|
|
|
|
|
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."""
|
|
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] = {}
|
|
# Symbols counted since the last pinned anchor, held back until a pinned
|
|
# address after them agrees.
|
|
pending_run: dict[str, int] = {}
|
|
order: list[str] = []
|
|
checked = 0
|
|
problems: list[str] = []
|
|
lost: list[str] = []
|
|
# UNION frames: (start address, widest branch so far, whether a branch was
|
|
# unevaluable). A frame whose start is unknown, or any one of whose branches this
|
|
# tool could not size, poisons the whole union: what the section advances by is
|
|
# the widest branch, so one branch it cannot measure means it cannot measure any.
|
|
unions: list[tuple[int | None, int, bool]] = []
|
|
index = 0
|
|
while index < len(lines):
|
|
raw = lines[index]
|
|
index += 1
|
|
line = raw.split(';')[0].strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith('SECTION'):
|
|
# A section's address comes from the linker, not the source. WRAM0
|
|
# sections are packed in declaration order, so the cursor carries on
|
|
# -- and the next address `symbols.rs` pins is what tests that: any
|
|
# padding the linker inserted would land as a MISMATCH and this tool
|
|
# would emit nothing.
|
|
continue
|
|
if line == 'UNION':
|
|
unions.append((cursor, 0, False))
|
|
continue
|
|
if line == 'NEXTU':
|
|
if not unions:
|
|
cursor = None
|
|
continue
|
|
start, widest, poisoned = unions.pop()
|
|
poisoned = poisoned or start is None or cursor is None
|
|
if not poisoned:
|
|
widest = max(widest, cursor - start)
|
|
unions.append((start, widest, poisoned))
|
|
cursor = start
|
|
continue
|
|
if line == 'ENDU':
|
|
if not unions:
|
|
cursor = None
|
|
continue
|
|
start, widest, poisoned = unions.pop()
|
|
if poisoned or start is None or cursor is None:
|
|
cursor = None
|
|
continue
|
|
cursor = start + max(widest, cursor - start)
|
|
continue
|
|
if line.startswith(('FOR ', 'REPT ')):
|
|
# Evaluate the body only when every line of it has a known size.
|
|
head = line.split(None, 1)[1]
|
|
# `REPT n`, `FOR v, stop` and `FOR v, start, stop`: rgbasm's own
|
|
# three forms, and the third iterates stop - start times.
|
|
arguments = [part.strip() for part in head.split(',')]
|
|
count_token = arguments[-1]
|
|
start_token = arguments[-2] if line.startswith('FOR ') and len(arguments) == 3 else None
|
|
body: list[str] = []
|
|
depth = 1
|
|
while index < len(lines):
|
|
inner = lines[index].split(';')[0].strip()
|
|
index += 1
|
|
if inner.startswith(('FOR ', 'REPT ')):
|
|
depth += 1
|
|
if inner == 'ENDR':
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
body.append(inner)
|
|
try:
|
|
count = size_of(count_token, known)
|
|
if start_token is not None:
|
|
count -= size_of(start_token, known)
|
|
per = 0
|
|
for inner in body:
|
|
per += declaration_size(inner, known, macros)
|
|
if cursor is not None:
|
|
cursor += count * per
|
|
except Unevaluable:
|
|
if cursor is not None:
|
|
lost.append(f'line {index}: {line}')
|
|
cursor = None
|
|
pending_run.clear()
|
|
continue
|
|
label = re.match(r'^(w\w+)::', line)
|
|
if label is not None:
|
|
name = label.group(1)
|
|
if name in table:
|
|
if cursor is not None and cursor != table[name]:
|
|
problems.append(
|
|
f'{name}: wram.asm gives ${cursor:04x}, symbols.rs pins ${table[name]:04x}'
|
|
)
|
|
pending_run.clear()
|
|
elif cursor is not None:
|
|
checked += 1
|
|
# Everything counted since the last anchor is now bracketed
|
|
# by two addresses the table already carries.
|
|
resolved.update(pending_run)
|
|
order.extend(pending_run)
|
|
pending_run.clear()
|
|
else:
|
|
if verbose:
|
|
lost.append(f'cold anchor at {name} (line {index})')
|
|
pending_run.clear()
|
|
cursor = table[name]
|
|
elif cursor is not None:
|
|
pending_run[name] = cursor
|
|
line = line[label.end() :].strip()
|
|
if not line:
|
|
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)
|
|
except Unevaluable:
|
|
if cursor is not None:
|
|
lost.append(f'line {index}: {line}')
|
|
cursor = None
|
|
pending_run.clear()
|
|
if verbose:
|
|
for entry in lost:
|
|
print(f'UNEVALUABLE {entry}')
|
|
return resolved, problems, checked
|
|
|
|
|
|
def declaration_size(line: str, known: dict[str, int], macros: dict[str, int]) -> int:
|
|
"""Bytes one declaration line reserves, or [`Unevaluable`]."""
|
|
line = line.split(';')[0].strip()
|
|
if not line or line.endswith('::') or line.startswith(('ENDSECTION', 'ASSERT', 'ENDR')):
|
|
return 0
|
|
# Any label, including the `{02d:n}` interpolations a FOR body labels its
|
|
# iterations with; what reserves the bytes is the declaration after it.
|
|
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
|
|
if not line:
|
|
return 0
|
|
if line == 'db':
|
|
return 1
|
|
if line == 'dw':
|
|
return 2
|
|
if line.startswith('db '):
|
|
return max(1, len([part for part in line[3:].split(',') if part.strip()]))
|
|
if line.startswith('dw '):
|
|
return 2 * max(1, len([part for part in line[3:].split(',') if part.strip()]))
|
|
if line.startswith('ds '):
|
|
return size_of(line[3:], known)
|
|
if line.startswith('flag_array '):
|
|
return math.ceil(size_of(line[len('flag_array ') :], known) / 8)
|
|
head = line.split(None, 1)[0]
|
|
if head in macros:
|
|
return macros[head]
|
|
raise Unevaluable(line)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--pokered', type=Path, required=True)
|
|
parser.add_argument('--verbose', action='store_true', help='name every declaration it will not size')
|
|
parser.add_argument('--emit', action='store_true', help='write the new addresses into symbols.rs')
|
|
args = parser.parse_args()
|
|
|
|
text = SYMBOLS.read_text()
|
|
commit = re.search(r'POKERED_COMMIT: &str = "([0-9a-f]{40})"', text)
|
|
if commit is None:
|
|
raise SystemExit('symbols.rs carries no POKERED_COMMIT')
|
|
head = (args.pokered / '.git/HEAD').read_text().strip()
|
|
if head.startswith('ref:'):
|
|
head = (args.pokered / '.git' / head.split()[1]).read_text().strip()
|
|
if head != commit.group(1):
|
|
raise SystemExit(
|
|
f'checkout is at {head}, symbols.rs pins {commit.group(1)}: two revisions of the '
|
|
'disassembly renumber RAM relative to each other'
|
|
)
|
|
|
|
table = pinned(text)
|
|
known = constants(args.pokered)
|
|
resolved, problems, checked = walk(args.pokered, table, known, args.verbose)
|
|
if problems:
|
|
for problem in problems:
|
|
print(f'MISMATCH {problem}')
|
|
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted')
|
|
print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement')
|
|
|
|
# A name this tool has already emitted is pinned, so the walk meets it as an
|
|
# anchor rather than resolving it: it was re-derived all the same, and the
|
|
# comparison above is what says so.
|
|
missing = [name for name in WANTED if name not in resolved and name not in table]
|
|
if missing:
|
|
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
|
|
for name in WANTED:
|
|
if name in resolved:
|
|
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
|
|
else:
|
|
print(f'{name} = ${table[name]:04x} (already pinned; {WANTED[name]})')
|
|
|
|
if not args.emit:
|
|
return
|
|
block = re.search(r'(pub mod ram \{\n)(.*?)(\n\}\n)', text, re.S)
|
|
if block is None:
|
|
raise SystemExit('no ram block in symbols.rs')
|
|
rows = []
|
|
for row in block.group(2).splitlines():
|
|
name = re.match(r'\s*pub const (w\w+): u16 = 0x([0-9a-f]{4});', row)
|
|
if name is None:
|
|
continue
|
|
rows.append((int(name.group(2), 16), name.group(1)))
|
|
for name in WANTED:
|
|
if name not in table:
|
|
rows.append((resolved[name], name))
|
|
rows = sorted(set(rows))
|
|
width = max(len(name) for _, name in rows)
|
|
body = '\n'.join(
|
|
f' pub const {name}: u16 = 0x{address:04x};'.ljust(width + 31)
|
|
+ f'// {address}'
|
|
for address, name in rows
|
|
)
|
|
SYMBOLS.write_text(text[: block.start(2)] + body + text[block.end(2) :])
|
|
print(f'symbols.rs: {len(rows)} addresses written')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|