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

557 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""Derive the Pokemon Yellow import manifest from the shipped Red manifest.
Yellow is a separate pret tree (pokeyellow), not a `_YELLOW` flip of pokered.
Most of the ~3268 Red manifest symbols still exist under the same names in
pokeyellow.sym (~3123 with shifted addresses). The remainder need aliases,
synthetic addresses (Mew in BaseStats), or omission (FightIntro* -- Yellow's
intro movie is different; RomExtractor must skip those).
Map/object/sprite/tileset/text metadata diverge enough that those sections are
rebuilt from pokeyellow source (same helpers as make_rom_manifest.py), while
ROM-address tables and other Red-shaped sections keep the derive-and-remap
path.
Usage mirrors make_blue_manifest.py: deep-copy Red, remap symbols, override
version-gated field bits, write tools/rom_manifest_yellow.json.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import constants, field, util # noqa: E402
from make_rom_manifest import ( # noqa: E402
_music_label,
check_pokered_revision,
map_metadata,
simple_constants,
sprite_metadata,
text_metadata,
tileset_metadata,
)
import re # noqa: E402
# See make_rom_manifest.py's POKERED_REVISION comment for why this pin
# matters -- pokeyellow can drift the same way pokered did for the Silph Co
# 10F/11F rename. Verified against this commit: symbols/text/trainerHeaders/
# trainerPartyOverrides all come out byte-for-byte identical to the
# committed rom_manifest_yellow.json (field.oldManBattle does not -- a
# separate, pre-existing, undiagnosed discrepancy unrelated to text labels,
# left alone the same way field.seafoam/tradeArt were for Red/Blue).
POKEYELLOW_REVISION = "e6ba56989b0f2694f393e6924820be11dcc1fbb8"
from rom_data import SymbolTable # noqa: E402
from yellow_symbol_aliases import ( # noqa: E402
FAN_CLUB_ID_RENAMES,
GAME_CORNER_ID_RENAMES,
MAP_CONST_RENAMES,
MAP_LABEL_RENAMES,
OMIT_INTRO_SYMBOLS,
SYMBOL_ALIASES,
)
try:
from rom_data import CANONICAL_YELLOW_SHA1
except ImportError: # pragma: no cover -- constant lands with GameVersion work
CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1"
DEV = "/Users/bryanbassett/Documents/development"
DEFAULT_RED = os.path.join(os.path.dirname(__file__), "rom_manifest.json")
DEFAULT_OUT = os.path.join(os.path.dirname(__file__), "rom_manifest_yellow.json")
DEFAULT_POKEYELLOW = os.path.join(DEV, "pokeyellow")
DEFAULT_SYMBOLS = os.path.join(DEV, "pokeyellow-symbols/pokeyellow.sym")
BASE_STATS_ENTRY_SIZE = 28
MEW_DEX_NUMBER = 151
# Yellow-only symbols Red never referenced. Title / intro / CGB tables must
# be injected so RomExtractor can rip them (make_yellow_manifest only remaps
# Red's name set by default).
YELLOW_EXTRA_SYMBOLS = (
"TitlePikachuBGGraphics",
"TitlePikachuOBGraphics",
"TitleScreenPikachuTilemap",
"TitleScreenPikaBubbleTilemap",
"TitleScreenPokemonLogoTilemap",
"PokemonLogoCornerGraphics",
"YellowIntroGraphics1",
"YellowIntroGraphics2",
"YellowIntroCloudGFX",
"PikachuCriesPointerTable",
"CGBBasePalettes",
# Jessie & James share the ROCKET trainer class but battle behind their
# own pic (home/trainers2.asm IsFightingJessieJames) (#439)
"JessieJamesPic",
# the five Pikachu-only emotion bubbles (emotion_bubbles.asm)
"SkullEmote",
"HeartEmote",
"BoltEmote",
"ZzzEmote",
"FishEmote",
# Surfing Pikachu minigame sheets (gfx/surfing_pikachu.asm)
"SurfingPikachu1Graphics1",
"SurfingPikachu1Graphics2",
"SurfingPikachu1Graphics3",
# Oak's own battle back pic. LoadPlayerBackPic (engine/battle/core.asm)
# picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
# BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557).
"ProfOakPicBack",
# Base frames for the framed portrait TalkToPikachu draws, one per
# PikaPicAnimScript (data/pikachu/pikachu_pic_animation.asm). These are
# raw address labels because the pikapic blobs carry no named symbols;
# RomExtractor's PIKAPIC_BASE table indexes them positionally, so the
# order here is not load bearing but every entry must resolve (#561).
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_f0abf", "Pic_f0cf4",
# Yellow-only overworld player surf sprite, loaded outside
# SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) --
# needs its own extract like RedBikeSprite. (RFC 0001)
"SurfingPikachuSprite",
)
# Yellow-only dialogue whose bank labels carry no leading underscore, so
# the text-label scan misses them (scripts/CeruleanMelaniesHouse.asm --
# the Bulbasaur gift and the pet flavor lines).
YELLOW_EXTRA_TEXT_LABELS = (
"MelanieText1", "MelanieText2", "MelanieText3",
"MelanieText4", "MelanieText5",
"MelanieBulbasaurText", "MelanieOddishText", "MelanieSandshrewText",
)
YELLOW_EXTRA_PALETTES = (
"PIKACHUS_BEACH",
"PIKACHU_PORTRAIT",
"PIKACHUS_BEACH_TITLE",
)
def _rename_keys(obj, renames):
"""Recursively rename dict keys (and rewrite matching string values)."""
if isinstance(obj, dict):
out = {}
for key, value in obj.items():
new_key = renames.get(key, key)
out[new_key] = _rename_keys(value, renames)
return out
if isinstance(obj, list):
return [_rename_keys(item, renames) for item in obj]
if isinstance(obj, str):
return renames.get(obj, obj)
return obj
def _replace_strings(obj, renames):
"""Recursively replace string values (and list entries) via renames."""
if isinstance(obj, dict):
return {k: _replace_strings(v, renames) for k, v in obj.items()}
if isinstance(obj, list):
return [_replace_strings(item, renames) for item in obj]
if isinstance(obj, str):
return renames.get(obj, obj)
return obj
def _drop_strings(obj, dropped):
"""Remove dropped names from lists and as dict keys / string values."""
if isinstance(obj, dict):
out = {}
for key, value in obj.items():
if key in dropped:
continue
if isinstance(value, str) and value in dropped:
continue
out[key] = _drop_strings(value, dropped)
return out
if isinstance(obj, list):
result = []
for item in obj:
if isinstance(item, str) and item in dropped:
continue
result.append(_drop_strings(item, dropped))
return result
return obj
def _resolve_mew_base_stats(yellow_symbols):
base = yellow_symbols.by_name.get("BaseStats")
if base is None:
raise SystemExit("pokeyellow.sym missing BaseStats (needed for Mew)")
return [base.bank, base.address + (MEW_DEX_NUMBER - 1) * BASE_STATS_ENTRY_SIZE]
def _rebuild_map_songs(pokeyellow, map_order, music_headers):
"""Zip pokeyellow data/maps/songs.asm onto the Yellow mapOrder."""
map_song_consts = []
path = os.path.join(pokeyellow, "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))
if len(map_song_consts) != len(map_order):
raise SystemExit(
f"Yellow songs.asm has {len(map_song_consts)} entries but "
f"mapOrder has {len(map_order)}")
out = {}
missing = []
for map_name, const_name in zip(map_order, map_song_consts):
label = _music_label(const_name, music_headers)
if label not in music_headers:
missing.append(f"{map_name}:{const_name}->{label}")
continue
out[map_name] = label
if missing:
raise SystemExit(
"Yellow map songs could not resolve music headers: "
+ ", ".join(missing[:12])
+ (" ..." if len(missing) > 12 else ""))
return out
def _rebuild_yellow_sourced(yellow, pokeyellow):
"""Replace sections that Yellow authors differently from Red."""
map_order, map_dims = constants.extract_map_constants(pokeyellow)
tileset_order = [
n for n in simple_constants(
pokeyellow, "constants/tileset_constants.asm") if n
]
sprite_consts = simple_constants(
pokeyellow, "constants/sprite_constants.asm")
sprite_order = [n or "UNUSED" for n in sprite_consts[1:]]
tile_animations = [
name or "UNUSED"
for name in simple_constants(
pokeyellow, "constants/map_data_constants.asm")
if name and name.startswith("TILEANIM_")
]
yellow["constants"]["mapOrder"] = map_order
yellow["constants"]["maps"] = map_dims
yellow["constants"]["tilesetOrder"] = tileset_order
yellow["constants"]["spriteOrder"] = sprite_order
yellow["maps"] = map_metadata(pokeyellow, map_dims)
yellow["tilesets"] = tileset_metadata(pokeyellow, tileset_order)
yellow["sprites"] = sprite_metadata(pokeyellow, sprite_order)
yellow["text"] = text_metadata(pokeyellow)
yellow["tileAnimations"] = tile_animations
yellow["audio"]["mapSongs"] = _rebuild_map_songs(
pokeyellow, map_order, yellow["audio"]["musicHeaders"])
# Red's positional header mapping lands Yellow's intro song
# (pokeyellow Music_YellowIntro, 1f:4294) under the name
# Music_IntroBattle; expose it under its own name too so the Yellow
# intro movie state can ask for the right label.
headers = yellow["audio"]["musicHeaders"]
if "Music_YellowIntro" not in headers and "Music_IntroBattle" in headers:
headers["Music_YellowIntro"] = dict(headers["Music_IntroBattle"])
return {
"mapCount": len(map_order),
"mapMeta": len(yellow["maps"]),
"tilesets": len(tileset_order),
"sprites": len(sprite_order),
"textLabels": len(yellow["text"]["labels"]),
}
def _remap_audio(yellow, yellow_symbols):
"""Re-anchor the Red-copied audio section on pokeyellow.sym (#522).
Yellow's music bank $1f shifts after 1f:4294: Music_YellowIntro is a
3-channel header (pokeyellow audio/headers/musicheaders3.asm) where
Red's Music_IntroBattle had 4, so Red's addresses for every later
header land on a channel-2 row (one voice, and no tempo command --
Viridian Forest slow and hollow). Yellow also has a single
wave-sample table at Audio1_WavePointers (audio.asm "Music 1"), not
Red's per-engine 0x4373 copies, and CryData moved to 0e:5462. Names
absent from the sym (Music_IntroBattle, which Yellow only knows as
Music_YellowIntro) keep their positional mapping.
"""
for name, header in yellow["audio"]["musicHeaders"].items():
symbol = yellow_symbols.by_name.get(name)
if symbol is not None:
header["bank"] = symbol.bank
header["address"] = symbol.address
wave = yellow_symbols.by_name.get("Audio1_WavePointers.wave0")
if wave is None:
raise SystemExit("pokeyellow.sym missing Audio1_WavePointers.wave0")
for engine in yellow["audio"]["waveBanks"]:
yellow["audio"]["waveBanks"][engine] = {
"bank": wave.bank, "address": wave.address,
}
cry = yellow_symbols.by_name.get("CryData")
if cry is None:
raise SystemExit("pokeyellow.sym missing CryData")
yellow["audio"]["cryData"] = {"bank": cry.bank, "address": cry.address}
def derive(red, pokeyellow, symbols_path):
"""Return the Yellow manifest derived from the Red manifest dict."""
yellow = copy.deepcopy(red)
yellow["romSha1"] = CANONICAL_YELLOW_SHA1
yellow_symbols = SymbolTable(symbols_path)
omit = set(OMIT_INTRO_SYMBOLS)
dropped = {name for name, alias in SYMBOL_ALIASES.items() if alias is None}
dropped |= omit
symbol_renames = {
name: alias for name, alias in SYMBOL_ALIASES.items() if alias
}
# Structural renames for Red-shaped leftovers (field townMap, etc.) before
# Yellow-sourced sections overwrite maps/text/sprites/tilesets.
structural = {}
structural.update(MAP_CONST_RENAMES)
structural.update(MAP_LABEL_RENAMES)
structural.update(GAME_CORNER_ID_RENAMES)
structural.update(FAN_CLUB_ID_RENAMES)
structural.update(symbol_renames)
yellow = _rename_keys(yellow, structural)
yellow = _replace_strings(yellow, structural)
yellow = _drop_strings(yellow, dropped)
rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow)
_remap_audio(yellow, yellow_symbols) # #522
for label in YELLOW_EXTRA_TEXT_LABELS:
if label not in yellow["text"]["labels"]:
yellow["text"]["labels"].append(label)
# Rebuild symbols from Red's name set with Yellow addresses / aliases,
# then ensure every Yellow-sourced label/header is present.
resolved = {}
missing = []
alias_hits = 0
for name in red["symbols"]:
if name in omit or name in dropped:
continue
if name == "MewBaseStats":
resolved["MewBaseStats"] = _resolve_mew_base_stats(yellow_symbols)
alias_hits += 1
continue
target = symbol_renames.get(name, name)
if name in symbol_renames:
alias_hits += 1
# Structural map-header rename may already have changed the key.
target = MAP_LABEL_RENAMES.get(target, target)
if target.endswith("_h"):
base = target[:-2]
target = MAP_LABEL_RENAMES.get(base, base) + "_h" \
if base in MAP_LABEL_RENAMES else target
symbol = yellow_symbols.by_name.get(target)
if symbol is None:
# Drop Red-only symbols that Yellow-sourced sections no longer need.
continue
resolved[target] = [symbol.bank, symbol.address]
print(
"warning: omitting Yellow-incompatible intro symbols "
f"(RomExtractor must skip): {', '.join(OMIT_INTRO_SYMBOLS)}"
)
for label in yellow["text"]["labels"]:
if label in resolved:
continue
symbol = yellow_symbols.by_name.get(label)
if symbol is None:
missing.append(label)
continue
resolved[label] = [symbol.bank, symbol.address]
for spec in yellow["maps"].values():
header = spec["label"] + "_h"
if header in resolved:
continue
symbol = yellow_symbols.by_name.get(header)
if symbol is None:
missing.append(header)
continue
resolved[header] = [symbol.bank, symbol.address]
# Pointer asm labels (ViridianPokeCenterChanseyText etc.) also need symbols
# when extract_text resolves through them.
for pointers in yellow["text"]["pointers"].values():
for spec in pointers.values():
for key in ("label", "text"):
name = spec.get(key)
if not name or name in resolved:
continue
symbol = yellow_symbols.by_name.get(name)
if symbol is not None:
resolved[name] = [symbol.bank, symbol.address]
yellow["symbols"] = resolved
for name in YELLOW_EXTRA_SYMBOLS:
symbol = yellow_symbols.by_name.get(name)
if symbol is None:
raise SystemExit(f"pokeyellow.sym missing Yellow extra symbol {name}")
yellow["symbols"][name] = [symbol.bank, symbol.address]
# Yellow-only songs live in music bank $20, which Red's engine never
# had; ship the bank in the audio pack and add their headers.
yellow["audio"]["programBanks"] = [2, 8, 31, 32]
for name in ("Music_MeetJessieJames", "Music_SurfingPikachu",
"Music_GBPrinter"):
symbol = yellow_symbols.by_name.get(name)
if symbol is None:
raise SystemExit(f"pokeyellow.sym missing Yellow song {name}")
yellow["audio"]["musicHeaders"][name] = {
"bank": symbol.bank, "address": symbol.address, "engine": 3,
}
# SuperPalettes grows by three Yellow-only SGB entries after GAMEFREAK.
order = list(yellow.get("paletteOrder") or [])
for name in YELLOW_EXTRA_PALETTES:
if name not in order:
order.append(name)
yellow["paletteOrder"] = order
# Yellow appends ICON_PIKACHU ($a) after Red's ten party icons
# (constants/icon_constants.asm); MonPartyData nybble $a resolves to
# it, drawn from the overworld PikachuSprite sheet.
icons = list(yellow.get("iconOrder") or [])
if "PIKACHU" not in icons:
icons.append("PIKACHU")
yellow["iconOrder"] = icons
still_missing = []
for name in yellow["symbols"]:
if name == "MewBaseStats":
continue
if name not in yellow_symbols.by_name:
still_missing.append(name)
if still_missing or missing:
raise SystemExit(
"pokeyellow.sym is missing symbols the manifest needs: "
+ ", ".join(sorted(set(still_missing + missing))[:20])
+ (" ..." if len(set(still_missing + missing)) > 20 else ""))
# Version-gated field bits from pokeyellow.
saved = util.ASM_DEFINES
util.ASM_DEFINES = set()
try:
yellow["field"]["presetNames"] = field.parse_preset_names(pokeyellow)
try:
yellow["field"]["credits"] = field.parse_credits(pokeyellow)
except SystemExit as exc:
print(f"warning: parse_credits failed ({exc}); keeping Red credits")
# TODO: hand-author a Yellow credits banner if pret layout drifts.
yellow["field"]["trades"] = field.parse_trades(pokeyellow)
finally:
util.ASM_DEFINES = saved
presets = yellow["field"]["presetNames"]
if "YELLOW" not in presets["player"] or "BLUE" not in presets["rival"]:
raise SystemExit(
f"Yellow preset-name parse unexpected: {presets!r}")
# Fixed Pikachu title (no TitleMons cycle); extractor fills image paths.
title = yellow["field"].setdefault("title", {})
title["layout"] = "yellow_pikachu"
title["cycleSpecies"] = []
title["music"] = title.get("music") or "Music_TitleScreen"
title["pikachuBg"] = {
"path": "assets/generated/title/pikachu_bg.png",
"width": 128, "height": 32,
}
title["pikachuOb"] = {
"path": "assets/generated/title/pikachu_ob.png",
"width": 96, "height": 8,
}
title["pikachu"] = {
"path": "assets/generated/title/pikachu.png",
"width": 96, "height": 72,
}
title["pikaBubble"] = {
"path": "assets/generated/title/pika_bubble.png",
"width": 56, "height": 32,
}
# Yellow's emote sheet grows the five Pikachu-only bubbles
# (engine/overworld/emotion_bubbles.asm Skull/Heart/Bolt/Zzz/FishEmote,
# constants/script_constants.asm order); RomExtractor rips whatever
# this bubble list names.
yellow_bubbles = ["EXCLAMATION_BUBBLE", "QUESTION_BUBBLE", "SMILE_BUBBLE",
"SKULL_BUBBLE", "HEART_BUBBLE", "BOLT_BUBBLE",
"ZZZ_BUBBLE", "FISH_BUBBLE"]
yellow["field"]["emotionBubbles"] = {
"path": "assets/generated/emotes.png",
"width": 16 * len(yellow_bubbles), "height": 16,
"bubbles": [{"name": name, "x": i * 16, "y": 0, "w": 16, "h": 16}
for i, name in enumerate(yellow_bubbles)],
}
# The Oak-speech show-off mon is the player's Pikachu in Yellow
# (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo);
# the deep-copied Red field.oakSpeech has no demoSpecies, so stamp
# it or the import falls back to NIDORINO (#915).
yellow["field"]["oakSpeech"]["demoSpecies"] = "PIKACHU"
# Ensure Melanie / Summer Beach town-map entries exist after rebuild.
locations = yellow["field"]["townMap"]["locations"]
if "CERULEAN_MELANIES_HOUSE" not in locations \
and "CERULEAN_CITY" in locations:
locations["CERULEAN_MELANIES_HOUSE"] = dict(locations["CERULEAN_CITY"])
if "SUMMER_BEACH_HOUSE" not in locations and "ROUTE_19" in locations:
locations["SUMMER_BEACH_HOUSE"] = dict(locations["ROUTE_19"])
meta = {
"aliasCount": alias_hits,
"omittedIntro": list(OMIT_INTRO_SYMBOLS),
"droppedSymbols": sorted(dropped - omit),
"rebuilt": rebuilt,
}
return yellow, meta
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--red", default=DEFAULT_RED,
help="shipped Red manifest to derive from")
parser.add_argument("--pokeyellow", default=DEFAULT_POKEYELLOW,
help="pokeyellow source checkout")
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS,
help="pokeyellow.sym symbol file")
parser.add_argument("--out", default=DEFAULT_OUT)
parser.add_argument(
"--allow-revision-mismatch", action="store_true",
help="generate even if --pokeyellow isn't at POKEYELLOW_REVISION")
args = parser.parse_args()
pokeyellow = os.path.abspath(args.pokeyellow)
if not os.path.isfile(os.path.join(pokeyellow, "main.asm")):
raise SystemExit(f"{pokeyellow} is not a pokeyellow checkout")
if POKEYELLOW_REVISION is not None:
check_pokered_revision(
pokeyellow, POKEYELLOW_REVISION,
allow_mismatch=args.allow_revision_mismatch)
with open(args.red, encoding="utf-8") as f:
red = json.load(f)
yellow, meta = derive(red, pokeyellow, os.path.abspath(args.symbols))
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(yellow, f, ensure_ascii=True, indent=2, sort_keys=True)
f.write("\n")
print(f"wrote {args.out}")
print(f"symbols: {len(yellow['symbols'])}")
print(f"aliases applied: {meta['aliasCount']}")
print(f"omitted intro: {meta['omittedIntro']}")
print(f"dropped: {len(meta['droppedSymbols'])} "
f"({', '.join(meta['droppedSymbols'][:8])}...)")
print(f"rebuilt: {meta['rebuilt']}")
if __name__ == "__main__":
main()