Files
gen1recomp/tools/extract/text.py
T
thibautbus 934a4c55ca Route a handful of pokered dialogue labels through game.data.text
Several hand-ported scripts carry pokered dialogue as inline English
literals instead of reading game.data.text, because the real ROM
label was never reachable from data/generated/text.lua:
ViridianCityYoungster2OkThenText/CaterpieAndWeedleDescriptionText,
TMNotebookText, the SS Anne kitchen cook's three dish lines, and the
Viridian fisher's pre-gift line (data/scripts/story5.lua's gift()
already read t[label] here, just had a stale comment and a missing
fallback).

Traced the actual cause carefully -- there are two independent,
differently-behaved label scanners in this codebase:

- tools/extract/text.py's parse_text_file() requires a label to
  start with "_" to be collected. This is a real bug (confirmed
  against a real pret/pokered checkout), but this function has no
  callers anywhere in the tree and no __main__ entry point -- it
  looks like dead code left over from an earlier version of the
  pipeline.
- The function that actually produces the shipped label list is
  text_metadata() in tools/make_rom_manifest.py, which feeds
  manifest["text"]["labels"], which build_rom_data.py's
  extract_text() iterates to decode each label straight from the
  ROM. text_metadata() already uses the permissive regex (no "_"
  requirement) since commit 0f581e2f.

So the actual blocker is that the committed tools/rom_manifest.json
was stale relative to text_metadata()'s current code, not a source
bug. Verified by rebuilding pret/pokered from source with RGBDS
(reproducible -- the resulting pokered.gbc/pokeblue.gbc hash to the
same canonical SHA-1s gen1recomp already pins, so no cartridge dump
was involved anywhere here) and running the real, unmodified
make_rom_manifest.py against it: 2595 labels against the committed
manifest's 2585, a clean superset containing everything these
scripts need. SilphCo2FSilphWorkerFPleaseTakeThisText is the one
exception already in the manifest -- confirmed by commit 0f581e2f
("so many bugs i cannot even breathe") that it was hand-patched in
exactly this same targeted way, for issue #393.

Fix:
- tools/extract/text.py: relaxed parse_text_file()'s regex to match
  text_metadata()'s, for consistency (no effect on what ships, since
  nothing calls this function, but no reason to leave a legacy copy
  of the same scanner out of sync).
- Four scripts read the real label first (t[label] or fallback, the
  established pattern): celadon_eevee.lua, ss_anne_kitchen.lua,
  viridian_city.lua, story5.lua (comment/fallback only, lookup was
  already correct).
- tools/rom_manifest.json and tools/rom_manifest_blue.json:
  regenerated for real -- both files are the direct, unedited output
  of running make_rom_manifest.py/make_blue_manifest.py against a
  real pret/pokered checkout at POKERED_REVISION, not hand-assembled
  or reverse-engineered to match. Only safe because of the two fixes
  below, which exist specifically so a real run doesn't regress
  anything the previously-committed files had. Diffing a real
  make_rom_manifest.py run against that previous file (1143 lines
  out of 45253) found exactly what a naive "just regenerate against
  whatever pokered HEAD is handy" would have silently broken:
  - pret/pokered commit 079d1cc92fc3b0ec82bc1418c2b4045bfca84620
    (PR #596, 2026-08-06) renamed
    _SilphCo10FGiovanniILostAgainText/_SilphCo10FPorygonText to
    _SilphCo11F... (they live in text/SilphCo11F.asm, Giovanni's
    floor). data/scripts/victories.lua:183 still hardcodes the old
    name, and extracting under pokered's new name would silently
    blank Giovanni's "I lost again!?" rematch line. Fix is the pin
    below: POKERED_REVISION is pinned to the last commit before this
    rename, so today's generator output matches victories.lua
    natively, no engine code touched, no generator-side workaround
    either. Advancing the pin past this commit is a real, welcome
    future upgrade -- it just needs victories.lua's labels (and
    anything else's) fixed up in the same change.
  - trainerPartyOverrides.OPP_CHIEF (Giovanni's Celadon gym team)
    wasn't produced by any code under tools/ at all. Traced why:
    pret/pokered's data/trainers/parties.asm has "ChiefData: ; none"
    -- the Celadon Chief's battle is unused/cut content in the
    original game, and RomExtractor.lua's own comment confirms
    gen1recomp reimplements it as a real fight using a hand-authored
    party for exactly that reason -- no pokered commit, old or new,
    will ever produce this data. Added a TRAINER_PARTY_OVERRIDES
    constant to make_rom_manifest.py so this survives every future
    regeneration automatically; verified it reproduces the committed
    value byte-for-byte and flows through to Blue/Yellow for free
    via their existing derive-from-Red path.
  - trainerHeaders.MtMoonB2F's Super Nerd slot is pre-existing
    fabricated data, not pokered drift: his event name,
    EVENT_BEAT_MT_MOON_3_SUPER_NERD, has never existed in pokered at
    any point in its history (the real name,
    EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD, has been stable since 2015),
    nor in gen1recomp's own event_flags.lua; trainerDefeated()
    checks defeatedTrainers[npc.id] first, the same pattern already
    used for the Fighting Dojo's Karate Master, so this entry is
    very likely already inert. field.seafoam also differs from a
    fresh regeneration (two showObject boulder-toggle IDs in the B3F
    puzzle, a live gameplay system nobody has verified either value
    against), and field.tradeArt is new content a fresh extraction
    produces that was never shipped. None of those three are this
    PR's problem to fix, but a real generator run has to do
    something with them regardless -- so make_rom_manifest.py gets a
    new apply_known_nonreproducible_overrides(), called right after
    text_metadata()/field_metadata(), that pins MtMoonB2F and
    seafoam back to what was already shipped and drops tradeArt,
    each with a comment explaining why and what the real fix looks
    like (MtMoonB2F needs a Data:seedMtMoonB2FSuperNerd()-style
    engine seed, not manifest data). Verified this override function
    was complete and correct -- diffed a real run against the
    previously-committed file first, empty -- before trusting it to
    write tools/rom_manifest.json/_blue.json directly; both are now
    literally that generator's output, not hand-assembled.
    tools/rom_manifest_yellow.json isn't touched by this PR at all:
    it already had all ten labels, and make_yellow_manifest.py has
    no matching override yet for its own field.oldManBattle outlier,
    so regenerating it for real isn't safe the same way yet.
- tools/make_rom_manifest.py: added a POKERED_REVISION pin --
  cf621a76d4941c93c078eb38e0880fe8db48ef40, the last pret/pokered
  commit before the Silph Co rename above, chosen deliberately
  rather than current HEAD -- and a check_pokered_revision() guard
  main() calls before generating: fails loudly if --pokered isn't
  at that commit instead of silently absorbing whatever upstream
  renames or restructures since, with an explicit
  --allow-revision-mismatch escape hatch for a deliberate pin bump.
  That's the intended way this pin moves forward: diff a fresh run
  against the committed manifest, fix up whatever engine code
  depends on by exact name, and bump POKERED_REVISION in the same
  change -- a conscious, reviewable decision instead of a silent
  contributor default. Wired the same guard into
  make_blue_manifest.py and make_yellow_manifest.py for their own
  --pokered/--pokeyellow checkouts; make_yellow_manifest.py gets its
  own POKEYELLOW_REVISION (e6ba56989b0f2694f393e6924820be11dcc1fbb8,
  verified here). The pin and its guard live entirely in the
  generator source, not also embedded as a field in the shipped
  manifests -- that would be redundant with the .py constant next to
  it in the same commit, for no protection the guard doesn't already
  give.
- tools/make_rom_manifest.py, make_blue_manifest.py,
  make_yellow_manifest.py: switched json.dump(..., ensure_ascii=
  False, ...) to ensure_ascii=True to match how the committed
  manifests were actually encoded (escaped \uXXXX rather than
  literal UTF-8). Purely cosmetic -- json.load parses both
  identically -- but needed so that a fresh make_rom_manifest.py run
  at POKERED_REVISION now produces tools/rom_manifest.json
  byte-for-byte (plain diff empty), not just content-equal. One
  small, pre-existing cosmetic mismatch remains and wasn't chased: a
  single nested dict, field.cardKeyDoors.doors, has its
  SILPH_CO_10F/11F keys in natural floor order in the committed file
  instead of the sort_keys=True lexicographic order everything else
  in the file uses -- same content either way.
- tests/rom_manifest_generator_test.py: new ROM-free unit tests (same
  style as the existing tests/build_rom_data_cli_test.py, wired into
  scripts/test.sh as a T0 tier) for check_pokered_revision() and
  apply_known_nonreproducible_overrides() -- matching/mismatched/
  bypassed/unresolvable-checkout revision cases, and that the
  MtMoonB2F/seafoam/tradeArt overrides land correctly (including
  alongside a populated map entry, and without erroring when
  tradeArt is already absent). Doesn't replace the manual real-ROM
  verification above, which needs an actual pokered/RGBDS toolchain
  -- but a future typo or logic slip in either function now fails
  immediately instead of only surfacing next time someone happens to
  redo that manual check.
- Left data/scripts/flavor/silph_co_9f.lua's nurse dialogue (labels
  also added to both manifests here) untouched code-wise: static
  command table, not a function, needs its
  face_player/heal_party/fade state machine restructured to use
  t[label] safely, and show_text's un-resolved-label fallback prints
  the label name literally rather than English -- not safe without
  interactive testing.

Checked Yellow's equivalent case (Melanie's House) since it looked
like the same shape: it isn't actually broken.
tools/make_yellow_manifest.py's YELLOW_EXTRA_TEXT_LABELS already
force-includes those eight labels, and a real built
dialogue_yellow.lua already has correct French translations for
them. tools/rom_manifest_yellow.json also already carries all ten
labels this PR adds to Red/Blue. No label changes needed there.

Tested: patched parse_text_file() against a real pret/pokered
checkout (+11 labels, 0 removed, all clearly dialogue-shaped);
rebuilt pokered.gbc/pokeblue.gbc with RGBDS at current pret/pokered
HEAD (confirmed hashes to gen1recomp's own canonical SHA-1s) and
diffed a fresh make_rom_manifest.py run there against the committed
manifest to map out what a full regen at HEAD would need; re-checked
out the same pokered checkout at cf621a76 (POKERED_REVISION),
rebuilt both ROMs again (same canonical hashes); with
apply_known_nonreproducible_overrides() and the ensure_ascii fix in
place, ran both generators to a scratch path first and diffed
against the then-committed manifests -- empty -- before running them
again writing tools/rom_manifest.json/_blue.json directly, so both
files are now the generator's literal, unedited output (git diff on
that final write: 18 lines moved in rom_manifest.json, exactly the
pre-existing SILPH_CO_10F/11F ordering quirk; zero lines changed in
rom_manifest_blue.json); rebuilt pokeyellow.gbc the same way, confirmed its
canonical hash, and confirmed make_yellow_manifest.py's symbols/
text/trainerHeaders/trainerPartyOverrides also come out byte-for-
byte identical to the committed tools/rom_manifest_yellow.json; ran
the real build_rom_data.py --only text against all three rebuilt
ROMs and confirmed every added label decodes from real ROM bytes
matching the English fallback literals exactly; verified
check_pokered_revision() actually raises on a deliberate mismatch
before relying on it to gate the runs above; python3
tests/rom_manifest_generator_test.py: 9/9 pass; luajit
tests/run_engine.lua: 250/250 suites pass.
2026-08-20 09:42:20 +02:00

372 lines
13 KiB
Python

"""Extract dialogue text and the character map.
Sources:
text/*.asm -> _SomeText:: labels with text/line/cont/para macros
constants/charmap.asm -> character -> glyph code mapping (for the font)
scripts/<Map>.asm -> TEXT_* constant -> text label resolution
Text encoding in the generated tables:
\n new line inside a page (`line`)
\v scrolled line (`cont`)
\f new page (`para` / <PAGE>)
{PLAYER} {RIVAL} {TARGET} {USER} ... runtime string tokens
`#` expands to "POKé" and <PKMN>/<PC>/<TM>/... expand per the charmap, so
generated strings contain plain (UTF-8) text renderable by the glyph font.
Unknown text commands emit warnings and a {UNSUPPORTED:...} token so nothing
is silently dropped (docs/extraction-notes.md lists the known ones).
"""
import os
import re
from . import util
from .util import read_asm, warn
# Multi-char charmap sequences that expand to plain text.
EXPANSIONS = {
"#": "POKé",
"<PKMN>": "POKéMON",
"<PC>": "PC",
"<TM>": "TM",
"<TRAINER>": "TRAINER",
"<ROCKET>": "ROCKET",
"<……>": "……",
"<LV>": "{LV}",
"<PLAYER>": "{PLAYER}",
"<RIVAL>": "{RIVAL}",
"<TARGET>": "{TARGET}",
"<USER>": "{USER}",
"<ID>": "{ID}",
"<PARA>": "\f",
"<PAGE>": "\f",
"<LINE>": "\n",
"<CONT>": "\v",
"<NEXT>": "\n",
"<DONE>": "",
"<PROMPT>": "",
"<NULL>": "",
"@": "",
}
# text macro -> separator prepended before its string argument
STRING_MACROS = {
"text": "",
"next": "\n", # not used in red, but harmless
"line": "\n",
"cont": "\v",
"para": "\f",
"page": "\f",
"text_start": "",
}
# Macros that end a text block.
END_MACROS = {"done", "prompt", "text_end", "text_promptbutton",
"text_waitbutton", "dex"}
# Macros we understand but represent as tokens (dynamic content).
DYNAMIC_MACROS = {
"text_ram": "RAM",
"text_decimal": "NUM",
"text_bcd": "NUM",
"text_low": "",
"text_pause": "",
"text_dots": "DOTS",
}
def decode_string(s, lineno, path):
"""Expand charmap sequences inside a quoted asm string."""
out = []
i = 0
while i < len(s):
ch = s[i]
if ch == "<":
end = s.find(">", i)
if end == -1:
warn(f"{path}:{lineno}: unterminated <...> in string")
out.append(ch)
i += 1
continue
tok = s[i:end + 1]
if tok in EXPANSIONS:
out.append(EXPANSIONS[tok])
else:
# single glyph tokens like <BOLD_V>, <COLON>, <ED>
out.append("{" + tok[1:-1] + "}")
i = end + 1
elif ch in EXPANSIONS:
out.append(EXPANSIONS[ch])
i += 1
else:
out.append(ch)
i += 1
return "".join(out)
def parse_text_file(path, texts, rel):
"""Parse one text/*.asm file into texts[label] = string."""
label = None
parts = []
unsupported = set()
skip_vc_branch = False
def flush():
nonlocal label, parts
if label is not None:
texts[label] = {"text": "".join(parts), "source": rel}
label, parts = None, []
for lineno, line in read_asm(path):
s = line.strip()
if not s:
continue
if s.startswith("vc_patch "):
continue
if s.startswith("IF DEF(_RED_VC) || DEF(_BLUE_VC)"):
skip_vc_branch = True
continue
if skip_vc_branch:
if s == "ELSE":
skip_vc_branch = False
continue
if s in ("ENDC", "vc_patch_end"):
continue
# Most pokered text labels start with "_" by convention, but a
# handful of real dialogue labels don't (e.g. text/ViridianCity.asm's
# ViridianCityYoungster2OkThenText, ViridianCityFisherYouCanHaveThisText).
# text/*.asm, data/text/text_*.asm and dex_text.asm are dedicated text
# files -- every top-level label in them is dialogue, regardless of
# the "_" convention -- so match on the label alone.
m = re.match(r"(\w+)::?\s*$", s)
if m:
flush()
label = m.group(1)
continue
if label is None:
continue
m = re.match(r"(\w+)(?:\s+(.*))?$", s)
if not m:
continue
macro, rest = m.group(1), (m.group(2) or "").strip()
if macro in STRING_MACROS:
sm = re.match(r'"((?:[^"\\]|\\.)*)"', rest)
if sm:
parts.append(STRING_MACROS[macro] + decode_string(sm.group(1), lineno, rel))
elif rest:
warn(f"{rel}:{lineno}: {macro} without string literal: {rest!r}")
continue
if macro in END_MACROS:
flush()
continue
if macro in DYNAMIC_MACROS:
tokname = DYNAMIC_MACROS[macro]
if tokname:
parts.append("{" + tokname + ":" + rest.replace('"', "") + "}")
continue
if macro in ("text_far", "text_asm"):
# text banks sometimes chain; record a link token
parts.append("{FAR:" + rest + "}" if macro == "text_far" else "{ASM}")
continue
unsupported.add(macro)
parts.append("{UNSUPPORTED:" + macro + "}")
flush()
for macro in sorted(unsupported):
warn(f"{rel}: unsupported text macro '{macro}'")
def parse_marts(pokered):
"""data/items/marts.asm: clerk text label -> script_mart item list."""
marts = {}
label = None
for lineno, line in read_asm(os.path.join(pokered, "data/items/marts.asm")):
s = line.strip()
m = re.match(r"(\w+)::?\s*$", s)
if m:
label = m.group(1)
continue
m = re.match(r"script_mart\s+(.*)$", s)
if m and label:
marts[label] = [a for a in re.split(r",\s*", m.group(1)) if a]
label = None
return marts
def parse_script_text_pointers(pokered):
"""Resolve TEXT_* constants to text labels via scripts/*.asm.
A `dw_const SomeText, TEXT_FOO` entry points at a local label whose body
is usually `text_far _SomeText` + `text_end`. Bodies containing
text_asm are flagged so the runtime knows a hand-ported script owns
them. Special TX_SCRIPT macros are recognized: script_mart item lists
(also resolved from data/items/marts.asm for labels defined there),
script_pokecenter_nurse, script_pokecenter_pc and
script_cable_club_receptionist (engine/link/cable_club_npc.asm).
Returns {map_label: {TEXT_CONST: {text=..., asm=bool, mart=..., ...}}}.
"""
scripts_dir = os.path.join(pokered, "scripts")
marts = parse_marts(pokered)
result = {}
for fname in sorted(os.listdir(scripts_dir)):
if not fname.endswith(".asm"):
continue
map_label = fname[:-4]
path = os.path.join(scripts_dir, fname)
lines = read_asm(path)
pointers = {} # TEXT_CONST -> local label
for lineno, line in lines:
m = re.match(r"dw_const\s+(\w+),\s*(TEXT_\w+)", line.strip())
if m:
pointers[m.group(2)] = m.group(1)
# index label -> line span
label_at = {}
for i, (lineno, line) in enumerate(lines):
m = re.match(r"(\w+):{1,2}\s*$", line.strip())
if m:
label_at[m.group(1)] = i
entries = {}
for const, label in pointers.items():
info = {"label": label}
i = label_at.get(label)
if i is None:
if label in marts:
info["mart"] = marts[label]
else:
info["asm"] = True
else:
j = i + 1
fars = []
is_asm = False
while j < len(lines):
s = lines[j][1].strip()
j += 1
if not s:
continue
if re.match(r"\w+:{1,2}\s*$", s): # next top-level label
break
m = re.match(r"text_far\s+(\w+)", s)
if m:
fars.append(m.group(1))
continue
if s.startswith("text_asm"):
is_asm = True
continue
m = re.match(r"script_mart\s+(.*)$", s)
if m:
info["mart"] = [a for a in re.split(r",\s*", m.group(1)) if a]
continue
if s.startswith("script_pokecenter_nurse"):
info["nurse"] = True
continue
if s.startswith("script_pokecenter_pc"):
info["pc"] = True
continue
if s.startswith("script_cable_club_receptionist"):
# TX_SCRIPT_CABLE_CLUB_RECEPTIONIST -> CableClubNPC
# (home/text_script.asm, engine/link/cable_club_npc.asm)
info["cableClub"] = True
continue
if s.startswith("text_end") or s == "done":
break
if fars:
info["text"] = fars[0]
if is_asm:
info["asm"] = True
entries[const] = info
if entries:
result[map_label] = entries
return result
def parse_trainer_headers(pokered):
"""Extract per-map trainer headers from scripts/*.asm.
`def_trainers N` gives the object index of the first trainer (default
1); each `trainer EVENT, range, BattleText, EndBattleText,
AfterBattleText` row applies to consecutive objects. The three text
labels are local labels resolved through their `text_far` bodies.
Returns {map_label: {objIndex: {event, range, battle, won, after}}}.
"""
scripts_dir = os.path.join(pokered, "scripts")
result = {}
for fname in sorted(os.listdir(scripts_dir)):
if not fname.endswith(".asm"):
continue
map_label = fname[:-4]
lines = read_asm(os.path.join(scripts_dir, fname))
# local label -> first text_far target
far_of = {}
current = None
for lineno, line in lines:
s = line.strip()
m = re.match(r"(\w+):{1,2}\s*$", s)
if m:
current = m.group(1)
continue
m = re.match(r"text_far\s+(\w+)", s)
if m and current and current not in far_of:
far_of[current] = m.group(1)
start = None
headers = {}
idx = 0
for lineno, line in lines:
s = line.strip()
m = re.match(r"def_trainers(?:\s+(\d+))?$", s)
if m:
start = int(m.group(1)) if m.group(1) else 1
idx = 0
continue
m = re.match(r"trainer\s+(EVENT_\w+),\s*(\d+),\s*(\w+),\s*(\w+),\s*(\w+)", s)
if m and start is not None:
obj_index = start + idx
idx += 1
headers[obj_index] = {
"event": m.group(1),
"range": int(m.group(2)),
"battle": far_of.get(m.group(3)),
"won": far_of.get(m.group(4)),
"after": far_of.get(m.group(5)),
"source": f"scripts/{fname}:{lineno}",
}
if headers:
result[map_label] = headers
return result
def extract(pokered, out_dir):
texts = {}
text_dir = os.path.join(pokered, "text")
for fname in sorted(os.listdir(text_dir)):
if fname.endswith(".asm"):
parse_text_file(os.path.join(text_dir, fname), texts, f"text/{fname}")
# engine strings (nurse dialogue, battle messages, ...) live in
# data/text/text_*.asm with the same macro format
data_text_dir = os.path.join(pokered, "data/text")
for fname in sorted(os.listdir(data_text_dir)):
if re.match(r"text_\d+\.asm$", fname):
parse_text_file(os.path.join(data_text_dir, fname), texts,
f"data/text/{fname}")
# Pokédex descriptions
parse_text_file(os.path.join(pokered, "data/pokemon/dex_text.asm"), texts,
"data/pokemon/dex_text.asm")
pointers = parse_script_text_pointers(pokered)
trainer_headers = parse_trainer_headers(pokered)
util.write_lua(os.path.join(out_dir, "text.lua"),
{k: v["text"] for k, v in sorted(texts.items())},
header="Source: pret/pokered text/*.asm")
util.write_lua(os.path.join(out_dir, "text_pointers.lua"), pointers,
header="Source: pret/pokered scripts/*.asm (def_text_pointers tables)\n"
"Entries may carry mart/nurse/pc/cableClub markers from TX_SCRIPT macros.")
util.write_lua(os.path.join(out_dir, "trainer_headers.lua"), trainer_headers,
header="Source: pret/pokered scripts/*.asm (def_trainers tables)\n"
"Keyed by map label, then object index; range is sight distance.")
return texts, pointers