Files
gen1recomp/tools/make_rom_manifest.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

886 lines
32 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate non-ROM metadata used by tools/build_rom_data.py.
The resulting JSON contains symbolic IDs, dimensions, and enum names that
were erased during assembly. It deliberately contains no ROM byte ranges or
graphics/audio payload.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import (battle_anims, constants, field, font, items, maps, # noqa: E402
pokemon, sprites, text, tilesets, trainers, util)
from rom_data import CANONICAL_RED_SHA1, SymbolTable # noqa: E402
# The pret/pokered commit this manifest was last verified against. Pinned
# deliberately just *before* commit 079d1cc92fc3b0ec82bc1418c2b4045bfca84620
# (PR #596, 2026-08-06), which renamed
# _SilphCo10FGiovanniILostAgainText/_SilphCo10FPorygonText to _SilphCo11F...
# -- data/scripts/victories.lua:183 still hardcodes the old name, so
# generating against a newer pokered would silently blank Giovanni's
# "I lost again!?" rematch line. Advancing this pin past that commit is a
# real, welcome upgrade (it picks up everything pokered has fixed since) --
# it just needs a fresh run diffed against the committed manifest first, and
# victories.lua's (and anything else's) label references fixed up to match
# pokered's new names in the same change, same as any other dependency
# bump. Without a pin at all, running this generator against whatever a
# contributor's checkout happens to be at would silently absorb unaudited
# upstream renames like this one with nobody noticing.
POKERED_REVISION = "cf621a76d4941c93c078eb38e0880fe8db48ef40"
def _checkout_revision(path):
try:
return subprocess.run(
["git", "rev-parse", "HEAD"], cwd=path, check=True,
capture_output=True, text=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return None
def check_pokered_revision(pokered, expected, *, allow_mismatch=False):
actual = _checkout_revision(pokered)
if actual is None or actual == expected or allow_mismatch:
return
raise SystemExit(
f"{pokered} is at {actual}, but this generator is pinned to "
f"{expected}. Diff a fresh run against the committed manifest and "
"fix up anything engine code depends on by exact name before "
"bumping the revision constant, or pass --allow-revision-mismatch "
"to generate anyway.")
def simple_constants(pokered, relpath, stop_at=None):
return util.parse_const_block(
os.path.join(pokered, relpath), stop_at=stop_at)
def charmap(pokered):
expansions = dict(text.EXPANSIONS)
expansions.update({
"<DOT>": ".",
"<to>": "to",
"<LV>": "{LV}",
"<ID>": "{ID}",
})
out = {}
path = os.path.join(pokered, "constants/charmap.asm")
for _, line in util.read_asm(path):
m = re.match(
r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$[0-9a-fA-F]+)',
line.strip())
if not m:
continue
value = int(m.group(2)[1:], 16)
if str(value) in out:
continue
seq = m.group(1).replace('\\"', '"')
out[str(value)] = expansions.get(seq, seq)
return out
def sfx_keys(pokered, symbols):
"""Map the one-byte sound IDs stored in MoveSoundTable to audio keys."""
out = {}
# Move animations always resolve their one-byte IDs through the battle
# SFX headers. The same address-derived IDs are reused by the other two
# audio banks, so accepting all SFX_* symbols would create collisions.
battle_bank = symbols["SFX_Pound"].bank
path = os.path.join(pokered, "constants/music_constants.asm")
for _, line in util.read_asm(path):
m = re.match(r"music_const\s+SFX_\w+,\s*(SFX_\w+)", line.strip())
if not m:
continue
label = m.group(1)
symbol = symbols.by_name.get(label)
if not symbol or symbol.bank != battle_bank:
continue
delta = symbol.address - 0x4000
if delta < 0 or delta % 3:
continue
sound_id = delta // 3
if not 0 <= sound_id <= 0xFF:
continue
key = re.sub(r"_[123]$", "", label.removeprefix("SFX_"))
out[str(sound_id)] = key
return out
def battle_animation_metadata(pokered):
"""Return names and dimensions that do not survive ROM assembly."""
consts = battle_anims.parse_anim_constants(pokered)
base_coords = battle_anims.parse_base_coords(pokered)
frame_blocks = battle_anims.parse_frame_blocks(pokered)
subanims = battle_anims.parse_subanimations(
pokered, len(frame_blocks), len(base_coords), consts)
special_effects = {
str(value): name
for name, value in consts.items()
if name.startswith("SE_")
}
sheets = (
"move_anim_0.png", "move_anim_1.png", "move_anim_0.png")
return {
"baseCoordCount": len(base_coords),
"frameBlockCount": len(frame_blocks),
"subanimCount": len(subanims),
"moveCount": battle_anims.NUM_ATTACKS,
"miscAnimations": battle_anims.MISC_ANIMS,
"subanimTypes": battle_anims.SUBANIMTYPE_NAMES,
"firstSpecialEffect": min(
int(value) for value in special_effects),
"specialEffects": special_effects,
"tilesheets": [
{
"path": f"assets/generated/battle/anims/{filename}",
"source": f"gfx/battle/{filename}",
"width": 128,
"height": 40,
}
for filename in sheets
],
}
def pokemon_metadata(pokered, dex_order):
pics = pokemon.parse_pic_files(pokered)
by_dex_const = {}
base_dir = os.path.join(pokered, "data/pokemon/base_stats")
for fname in sorted(os.listdir(base_dir)):
if not fname.endswith(".asm"):
continue
rel = f"data/pokemon/base_stats/{fname}"
stats = pokemon.parse_base_stats_file(
os.path.join(base_dir, fname), rel)
by_dex_const[stats["dexConst"]] = stats
assets = {}
for species in dex_order:
stats = by_dex_const["DEX_" + species]
front = pics.get(stats.get("picFront", ""))
back = pics.get(stats.get("picBack", ""))
assets[species] = {
"front": os.path.splitext(os.path.basename(front))[0]
if front else None,
"back": os.path.splitext(os.path.basename(back))[0]
if back else None,
"frontLabel": stats.get("picFront") if front else None,
"backLabel": stats.get("picBack") if back else None,
}
growth_rates = []
for _, line in util.read_asm(
os.path.join(pokered, "constants/pokemon_data_constants.asm")):
m = re.match(r"const\s+GROWTH_(\w+)", line.strip())
if m:
growth_rates.append(m.group(1))
return assets, growth_rates
def trainer_pic_metadata(pokered):
pics = {}
for _, line in util.read_asm(os.path.join(pokered, "gfx/pics.asm")):
m = re.match(
r'(\w+)Pic::?\s+INCBIN\s+"(gfx/trainers/[^"]+)"',
line.strip())
if m:
base = os.path.splitext(os.path.basename(m.group(2)))[0]
pics[m.group(1)] = {
"label": m.group(1) + "Pic",
"path": f"assets/generated/battle/trainers/{base}.png",
"imageBase": base,
}
labels, _ = trainers.parse_parties(pokered)
# Keep missing pictures as explicit false values. JSON null becomes nil in
# Lua and would collapse these two array positions in the runtime importer.
return [pics.get(label) or False for label in labels]
def map_metadata(pokered, map_dims):
"""Keep only map names and other labels that assembly erased."""
headers_dir = os.path.join(pokered, "data/maps/headers")
blocks_files = maps.parse_blocks_files(pokered)
toggles = maps.parse_toggleable_objects(pokered)
out = {}
for fname in sorted(os.listdir(headers_dir)):
if not fname.endswith(".asm"):
continue
header = maps.parse_header(os.path.join(headers_dir, fname))
if "const" not in header or header["const"] not in map_dims:
continue
const = header["const"]
label = header["label"]
if const in out:
def spells_const(value):
return value.upper() == const.replace("_", "")
if spells_const(out[const]["label"]) == spells_const(label):
raise ValueError(f"duplicate map header for {const}")
if spells_const(out[const]["label"]):
continue
parsed = maps.parse_objects(
os.path.join(pokered, "data/maps/objects", f"{label}.asm"))
object_names = parsed["objectNames"]
object_specs = []
for index, obj in enumerate(parsed["objects"]):
spec = {"text": obj["text"]}
for key in ("trainerClass", "trainerParty", "pokemon", "item"):
if key in obj:
spec[key] = obj[key]
if index < len(object_names):
spec["name"] = object_names[index]
if toggles.get(const, {}).get(object_names[index]) == "OFF":
spec["hidden"] = True
object_specs.append(spec)
block_rel = blocks_files.get(label, f"maps/{label}.blk")
out[const] = {
"label": label,
"blockLength": os.path.getsize(os.path.join(pokered, block_rel)),
"signTexts": [sign["text"] for sign in parsed["signs"]],
"objects": object_specs,
}
return out
def tileset_metadata(pokered, order):
"""Describe tileset payload sizes and symbolic enum names."""
from PIL import Image
labels = tilesets.parse_incbin_labels(pokered)
headers = []
path = os.path.join(pokered, "data/tilesets/tileset_headers.asm")
for _, line in util.read_asm(path):
match = tilesets.TILESET_RE.match(line.strip())
if match:
headers.append(match)
if len(headers) != len(order):
raise ValueError("tileset header count does not match constants")
out = []
for match, const_name in zip(headers, order):
name = match.group(1)
gfx_rel = labels[f"{name}_GFX"]
block_rel = labels[f"{name}_Block"]
png_rel = re.sub(r"\.2bpp$", ".png", gfx_rel)
with Image.open(os.path.join(pokered, png_rel)) as image:
width, height = image.size
block_size = os.path.getsize(os.path.join(pokered, block_rel))
if block_size % 16:
raise ValueError(f"{block_rel} is not a whole number of blocks")
out.append({
"id": const_name,
"name": name,
"imageBase": os.path.splitext(os.path.basename(png_rel))[0],
"imageWidth": width,
"imageHeight": height,
"blockCount": block_size // 16,
})
return out
def sprite_metadata(pokered, order):
"""Describe overworld sprite labels and source atlas dimensions."""
from PIL import Image
files = sprites.parse_sprite_files(pokered)
sheets = sprites.parse_sheet_table(pokered)
if len(sheets) != len(order):
raise ValueError("sprite sheet count does not match constants")
out = []
for (label, _tiles, _line), const_name in zip(sheets, order):
source = files[label]
png_path = re.sub(r"\.2bpp$", ".png", source)
with Image.open(os.path.join(pokered, png_path)) as image:
width, height = image.size
out.append({
"id": const_name,
"label": label,
"imageBase": os.path.splitext(os.path.basename(png_path))[0],
"imageWidth": width,
"imageHeight": height,
})
bike_source = files["RedBikeSprite"]
bike_png = re.sub(r"\.2bpp$", ".png", bike_source)
with Image.open(os.path.join(pokered, bike_png)) as image:
bike_width, bike_height = image.size
out_manifest = {
"order": out,
"bike": {
"label": "RedBikeSprite",
"imageBase": "red_bike",
"imageWidth": bike_width,
"imageHeight": bike_height,
},
}
# Yellow-only: the surfing-Pikachu overworld ride sheet, parallel
# to the bike entry; absent in Red/Blue. (RFC 0001)
surf_source = files.get("SurfingPikachuSprite")
if surf_source:
surf_png = re.sub(r"\.2bpp$", ".png", surf_source)
with Image.open(os.path.join(pokered, surf_png)) as image:
surf_width, surf_height = image.size
out_manifest["surfPikachu"] = {
"label": "SurfingPikachuSprite",
"imageBase": "surfing_pikachu",
"imageWidth": surf_width,
"imageHeight": surf_height,
}
return out_manifest
def text_metadata(pokered):
"""Keep text labels, runtime substitutions, and script integration data."""
paths = []
text_dir = os.path.join(pokered, "text")
paths.extend(
os.path.join(text_dir, name)
for name in sorted(os.listdir(text_dir))
if name.endswith(".asm"))
data_text_dir = os.path.join(pokered, "data/text")
paths.extend(
os.path.join(data_text_dir, name)
for name in sorted(os.listdir(data_text_dir))
if re.match(r"text_\d+\.asm$", name))
paths.append(os.path.join(pokered, "data/pokemon/dex_text.asm"))
labels = []
dynamic = {}
current = None
command_ids = {"text_ram": 1, "text_bcd": 2, "text_decimal": 9}
token_names = {"text_ram": "RAM", "text_bcd": "NUM",
"text_decimal": "NUM"}
for path in paths:
for _, line in util.read_asm(path):
stripped = line.strip()
# not every string label carries the far-text underscore
# (SilphCo2FSilphWorkerFPleaseTakeThisText and friends live in
# the script bank), and dropping those lost their strings (#393)
match = re.match(r"(\w+)::?\s*$", stripped)
if match:
current = match.group(1)
labels.append(current)
continue
match = re.match(
r"(text_ram|text_bcd|text_decimal)\s+(.+)$", stripped)
if match and current:
macro, args = match.groups()
token = (
"{" + token_names[macro] + ":"
+ args.replace('"', "") + "}")
dynamic.setdefault(current, []).append(
[command_ids[macro], token])
pointers = text.parse_script_text_pointers(pokered)
trainer_headers = text.parse_trainer_headers(pokered)
for headers in trainer_headers.values():
for header in headers.values():
header.pop("source", None)
return {
"labels": sorted(set(labels)),
"dynamic": dynamic,
"pointers": pointers,
"trainerHeaders": trainer_headers,
}
def _without_sources(value):
if isinstance(value, dict):
return {
key: _without_sources(item)
for key, item in value.items()
if key != "source"
}
if isinstance(value, list):
return [_without_sources(item) for item in value]
return value
def field_metadata(pokered):
"""Capture the port's hand-authored field integration, without artwork."""
with tempfile.TemporaryDirectory() as temp_dir:
out_dir = os.path.join(temp_dir, "data", "generated")
os.makedirs(out_dir)
return _without_sources(field.extract(pokered, out_dir))
def _audio_headers(pokered, symbols, prefix, label_prefix):
headers = {}
root = os.path.join(pokered, "audio/headers")
for filename in sorted(os.listdir(root)):
if not filename.startswith(prefix) or not filename.endswith(".asm"):
continue
match = re.search(r"(\d+)\.asm$", filename)
engine = int(match.group(1)) if match else 1
for _, line in util.read_asm(os.path.join(root, filename)):
match = re.match(rf"({label_prefix}\w+)::?\s*$", line.strip())
if not match:
continue
name = match.group(1)
symbol = symbols.by_name.get(name)
if symbol:
headers[name] = {
"engine": engine,
"bank": symbol.bank,
"address": symbol.address,
}
return headers
def _music_label(const_name, music_headers):
candidate = "Music_" + "".join(
part.capitalize()
for part in const_name.removeprefix("MUSIC_").split("_"))
folded = candidate.lower()
return next(
(name for name in music_headers if name.lower() == folded),
candidate)
def audio_metadata(pokered, symbols, map_order):
"""Names and addresses needed to interpret audio bytecode from ROM."""
music_headers = _audio_headers(
pokered, symbols, "musicheaders", "Music_")
all_sfx_headers = _audio_headers(
pokered, symbols, "sfxheaders", "SFX_")
sfx_headers = {}
for name in sorted(all_sfx_headers):
spec = all_sfx_headers[name]
base = name.removeprefix("SFX_")
suffix = f"_{spec['engine']}"
if base.endswith(suffix):
base = base[:-len(suffix)]
if base == "Headers" \
or base.startswith(("Cry", "Noise_Instrument", "Unused")):
continue
sfx_headers.setdefault(base, spec)
cry_headers = {}
for number in range(0x26):
name = f"SFX_Cry{number:02X}_1"
if name in all_sfx_headers:
cry_headers[str(number)] = all_sfx_headers[name]
noise_headers = {}
for engine in (1, 2, 3):
per_engine = {}
for number in range(1, 20):
name = f"SFX_Noise_Instrument{number:02d}_{engine}"
if name in all_sfx_headers:
per_engine[str(number)] = all_sfx_headers[name]
noise_headers[str(engine)] = per_engine
map_song_consts = []
path = os.path.join(pokered, "data/maps/songs.asm")
for _, line in util.read_asm(path):
match = re.match(r"db\s+(MUSIC_\w+),", line.strip())
if match:
map_song_consts.append(match.group(1))
map_songs = {}
for map_name, const_name in zip(map_order, map_song_consts):
label = _music_label(const_name, music_headers)
if label in music_headers:
map_songs[map_name] = label
cry_data = symbols["CryData"]
wave_banks = {}
for engine in (1, 2, 3):
wave = symbols[f"Audio{engine}_WavePointers.wave0"]
wave_banks[str(engine)] = {
"bank": wave.bank,
"address": wave.address,
}
return {
"musicHeaders": music_headers,
"sfxHeaders": sfx_headers,
"cryHeaders": cry_headers,
"noiseHeaders": noise_headers,
"cryData": {
"bank": cry_data.bank,
"address": cry_data.address,
},
"waveBanks": wave_banks,
"mapSongs": map_songs,
"battle": {
"wild": "Music_WildBattle",
"trainer": "Music_TrainerBattle",
"gym": "Music_GymLeaderBattle",
"final": "Music_FinalBattle",
"wildWin": "Music_DefeatedWildMon",
"trainerWin": "Music_DefeatedTrainer",
"gymWin": "Music_DefeatedGymLeader",
},
}
DIRECT_SYMBOLS = {
"AttackAnimationPointers",
"BadgeNumbersTileGraphics",
"BaseStats",
"BugIconFrame1",
"BugIconFrame2",
"CircleTile",
"CryData",
"DoorTileIDPointers",
"EvosMovesPointerTable",
"FlowerTile1",
"FlowerTile2",
"FlowerTile3",
"FontGraphics",
"FrameBlockBaseCoords",
"FrameBlockPointers",
"FossilAerodactylPic",
"FossilKabutopsPic",
"GhostPic",
"GymLeaderFaceAndBadgeTileGraphics",
"ItemNames",
"ItemPrices",
"KeyItemFlags",
"MewBaseStats",
"MonPartyData",
"MonsterNames",
"MonsterPalettes",
"MoveNames",
"MoveAnimationTiles0",
"MoveAnimationTiles1",
"MoveAnimationTiles2",
"MoveAnimationTilesPointers",
"MoveSoundTable",
"Moves",
"NothingWildMons",
"OldManPicBack",
"PlantIconFrame1",
"PlantIconFrame2",
"PokeballTileGraphics",
"PokedexEntryPointers",
"PokedexTileGraphics",
"QuadrupedIconFrame1",
"QuadrupedIconFrame2",
"RedBikeSprite",
"RedPicBack",
"RedPicFront",
"SnakeIconFrame1",
"SnakeIconFrame2",
"SpinnerArrowAnimTiles",
"SpriteSheetPointerTable",
"SubanimationPointers",
"SuperPalettes",
"TechnicalMachinePrices",
"TextBoxGraphics",
"Tilesets",
"TrainerAI",
"TrainerClassMoveChoiceModifications",
"TrainerDataPointers",
"TrainerInfoTextBoxTileGraphics",
"TrainerNames",
"TrainerPicAndMoneyPointers",
"TypeEffects",
"WarpTileIDPointers",
"WildDataPointers",
}
FIELD_ASSET_SYMBOLS = {
"BattleHudTiles1",
"BattleHudTiles2",
"BattleHudTiles3",
"BattleTransitionTile",
"FallingStar",
"FightIntroBackMon",
"FightIntroFrontMon",
"FightIntroFrontMon2",
"FightIntroFrontMon3",
"GameBoyTiles",
"GameFreakIntro",
"GameFreakLogoGraphics",
"GengarIntroTiles1",
"GengarIntroTiles2",
"GengarIntroTiles3",
"HappyEmote",
"HpBarAndStatusGraphics",
"LedgeHoppingShadow",
"LinkCableTiles",
"MoveAnimationTiles1",
"NintendoCopyrightLogoGraphics",
"PlayerCharacterTitleGraphics",
"PokeCenterFlashingMonitorAndHealBall",
"PokemonLogoGraphics",
"QuestionEmote",
"RedFishingRodTiles",
"RedFishingTilesBack",
"RedFishingTilesFront",
"RedFishingTilesSide",
"SSAnneSmokePuffTile",
"ShockEmote",
"ShrinkPic1",
"ShrinkPic2",
"SlotMachineTiles1",
"SlotMachineTiles2",
"TheEndGfx",
"TownMapCursor",
"TradeBubbleIconGFX",
"TradingAnimationGraphics",
"TradingAnimationGraphics2",
"Version_GFX",
"WorldMapTileGraphics",
}
def embedded_symbols(data, symbols):
"""Return only addresses consumed by the ROM-backed extractor."""
names = set(DIRECT_SYMBOLS | FIELD_ASSET_SYMBOLS)
names.update(
spec["label"] + "_h" for spec in data["maps"].values())
names.update(data["typeNameLabels"])
names.update(data["text"]["labels"])
for spec in data["pokemonAssets"].values():
for key in ("frontLabel", "backLabel"):
if spec.get(key):
names.add(spec[key])
for spec in data["trainerPics"]:
if spec:
names.add(spec["label"])
missing = sorted(name for name in names if name not in symbols.by_name)
if missing:
raise ValueError(
"required symbols are missing: " + ", ".join(missing))
return {
name: [symbols[name].bank, symbols[name].address]
for name in sorted(names)
}
# Trainer parties this project reimplements that have no data to extract:
# CeladonChiefHouse's CHIEF is a talk-only NPC in the ROM (the Celadon Chief
# battle is unused/cut content -- pokered's data/trainers/parties.asm has
# `ChiefData:` followed by `; none`, zero Pokemon). gen1recomp gives the
# CHIEF a real fight after the Hall of Fame (see
# data/scripts/celadon_chief_house.lua and src/import/RomExtractor.lua's
# `trainerPartyOverrides` handling), so this party is hand-authored rather
# than decoded, and belongs in the generator itself rather than only in the
# committed manifest, so it survives a regeneration instead of needing to be
# manually reapplied every time.
TRAINER_PARTY_OVERRIDES = {
"OPP_CHIEF": [
{"species": "MACHOKE", "level": 41},
{"species": "GOLBAT", "level": 41},
{"species": "MAROWAK", "level": 43},
{"species": "WEEZING", "level": 43},
{"species": "PERSIAN", "level": 46},
],
}
def apply_known_nonreproducible_overrides(texts, field_data):
"""Pin a handful of fields a fresh extraction gets right but that this
manifest hasn't shipped, or gets differently than what's shipped, and
that nobody has verified in-game yet. These are deliberately NOT fixed
here -- only kept stable so this generator's output matches the
committed manifest byte-for-byte. Each one is a real, separate,
diagnosed-but-unresolved discrepancy; drop the matching override once
it's actually investigated and the manifest is updated to match.
"""
# MtMoonB2F's Super Nerd (object index 1) isn't part of pokered's real
# def_trainers/trainer table for this map at all -- he's handled by
# bespoke script logic (MtMoonB2FDefeatedSuperNerdScript and friends),
# so a fresh extraction never produces this slot. This manifest has
# always shipped one anyway, under an event name
# (EVENT_BEAT_MT_MOON_3_SUPER_NERD) that has never existed in pokered
# at any point in its history (the real flag is
# EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD, gen1recomp's own
# src/save_convert/data/event_flags.lua flag 1401) -- fabricated data,
# not pokered drift. OverworldState:trainerDefeated() checks
# Game.save.defeatedTrainers[npc.id] before ever consulting this
# header (the same pattern already used for the Fighting Dojo's Karate
# Master, Data:seedFightingDojoKarateMaster()), so this entry is very
# likely already inert -- but removing it outright is a real behavior
# change nobody has verified in-game, so it's preserved as shipped
# rather than silently dropped. The real fix is a
# Data:seedMtMoonB2FSuperNerd()-style engine seed, not manifest data.
texts["trainerHeaders"].setdefault("MtMoonB2F", {})[1] = {
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
"won": "_MtMoonB2FSuperNerdOkIllShareText",
}
# Seafoam Islands B3F's cross-floor boulder-toggle IDs (which B3F
# object shows/hides when a boulder falls in from B2F above) come out
# of a fresh extraction as TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/_4;
# this manifest has always shipped _5/_6 instead. Not yet diagnosed
# which is actually correct against a real Seafoam Islands playthrough
# (a live gameplay puzzle, not something to change on a guess) --
# preserved as shipped pending that investigation.
plugged = (
field_data["seafoam"]["SEAFOAM_ISLANDS_B3F"]["pluggedByHolesOn"])
plugged["holes"][0]["showObject"] = "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5"
plugged["holes"][1]["showObject"] = "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6"
# tradeArt is new content the current extractor can already produce
# (trade-animation sprite sheets) but that this manifest has never
# shipped -- no engine feature consumes it yet. Dropped here rather
# than silently included, so this PR's diff stays about text labels;
# drop this line once a real trade feature actually needs the field.
field_data.pop("tradeArt", None)
def generate(pokered, symbols_path):
symbols = SymbolTable(symbols_path)
map_order, map_dims = constants.extract_map_constants(pokered)
tilesets = [
n for n in simple_constants(
pokered, "constants/tileset_constants.asm") if n
]
sprites = simple_constants(pokered, "constants/sprite_constants.asm")
species = simple_constants(pokered, "constants/pokemon_constants.asm")
moves = simple_constants(
pokered, "constants/move_constants.asm", stop_at="NUM_ATTACKS")
types = constants.extract_types(pokered)
# Named elevator floors follow the 83 inventory items in the same
# ItemNames/ItemPrices tables. Machine IDs live later at $C4+ and are
# represented separately below.
item_order = simple_constants(
pokered, "constants/item_constants.asm")[1:]
# parse_const_block sees the `const HM_\1` macro body as a literal
# pseudo-entry; everything before it is the real contiguous name table.
item_order = item_order[:item_order.index("HM_")]
hms, tms = items.parse_machines(pokered)
effects = simple_constants(
pokered, "constants/move_effect_constants.asm")
trainer_order = [
name for name in trainers.parse_trainer_consts(pokered)
if name and name != "NOBODY"
]
dex_order = [
name.removeprefix("DEX_")
for name in simple_constants(
pokered, "constants/pokedex_constants.asm")
if name
]
pokemon_assets, growth_rates = pokemon_metadata(pokered, dex_order)
palette_order = [
name.removeprefix("PAL_")
for name in simple_constants(
pokered, "constants/palette_constants.asm")
if name and name.startswith("PAL_")
]
icon_order = [
name.removeprefix("ICON_")
for name in simple_constants(
pokered, "constants/icon_constants.asm")
if name and name.startswith("ICON_")
][:10]
tile_animations = [
name or "UNUSED"
for name in simple_constants(
pokered, "constants/map_data_constants.asm")
if name and name.startswith("TILEANIM_")
]
constants_data = {
"source": "ROM metadata manifest",
"mapOrder": map_order,
"maps": map_dims,
"tilesetOrder": tilesets,
"spriteOrder": [n or "UNUSED" for n in sprites[1:]],
"speciesOrder": [n or "UNUSED" for n in species[1:]],
"moveOrder": [n or "UNUSED" for n in moves[1:]],
"types": types,
}
type_name_labels = [
symbol.name for symbol in symbols.prefixed("TypeNames.")
]
dex_entries = pokemon.parse_dex_entries(
pokered, constants_data["speciesOrder"])
texts = text_metadata(pokered)
field_data = field_metadata(pokered)
apply_known_nonreproducible_overrides(texts, field_data)
audio_data = audio_metadata(pokered, symbols, map_order)
data = {
"format": 2,
"romSha1": CANONICAL_RED_SHA1,
"constants": constants_data,
"charmap": charmap(pokered),
"moveEffects": [name or "UNUSED" for name in effects],
"items": item_order,
"numItems": 83,
"hms": hms,
"tms": tms,
"trainers": trainer_order,
"trainerPics": trainer_pic_metadata(pokered),
"dexOrder": dex_order,
"pokemonAssets": pokemon_assets,
"growthRates": growth_rates,
"tmhmMoves": tms + hms,
"paletteOrder": palette_order,
"iconOrder": icon_order,
"maps": map_metadata(pokered, map_dims),
"tilesets": tileset_metadata(pokered, tilesets),
"tileAnimations": tile_animations,
"sprites": sprite_metadata(
pokered, [name or "UNUSED" for name in sprites[1:]]),
"fontCharmap": font.parse_charmap(pokered),
"sfxKeys": sfx_keys(pokered, symbols),
"typeNameLabels": type_name_labels,
"dexEntryLabels": {
species: entry.get("text")
for species, entry in dex_entries.items()
if entry.get("text")
},
"text": texts,
"field": field_data,
"audio": audio_data,
"battleAnimations": battle_animation_metadata(pokered),
"trainerPartyOverrides": TRAINER_PARTY_OVERRIDES,
}
data["symbols"] = embedded_symbols(data, symbols)
return data
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pokered", required=True)
parser.add_argument("--symbols", required=True)
parser.add_argument(
"--out",
default=os.path.join(os.path.dirname(__file__), "rom_manifest.json"))
parser.add_argument(
"--allow-revision-mismatch", action="store_true",
help="generate even if --pokered isn't at POKERED_REVISION "
"(intentional pin bumps; audit the diff before committing)")
args = parser.parse_args()
pokered = os.path.abspath(args.pokered)
if not os.path.isfile(os.path.join(pokered, "main.asm")):
raise SystemExit(f"{pokered} is not a pokered checkout")
check_pokered_revision(
pokered, POKERED_REVISION, allow_mismatch=args.allow_revision_mismatch)
data = generate(pokered, os.path.abspath(args.symbols))
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, ensure_ascii=True, indent=2, sort_keys=True)
f.write("\n")
print(f"wrote {args.out}")
if __name__ == "__main__":
main()