Files
gen1recomp/tools/build_rom_data.py
T
bryanthaboi ca4d3d283c Add Pokemon Crystal as a sixth supported version
Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.

Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
  make_gold_manifest as a library, with three additive keyword seams. Gold and
  Silver still regenerate byte-identical, which is the standing requirement for
  touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
  Crystal renames the credits mons, splits the trainer card, Pokegear and
  pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
  badge block sits one higher) and unownCharmap (the main charmap parser stops
  at the first newcharmap so the two cannot contaminate each other).

Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
  0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
  seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
  steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
  Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
  intro and title via src/import/CrystalMovie.lua.

Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
  original bugs where the bug is not hardware dependent; Crystal gets the fixes
  Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
  Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
  of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
  including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
  and mon caught data.

Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
2026-08-23 12:10:28 -04:00

2250 lines
84 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build game data directly from a canonical Pokemon Red/Blue/Yellow ROM.
It accepts one user-provided, canonical US Gen-1 ROM. Symbol addresses and
assembly-erased names are bundled as non-ROM metadata, so no pret checkout,
RGBDS build, or external .sym file is required for the public ROM path.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import shutil
import sys
from collections import deque
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import util # noqa: E402
from rom_data import ( # noqa: E402
CANONICAL_BLUE_SHA1, CANONICAL_RED_SHA1, CANONICAL_YELLOW_SHA1,
RomImage, SymbolTable, bcd, decode_text, decompress_pic, load_manifest,
read_string,
)
DATASETS = (
"constants", "tilesets", "maps", "font", "sprites", "moves", "items",
"type_chart", "palettes", "icons", "pokemon", "trainers", "encounters",
"text", "field", "battle_anims",
)
# Sound is decoded by the Lua importer (src/import/RomExtractor.lua), not here,
# so --clean must step around it rather than delete what it cannot rebuild.
UNOWNED_DATA = ("audio.lua",)
UNOWNED_ASSETS = ("audio",)
def clean_generated(out_dir, assets_dir):
"""Empty the generated dirs, keeping artifacts this tool never writes."""
kept = []
for path, spared in ((out_dir, UNOWNED_DATA), (assets_dir, UNOWNED_ASSETS)):
if not os.path.isdir(path):
continue
for name in os.listdir(path):
target = os.path.join(path, name)
if name in spared:
kept.append(target)
elif os.path.isdir(target):
shutil.rmtree(target)
else:
os.remove(target)
return kept
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
VERSION_MANIFESTS = {
"red": os.path.join(_TOOLS_DIR, "rom_manifest.json"),
"blue": os.path.join(_TOOLS_DIR, "rom_manifest_blue.json"),
"yellow": os.path.join(_TOOLS_DIR, "rom_manifest_yellow.json"),
}
VERSION_SHA1 = {
"red": CANONICAL_RED_SHA1,
"blue": CANONICAL_BLUE_SHA1,
"yellow": CANONICAL_YELLOW_SHA1,
}
SHA1_TO_VERSION = {sha1: version for version, sha1 in VERSION_SHA1.items()}
GB_SHADES = (
(255, 255, 255, 255),
(170, 170, 170, 255),
(85, 85, 85, 255),
(0, 0, 0, 255),
)
def _symbol(symbols, name):
try:
return symbols[name]
except KeyError as exc:
raise ValueError(f"required symbol {name!r} is missing") from exc
def _has_symbol(symbols, name):
return name in symbols.by_name
def resolve_manifest_path(version, manifest_arg):
"""Pick the shipped manifest for --version, or an explicit --manifest path."""
if manifest_arg is not None:
return manifest_arg
path = VERSION_MANIFESTS[version]
if not os.path.isfile(path):
raise ValueError(
f"manifest for version {version!r} is missing: {path} "
f"(generate it before extracting)")
return path
def version_for_manifest(manifest, requested_version=None, manifest_explicit=False):
"""Resolve game version from an explicit flag or the manifest romSha1."""
rom_sha1 = manifest.get("romSha1")
detected = SHA1_TO_VERSION.get(rom_sha1)
if manifest_explicit:
# Explicit --manifest wins: hash/version come from the file itself.
return detected or requested_version or "red"
if requested_version and detected and requested_version != detected:
raise ValueError(
f"--version {requested_version} does not match manifest romSha1 "
f"{rom_sha1} ({detected})")
return detected or requested_version or "red"
def detect_rom_version(path):
"""Read a canonical ROM once and return its supported game version."""
rom = RomImage(path, None)
version = SHA1_TO_VERSION.get(rom.sha1)
if version is None:
expected = ", ".join(VERSION_SHA1[name] for name in VERSION_MANIFESTS)
raise ValueError(
f"unsupported ROM SHA-1 {rom.sha1}; expected one of {expected}")
return version, rom
def extract_constants(manifest, out_dir):
data = manifest["constants"]
util.write_lua(
os.path.join(out_dir, "constants.lua"), data,
header="Source: canonical Pokemon Red ROM metadata manifest")
return data
def _read_terminated(rom, bank, address, terminator, limit=256):
out = []
for offset in range(limit):
value = rom.byte(bank, address + offset)
if value == terminator:
return out
out.append(value)
raise ValueError(
f"unterminated byte list at {bank:02x}:{address:04x}")
def _decode_2bpp(raw, width, height, transparent_color0=False):
if width % 8 or height % 8:
raise ValueError(f"2bpp dimensions must be tile-aligned: {width}x{height}")
tile_count = width // 8 * (height // 8)
if len(raw) != tile_count * 16:
raise ValueError(
f"2bpp payload is {len(raw)} bytes, expected {tile_count * 16}")
image = Image.new("RGBA", (width, height))
pixels = image.load()
tiles_per_row = width // 8
for tile in range(tile_count):
tile_x = (tile % tiles_per_row) * 8
tile_y = (tile // tiles_per_row) * 8
for y in range(8):
low = raw[tile * 16 + y * 2]
high = raw[tile * 16 + y * 2 + 1]
for x in range(8):
bit = 7 - x
shade = ((high >> bit) & 1) * 2 + ((low >> bit) & 1)
color = GB_SHADES[shade]
if transparent_color0 and shade == 0:
color = (255, 255, 255, 0)
pixels[tile_x + x, tile_y + y] = color
return image
def _decode_1bpp(raw, width, height, transparent_color0=False):
if width % 8 or height % 8:
raise ValueError(f"1bpp dimensions must be tile-aligned: {width}x{height}")
tile_count = width // 8 * (height // 8)
if len(raw) != tile_count * 8:
raise ValueError(
f"1bpp payload is {len(raw)} bytes, expected {tile_count * 8}")
image = Image.new("RGBA", (width, height))
pixels = image.load()
tiles_per_row = width // 8
for tile in range(tile_count):
tile_x = (tile % tiles_per_row) * 8
tile_y = (tile // tiles_per_row) * 8
for y in range(8):
row = raw[tile * 8 + y]
for x in range(8):
filled = bool(row & (1 << (7 - x)))
if filled:
color = (0, 0, 0, 255)
elif transparent_color0:
color = (255, 255, 255, 0)
else:
color = (255, 255, 255, 255)
pixels[tile_x + x, tile_y + y] = color
return image
def _columns_to_rows(raw, tiles_wide, tiles_high, bytes_per_tile=16):
out = bytearray(len(raw))
for y in range(tiles_high):
for x in range(tiles_wide):
source = (x * tiles_high + y) * bytes_per_tile
target = (y * tiles_wide + x) * bytes_per_tile
out[target:target + bytes_per_tile] = \
raw[source:source + bytes_per_tile]
return bytes(out)
def _save_png(image, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
image.save(path, optimize=True)
def _matte_color0(image):
pixels = image.load()
width, height = image.size
queue = deque()
seen = set()
def add(x, y):
if (x, y) not in seen and pixels[x, y] == (255, 255, 255, 255):
seen.add((x, y))
queue.append((x, y))
for x in range(width):
add(x, 0)
add(x, height - 1)
for y in range(height):
add(0, y)
add(width - 1, y)
while queue:
x, y = queue.popleft()
pixels[x, y] = (255, 255, 255, 0)
for next_x, next_y in (
(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
if 0 <= next_x < width and 0 <= next_y < height:
add(next_x, next_y)
return image
def _write_2bpp_png(
raw, width, height, path, transparent_color0=False):
_save_png(
_decode_2bpp(raw, width, height, transparent_color0),
path)
def _write_compressed_pic(rom, symbols, label, path):
symbol = _symbol(symbols, label)
compressed = rom.bytes(
symbol.bank, symbol.address, 0x8000 - symbol.address)
raw, width = decompress_pic(compressed)
image = _matte_color0(
_decode_2bpp(raw, width * 8, width * 8))
_save_png(image, path)
return width
def extract_tilesets(rom, symbols, manifest, out_dir, assets_dir):
order = manifest["constants"]["tilesetOrder"]
metadata = manifest["tilesets"]
animations = manifest["tileAnimations"]
if len(metadata) != len(order):
raise ValueError("tileset metadata count does not match constants")
headers = _symbol(symbols, "Tilesets")
warp_pointers = _symbol(symbols, "WarpTileIDPointers")
door_pointers = _symbol(symbols, "DoorTileIDPointers")
doors = {}
address = door_pointers.address
while True:
tileset_id = rom.byte(door_pointers.bank, address)
if tileset_id == 0xFF:
break
pointer = rom.word(door_pointers.bank, address + 1)
doors[tileset_id] = _read_terminated(
rom, door_pointers.bank, pointer, 0)
address += 3
out = {}
written_images = set()
for index, (const_name, spec) in enumerate(zip(order, metadata)):
if spec["id"] != const_name:
raise ValueError(
f"tileset metadata {spec['id']} is out of order at {const_name}")
row_address = headers.address + index * 12
gfx_bank = rom.byte(headers.bank, row_address)
block_pointer = rom.word(headers.bank, row_address + 1)
gfx_pointer = rom.word(headers.bank, row_address + 3)
collision_pointer = rom.word(headers.bank, row_address + 5)
counters = list(rom.bytes(headers.bank, row_address + 7, 3))
grass = rom.byte(headers.bank, row_address + 10)
animation_id = rom.byte(headers.bank, row_address + 11)
if animation_id >= len(animations):
raise ValueError(
f"{const_name}: unknown tile animation {animation_id}")
blocks_raw = rom.bytes(
gfx_bank, block_pointer, spec["blockCount"] * 16)
blocks = [
list(blocks_raw[offset:offset + 16])
for offset in range(0, len(blocks_raw), 16)
]
# Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
# (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
# banked; treat ROM0-range pointers as bank 0.
coll_bank = 0 if collision_pointer < 0x4000 else 1
walkable = sorted(_read_terminated(
rom, coll_bank, collision_pointer, 0xFF))
warp_pointer = rom.word(
warp_pointers.bank, warp_pointers.address + index * 2)
warp_tiles = sorted(set(_read_terminated(
rom, warp_pointers.bank, warp_pointer, 0xFF)))
base = spec["imageBase"]
image_path = os.path.join(assets_dir, "tilesets", base + ".png")
if base not in written_images:
byte_length = spec["imageWidth"] * spec["imageHeight"] // 4
stored_length = block_pointer - gfx_pointer
if stored_length < 0 or stored_length > byte_length \
or stored_length % 16:
raise ValueError(
f"{const_name}: invalid stored tileset graphics length "
f"{stored_length}")
pixels = rom.bytes(gfx_bank, gfx_pointer, stored_length)
pixels += bytes(byte_length - stored_length)
_write_2bpp_png(
pixels,
spec["imageWidth"], spec["imageHeight"], image_path)
written_images.add(base)
out[const_name] = {
"id": const_name,
"source": f"ROM:Tilesets[{index}]",
"image": f"assets/generated/tilesets/{base}.png",
"imageWidth": spec["imageWidth"],
"imageHeight": spec["imageHeight"],
"tilesPerRow": spec["imageWidth"] // 8,
"blocks": blocks,
"walkable": walkable,
"counterTiles": [value for value in counters if value != 0xFF],
"grassTile": None if grass == 0xFF else grass,
"doorTiles": sorted(doors.get(index, [])),
"warpTiles": warp_tiles,
"animation": animations[animation_id],
}
for number in (1, 2, 3):
symbol = _symbol(symbols, f"FlowerTile{number}")
_write_2bpp_png(
rom.bytes(symbol.bank, symbol.address, 16), 8, 8,
os.path.join(assets_dir, "tilesets", f"flower{number}.png"))
spinner = _symbol(symbols, "SpinnerArrowAnimTiles")
_write_2bpp_png(
rom.bytes(spinner.bank, spinner.address, 64), 32, 8,
os.path.join(assets_dir, "tilesets", "spinners.png"))
util.write_lua(
os.path.join(out_dir, "tilesets.lua"), out,
header="Source: canonical Pokemon Red ROM (Tilesets, blocksets,\n"
"tile graphics, collision/warp/door lists)")
return out
def extract_font(rom, symbols, manifest, out_dir, assets_dir):
fonts_dir = os.path.join(assets_dir, "fonts")
main_symbol = _symbol(symbols, "FontGraphics")
main_raw = rom.bytes(main_symbol.bank, main_symbol.address, 128 * 8)
main = Image.new("RGBA", (128, 64), (0, 0, 0, 0))
pixels = main.load()
for tile in range(128):
tile_x = (tile % 16) * 8
tile_y = (tile // 16) * 8
for y in range(8):
row = main_raw[tile * 8 + y]
for x in range(8):
if row & (1 << (7 - x)):
pixels[tile_x + x, tile_y + y] = (0, 0, 0, 255)
_save_png(main, os.path.join(fonts_dir, "font.png"))
extra_symbol = _symbol(symbols, "TextBoxGraphics")
extra_raw = rom.bytes(extra_symbol.bank, extra_symbol.address, 32 * 16)
extra_shaded = _decode_2bpp(extra_raw, 128, 16)
extra = Image.new("RGBA", extra_shaded.size, (0, 0, 0, 0))
source_pixels = extra_shaded.load()
target_pixels = extra.load()
for y in range(extra.height):
for x in range(extra.width):
if source_pixels[x, y][0] < 128:
target_pixels[x, y] = (0, 0, 0, 255)
pokedex = _symbol(symbols, "PokedexTileGraphics")
dex_tiles = _decode_2bpp(
rom.bytes(pokedex.bank, pokedex.address, 32), 16, 8)
dex_pixels = dex_tiles.load()
for y in range(8):
for x in range(16):
extra.putpixel(
(x, y),
(0, 0, 0, 255) if dex_pixels[x, y][0] < 128
else (0, 0, 0, 0))
_save_png(extra, os.path.join(fonts_dir, "font_extra.png"))
data = {
"source": "ROM:FontGraphics, TextBoxGraphics, PokedexTileGraphics",
"image": "assets/generated/fonts/font.png",
"imageExtra": "assets/generated/fonts/font_extra.png",
"mainBase": 0x80,
"extraBase": 0x60,
"glyphsPerRow": 16,
"charmap": manifest["fontCharmap"],
}
util.write_lua(
os.path.join(out_dir, "font.lua"), data,
header="Source: canonical Pokemon Red ROM font graphics;\n"
"symbolic charmap comes from the metadata manifest")
return data
def extract_sprites(rom, symbols, manifest, out_dir, assets_dir):
order = manifest["constants"]["spriteOrder"]
metadata = manifest["sprites"]["order"]
table = _symbol(symbols, "SpriteSheetPointerTable")
if len(metadata) != len(order):
raise ValueError("sprite metadata count does not match constants")
out = {}
written = set()
for index, (const_name, spec) in enumerate(zip(order, metadata)):
if spec["id"] != const_name:
raise ValueError(
f"sprite metadata {spec['id']} is out of order at {const_name}")
address = table.address + index * 4
pointer = rom.word(table.bank, address)
first_half_length = rom.byte(table.bank, address + 2)
bank = rom.byte(table.bank, address + 3)
width = spec["imageWidth"]
height = spec["imageHeight"]
byte_length = width * height // 4
frames = height // 16
expected_length = first_half_length * (2 if frames >= 6 else 1)
if byte_length != expected_length:
# Commercial ROM sheet length wins over pret PNG atlases (Yellow's
# nurse.png is 16x64 but SpriteSheetPointerTable still stores 12
# tiles / 192 bytes).
byte_length = expected_length
if byte_length * 4 % width:
raise ValueError(
f"{const_name}: ROM sprite length {byte_length} is not "
f"tile-aligned for width {width}")
height = byte_length * 4 // width
frames = height // 16
expected_length = first_half_length * (2 if frames >= 6 else 1)
if byte_length != expected_length:
raise ValueError(
f"{const_name}: ROM sprite length {expected_length} does not "
f"match atlas length {width * spec['imageHeight'] // 4}")
base = spec["imageBase"]
if base not in written:
_write_2bpp_png(
rom.bytes(bank, pointer, byte_length),
width, height,
os.path.join(assets_dir, "sprites", base + ".png"),
transparent_color0=True)
written.add(base)
out[const_name] = {
"id": const_name,
"source": f"ROM:SpriteSheetPointerTable[{index}]",
"image": f"assets/generated/sprites/{base}.png",
"frames": frames,
"walker": frames >= 6,
}
bike = manifest["sprites"]["bike"]
bike_symbol = _symbol(symbols, bike["label"])
bike_length = bike["imageWidth"] * bike["imageHeight"] // 4
_write_2bpp_png(
rom.bytes(bike_symbol.bank, bike_symbol.address, bike_length),
bike["imageWidth"], bike["imageHeight"],
os.path.join(assets_dir, "sprites", bike["imageBase"] + ".png"),
transparent_color0=True)
bike_frames = bike["imageHeight"] // 16
out["SPRITE_RED_BIKE"] = {
"id": "SPRITE_RED_BIKE",
"source": "ROM:RedBikeSprite",
"image": "assets/generated/sprites/red_bike.png",
"frames": bike_frames,
"walker": bike_frames >= 6,
}
# Yellow-only: the surfing-Pikachu overworld ride sheet, loaded
# outside SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) --
# same extra extract as RedBikeSprite. (RFC 0001)
surf = manifest["sprites"].get("surfPikachu")
if surf and _has_symbol(symbols, surf["label"]):
surf_symbol = _symbol(symbols, surf["label"])
surf_length = surf["imageWidth"] * surf["imageHeight"] // 4
_write_2bpp_png(
rom.bytes(surf_symbol.bank, surf_symbol.address, surf_length),
surf["imageWidth"], surf["imageHeight"],
os.path.join(assets_dir, "sprites", surf["imageBase"] + ".png"),
transparent_color0=True)
surf_frames = surf["imageHeight"] // 16
out["SPRITE_SURFING_PIKACHU"] = {
"id": "SPRITE_SURFING_PIKACHU",
"source": f"ROM:{surf['label']}",
"image": f"assets/generated/sprites/{surf['imageBase']}.png",
"frames": surf_frames,
"walker": surf_frames >= 6,
}
util.write_lua(
os.path.join(out_dir, "sprites.lua"), out,
header="Source: canonical Pokemon Red ROM (overworld sprite sheets)")
return out
def _signed_byte(value):
return value - 0x100 if value & 0x80 else value
def _map_id(order, value):
if value == 0xFF:
return "LAST_MAP"
if value >= len(order):
raise ValueError(f"unknown map id ${value:02X}")
return order[value]
def extract_maps(rom, symbols, manifest, out_dir):
map_order = manifest["constants"]["mapOrder"]
dimensions = manifest["constants"]["maps"]
metadata = manifest["maps"]
tilesets = manifest["constants"]["tilesetOrder"]
sprites = manifest["constants"]["spriteOrder"]
movement_names = {0xFE: "WALK", 0xFF: "STAY"}
range_names = {
0x00: "ANY_DIR",
0x01: "UP_DOWN",
0x02: "LEFT_RIGHT",
0x10: "BOULDER_MOVEMENT_BYTE_2",
0xD0: "DOWN",
0xD1: "UP",
0xD2: "LEFT",
0xD3: "RIGHT",
0xFF: "NONE",
}
directions = (
("north", 0x08),
("south", 0x04),
("west", 0x02),
("east", 0x01),
)
out = {}
for const_name, spec in metadata.items():
dims = dimensions[const_name]
label = spec["label"]
header = _symbol(symbols, label + "_h")
address = header.address
tileset_id = rom.byte(header.bank, address)
height = rom.byte(header.bank, address + 1)
width = rom.byte(header.bank, address + 2)
if (width, height) != (dims["width"], dims["height"]):
raise ValueError(
f"{const_name}: ROM dimensions {width}x{height} do not match "
f"manifest {dims['width']}x{dims['height']}")
if tileset_id >= len(tilesets):
raise ValueError(f"{const_name}: unknown tileset id {tileset_id}")
block_pointer = rom.word(header.bank, address + 3)
connection_flags = rom.byte(header.bank, address + 9)
address += 10
connections = {}
for direction, bit in directions:
if not connection_flags & bit:
continue
target_id = rom.byte(header.bank, address)
y_offset = _signed_byte(rom.byte(header.bank, address + 7))
x_offset = _signed_byte(rom.byte(header.bank, address + 8))
encoded_offset = x_offset if direction in ("north", "south") \
else y_offset
if encoded_offset % 2:
raise ValueError(
f"{const_name}: odd {direction} connection offset")
connections[direction] = {
"map": _map_id(map_order, target_id),
"offset": -encoded_offset // 2,
}
address += 11
if connection_flags & ~0x0F:
raise ValueError(
f"{const_name}: unknown connection flags ${connection_flags:02X}")
object_pointer = rom.word(header.bank, address)
object_address = object_pointer
border_block = rom.byte(header.bank, object_address)
object_address += 1
warp_count = rom.byte(header.bank, object_address)
object_address += 1
warps = []
for _ in range(warp_count):
y, x, dest_warp, dest_map = rom.bytes(
header.bank, object_address, 4)
warps.append({
"x": x,
"y": y,
"destMap": _map_id(map_order, dest_map),
"destWarp": dest_warp + 1,
})
object_address += 4
sign_count = rom.byte(header.bank, object_address)
object_address += 1
if sign_count != len(spec["signTexts"]):
raise ValueError(
f"{const_name}: ROM has {sign_count} signs, metadata has "
f"{len(spec['signTexts'])}")
signs = []
for sign_text in spec["signTexts"]:
y, x, _text_id = rom.bytes(header.bank, object_address, 3)
signs.append({"x": x, "y": y, "text": sign_text})
object_address += 3
object_count = rom.byte(header.bank, object_address)
object_address += 1
if object_count != len(spec["objects"]):
raise ValueError(
f"{const_name}: ROM has {object_count} objects, metadata has "
f"{len(spec['objects'])}")
objects = []
for index, object_spec in enumerate(spec["objects"], start=1):
sprite_id, y, x, movement_id, range_id, text_id = rom.bytes(
header.bank, object_address, 6)
if not 1 <= sprite_id <= len(sprites):
raise ValueError(
f"{const_name} object {index}: unknown sprite {sprite_id}")
if movement_id not in movement_names or range_id not in range_names:
raise ValueError(
f"{const_name} object {index}: unknown movement encoding")
obj = {
"index": index,
"x": x - 4,
"y": y - 4,
"sprite": sprites[sprite_id - 1],
"movement": movement_names[movement_id],
"range": range_names[range_id],
"text": object_spec["text"],
}
object_address += 6
if text_id & 0x80:
if "item" not in object_spec:
raise ValueError(
f"{const_name} object {index}: unexpected item payload")
obj["item"] = object_spec["item"]
object_address += 1
elif text_id & 0x40:
extra, level_or_party = rom.bytes(
header.bank, object_address, 2)
object_address += 2
if "trainerClass" in object_spec:
obj["trainerClass"] = object_spec["trainerClass"]
party = object_spec.get("trainerParty")
obj["trainerParty"] = (
party if isinstance(party, str) else level_or_party)
elif "pokemon" in object_spec:
obj["pokemon"] = object_spec["pokemon"]
obj["level"] = level_or_party
else:
raise ValueError(
f"{const_name} object {index}: unexpected trainer "
"or static Pokemon payload")
_ = extra
elif any(key in object_spec for key in (
"item", "trainerClass", "pokemon")):
raise ValueError(
f"{const_name} object {index}: missing extra payload")
for key in ("name", "hidden"):
if key in object_spec:
obj[key] = object_spec[key]
objects.append(obj)
expected_blocks = width * height
block_length = spec["blockLength"]
if block_length > expected_blocks:
raise ValueError(
f"{const_name}: block payload is longer than map dimensions")
blocks = list(rom.bytes(
header.bank, block_pointer, block_length))
blocks.extend([border_block] * (expected_blocks - block_length))
out[const_name] = {
"id": const_name,
"label": label,
"index": dims["index"],
"source": f"ROM:{header.bank:02X}:{header.address:04X}",
"tileset": tilesets[tileset_id],
"width": width,
"height": height,
"blocks": blocks,
"borderBlock": border_block,
"connections": connections,
"warps": warps,
"signs": signs,
"objects": objects,
}
util.write_lua(
os.path.join(out_dir, "maps.lua"), out,
header="Source: canonical Pokemon Red ROM (map headers, block maps,\n"
"connections, warps, signs, and object events)")
return out
def _animation_flags(rom, symbols, count):
table = _symbol(symbols, "AttackAnimationPointers")
flags = []
for index in range(count):
address = rom.word(table.bank, table.address + index * 2)
shake = False
flash = False
for _ in range(256):
first = rom.byte(table.bank, address)
if first == 0xFF:
break
if first >= 0xD8:
shake = shake or first == 0xFB
flash = flash or first in (0xF8, 0xFE)
address += 2
else:
address += 3
else:
raise ValueError(f"unterminated move animation {index + 1}")
flags.append((shake, flash))
return flags
def extract_moves(rom, symbols, manifest, out_dir):
order = manifest["constants"]["moveOrder"]
type_by_id = {
int(value): name
for name, value in manifest["constants"]["types"].items()
}
effects = manifest["moveEffects"]
charmap = manifest["charmap"]
sfx_keys = manifest["sfxKeys"]
moves = _symbol(symbols, "Moves")
names = _symbol(symbols, "MoveNames")
sounds = _symbol(symbols, "MoveSoundTable")
flags = _animation_flags(rom, symbols, len(order))
decoded_names = []
address = names.address
for _ in order:
value, consumed = read_string(
rom, names.bank, address, charmap, max_length=32)
decoded_names.append(value)
address += consumed
out = {}
for index, move_id in enumerate(order):
row = rom.bytes(moves.bank, moves.address + index * 6, 6)
if row[0] != index + 1:
raise ValueError(
f"Moves row {index + 1} stores animation id {row[0]}")
effect = effects[row[1]] if row[1] < len(effects) else f"EFFECT_{row[1]:02X}"
type_name = type_by_id.get(row[3], f"TYPE_{row[3]:02X}")
sound_id, pitch, tempo = rom.bytes(
sounds.bank, sounds.address + index * 3, 3)
anim = {
"sound": sfx_keys.get(str(sound_id), f"SFX_{sound_id:02X}"),
"pitch": pitch,
"tempo": tempo,
}
shake, flash = flags[index]
if shake:
anim["shake"] = True
if flash:
anim["flash"] = True
out[move_id] = {
"id": move_id,
"index": index + 1,
"name": decoded_names[index],
"source": f"ROM:Moves[{index + 1}]",
"effect": effect,
"power": row[2],
"type": type_name,
"accuracy": round(row[4] * 100 / 255),
"pp": row[5],
"anim": anim,
}
util.write_lua(
os.path.join(out_dir, "moves.lua"), out,
header="Source: canonical Pokemon Red ROM (Moves, MoveNames,\n"
"MoveSoundTable, AttackAnimationPointers)")
return out
def extract_battle_anims(
rom, symbols, manifest, out_dir, assets_dir):
metadata = manifest["battleAnimations"]
move_order = manifest["constants"]["moveOrder"]
if len(move_order) != metadata["moveCount"]:
raise ValueError("battle animation move count does not match constants")
coords_symbol = _symbol(symbols, "FrameBlockBaseCoords")
base_coords = {}
for index in range(metadata["baseCoordCount"]):
y, x = rom.bytes(
coords_symbol.bank, coords_symbol.address + index * 2, 2)
base_coords[index] = {"y": y, "x": x}
blocks_symbol = _symbol(symbols, "FrameBlockPointers")
frame_blocks = {}
for index in range(metadata["frameBlockCount"]):
address = rom.word(
blocks_symbol.bank, blocks_symbol.address + index * 2)
count = rom.byte(blocks_symbol.bank, address)
address += 1
entries = []
for _ in range(count):
y, x, tile, attrs = rom.bytes(
blocks_symbol.bank, address, 4)
entry = {
"y": y,
"x": x,
"tile": tile,
"xflip": bool(attrs & 0x20),
"yflip": bool(attrs & 0x40),
}
if attrs & 0x80:
entry["prio"] = True
if attrs & 0x10:
entry["pal1"] = True
entries.append(entry)
address += 4
frame_blocks[index] = entries
subanim_symbol = _symbol(symbols, "SubanimationPointers")
subanims = {}
type_names = metadata["subanimTypes"]
for index in range(metadata["subanimCount"]):
address = rom.word(
subanim_symbol.bank, subanim_symbol.address + index * 2)
packed = rom.byte(subanim_symbol.bank, address)
type_id, count = packed >> 5, packed & 0x1F
if type_id >= len(type_names):
raise ValueError(
f"subanimation {index} has unknown type {type_id}")
address += 1
entries = []
for _ in range(count):
block, coord, mode = rom.bytes(
subanim_symbol.bank, address, 3)
if block >= metadata["frameBlockCount"]:
raise ValueError(
f"subanimation {index} has invalid frame block {block}")
if coord >= metadata["baseCoordCount"]:
raise ValueError(
f"subanimation {index} has invalid base coord {coord}")
entries.append({
"block": block, "coord": coord, "mode": mode})
address += 3
subanims[index] = {
"type": type_names[type_id],
"blocks": entries,
}
tiles_table = _symbol(symbols, "MoveAnimationTilesPointers")
tile_rows = []
tile_specs = metadata["tilesheets"]
if len(tile_specs) != 3:
raise ValueError("expected three battle animation tilesheets")
for index, spec in enumerate(tile_specs):
count, low, high, padding = rom.bytes(
tiles_table.bank, tiles_table.address + index * 4, 4)
if padding != 0xFF:
raise ValueError(
f"battle animation tilesheet {index} has invalid padding")
pointer = low | high << 8
expected = _symbol(symbols, f"MoveAnimationTiles{index}")
if expected.bank != tiles_table.bank or expected.address != pointer:
raise ValueError(
f"battle animation tilesheet {index} pointer differs")
tile_rows.append({
"count": count, "pointer": pointer, "spec": spec})
image_payloads = {}
for row in tile_rows:
path = row["spec"]["path"]
existing = image_payloads.get(path)
if existing and existing["pointer"] != row["pointer"]:
raise ValueError(
f"shared battle animation atlas {path} has two pointers")
if not existing:
existing = {"pointer": row["pointer"], "tiles": 0,
"spec": row["spec"]}
image_payloads[path] = existing
existing["tiles"] = max(existing["tiles"], row["count"])
for path, payload in image_payloads.items():
spec = payload["spec"]
byte_length = spec["width"] * spec["height"] // 4
stored_length = payload["tiles"] * 16
if stored_length > byte_length:
raise ValueError(f"{path}: battle animation atlas is too large")
raw = rom.bytes(
tiles_table.bank, payload["pointer"], stored_length)
raw += bytes(byte_length - stored_length)
prefix = "assets/generated/"
if not path.startswith(prefix):
raise ValueError(f"invalid generated asset path {path!r}")
_write_2bpp_png(
raw, spec["width"], spec["height"],
os.path.join(assets_dir, path[len(prefix):]),
transparent_color0=True)
tilesheets = {}
for index, row in enumerate(tile_rows):
spec = row["spec"]
tilesheets[index] = {
"path": spec["path"],
"width": spec["width"],
"height": spec["height"],
"tiles": row["count"],
"source": spec["source"],
}
move_names = move_order + metadata["miscAnimations"]
pointer_table = _symbol(symbols, "AttackAnimationPointers")
first_special = metadata["firstSpecialEffect"]
special_effects = metadata["specialEffects"]
move_anims = {}
for index, name in enumerate(move_names):
address = rom.word(
pointer_table.bank, pointer_table.address + index * 2)
sequence = []
for _ in range(256):
first = rom.byte(pointer_table.bank, address)
if first == 0xFF:
break
sound = rom.byte(pointer_table.bank, address + 1)
if first >= first_special:
effect = special_effects.get(str(first))
if not effect:
raise ValueError(
f"{name}: unknown special effect ${first:02X}")
row = {"effect": effect}
address += 2
else:
subanim = rom.byte(pointer_table.bank, address + 2)
delay = first & 0x3F
tileset = first >> 6
if not delay:
raise ValueError(f"{name}: zero animation delay")
if subanim >= metadata["subanimCount"]:
raise ValueError(
f"{name}: unknown subanimation {subanim}")
if tileset not in tilesheets:
raise ValueError(
f"{name}: unknown animation tileset {tileset}")
row = {
"subanim": subanim,
"tileset": tileset,
"delay": delay,
}
address += 3
if sound != 0xFF:
if sound >= len(move_order):
raise ValueError(
f"{name}: unknown animation sound {sound}")
row["sound"] = move_order[sound]
sequence.append(row)
else:
raise ValueError(f"{name}: unterminated battle animation")
move_anims[name] = {
"source": f"ROM:AttackAnimationPointers[{index}]",
"seq": sequence,
}
for name, anim in move_anims.items():
for row in anim["seq"]:
if "subanim" not in row:
continue
sheet = tilesheets[row["tileset"]]
for block_ref in subanims[row["subanim"]]["blocks"]:
for tile in frame_blocks[block_ref["block"]]:
if tile["tile"] >= sheet["tiles"]:
raise ValueError(
f"{name}: tile {tile['tile']} is out of range "
f"for tileset {row['tileset']}")
out = {
"tilesheets": tilesheets,
"baseCoords": base_coords,
"frameBlocks": frame_blocks,
"subanims": subanims,
"moveAnims": move_anims,
}
util.write_lua(
os.path.join(out_dir, "battle_anims.lua"), out,
header="Source: canonical Pokemon Red ROM battle animation tables,\n"
"frame geometry, coordinates, and OAM tile graphics")
return out
def _nybbles(raw, count):
out = []
for value in raw:
out.extend((value >> 4, value & 0x0F))
return out[:count]
def extract_items(rom, symbols, manifest, out_dir):
order = manifest["items"]
charmap = manifest["charmap"]
names = _symbol(symbols, "ItemNames")
prices = _symbol(symbols, "ItemPrices")
key_flags = _symbol(symbols, "KeyItemFlags")
tm_prices = _symbol(symbols, "TechnicalMachinePrices")
decoded_names = []
address = names.address
for _ in order:
value, consumed = read_string(
rom, names.bank, address, charmap, max_length=32)
decoded_names.append(value)
address += consumed
num_items = manifest["numItems"]
flags = rom.bytes(key_flags.bank, key_flags.address, (num_items + 7) // 8)
out = {}
for index, item_id in enumerate(order):
entry = {
"id": item_id,
"index": index + 1,
"name": decoded_names[index],
"price": bcd(rom.bytes(
prices.bank, prices.address + index * 3, 3)),
"source": f"ROM:ItemNames[{index + 1}]",
}
if index < num_items and flags[index // 8] & (1 << (index % 8)):
entry["keyItem"] = True
out[item_id] = entry
for number, move in enumerate(manifest["hms"], start=1):
item_id = "HM_" + move
out[item_id] = {
"id": item_id,
"name": f"HM{number:02d}",
"price": 0,
"machine": {"kind": "HM", "number": number, "move": move},
"source": "ROM metadata manifest (HM mapping)",
}
tms = manifest["tms"]
packed = rom.bytes(
tm_prices.bank, tm_prices.address, (len(tms) + 1) // 2)
prices_by_tm = _nybbles(packed, len(tms))
for number, move in enumerate(tms, start=1):
item_id = "TM_" + move
out[item_id] = {
"id": item_id,
"name": f"TM{number:02d}",
"price": prices_by_tm[number - 1] * 1000,
"machine": {"kind": "TM", "number": number, "move": move},
"source": f"ROM:TechnicalMachinePrices[{number}]",
}
util.write_lua(
os.path.join(out_dir, "items.lua"), out,
header="Source: canonical Pokemon Red ROM (ItemNames, ItemPrices,\n"
"KeyItemFlags, TechnicalMachinePrices)")
return out
def extract_type_chart(rom, symbols, manifest, out_dir):
type_by_id = {
int(value): name
for name, value in manifest["constants"]["types"].items()
}
effects = _symbol(symbols, "TypeEffects")
address = effects.address
matchups = []
while rom.byte(effects.bank, address) != 0xFF:
attacker, defender, multiplier = rom.bytes(
effects.bank, address, 3)
matchups.append({
"attacker": type_by_id.get(attacker, f"TYPE_{attacker:02X}"),
"defender": type_by_id.get(defender, f"TYPE_{defender:02X}"),
"multiplier": multiplier,
})
address += 3
names = []
seen = set()
for label in manifest["typeNameLabels"]:
symbol = _symbol(symbols, label)
if (symbol.bank, symbol.address) in seen:
continue
seen.add((symbol.bank, symbol.address))
name, _ = read_string(
rom, symbol.bank, symbol.address, manifest["charmap"],
max_length=16)
names.append(name)
data = {
"source": "ROM:TypeEffects + TypeNames",
"matchups": matchups,
"names": names,
}
util.write_lua(
os.path.join(out_dir, "type_chart.lua"), data,
header="Source: canonical Pokemon Red ROM; multipliers are x10")
return data
def _scale5(value):
return round(value * 255 / 31)
def extract_palettes(rom, symbols, manifest, out_dir):
order = manifest["paletteOrder"]
table = _symbol(symbols, "SuperPalettes")
palettes = {}
for index, name in enumerate(order):
colors = []
for color in range(4):
value = rom.word(
table.bank, table.address + index * 8 + color * 2)
colors.append([
_scale5(value & 0x1F),
_scale5((value >> 5) & 0x1F),
_scale5((value >> 10) & 0x1F),
])
palettes[name] = colors
mon_table = _symbol(symbols, "MonsterPalettes")
mon_pals = {}
for index, species in enumerate(manifest["dexOrder"], start=1):
palette_id = rom.byte(mon_table.bank, mon_table.address + index)
mon_pals[species] = order[palette_id]
data = {
"source": "ROM:SuperPalettes + MonsterPalettes",
"palettes": palettes,
"order": order,
"pokemon": mon_pals,
}
if _has_symbol(symbols, "CGBBasePalettes"):
cgb_table = _symbol(symbols, "CGBBasePalettes")
cgb = {}
for index, name in enumerate(order):
colors = []
for color in range(4):
value = rom.word(
cgb_table.bank, cgb_table.address + index * 8 + color * 2)
colors.append([
_scale5(value & 0x1F),
_scale5((value >> 5) & 0x1F),
_scale5((value >> 10) & 0x1F),
])
cgb[name] = colors
data["cgbBase"] = cgb
data["source"] = data["source"] + " + CGBBasePalettes"
util.write_lua(
os.path.join(out_dir, "palettes.lua"), data,
header="Source: canonical Gen-1 ROM; 4 RGB colors per palette")
return data
def extract_icons(rom, symbols, manifest, out_dir, assets_dir):
table = _symbol(symbols, "MonPartyData")
count = len(manifest["dexOrder"])
packed = rom.bytes(table.bank, table.address, (count + 1) // 2)
values = _nybbles(packed, count)
by_dex = [
manifest["iconOrder"][value]
if value < len(manifest["iconOrder"]) else f"ICON_{value:X}"
for value in values
]
icons = {
"MON": "assets/generated/sprites/monster.png",
"BALL": "assets/generated/sprites/poke_ball.png",
"HELIX": "assets/generated/sprites/fossil.png",
"FAIRY": "assets/generated/sprites/fairy.png",
"BIRD": "assets/generated/sprites/bird.png",
"WATER": "assets/generated/sprites/seel.png",
"BUG": "assets/generated/icons/bug.png",
"GRASS": "assets/generated/icons/plant.png",
"SNAKE": "assets/generated/icons/snake.png",
"QUADRUPED": "assets/generated/icons/quadruped.png",
}
icon_frames = {
"bug": ("BugIconFrame1", "BugIconFrame2"),
"plant": ("PlantIconFrame1", "PlantIconFrame2"),
"snake": ("SnakeIconFrame1", "SnakeIconFrame2"),
"quadruped": ("QuadrupedIconFrame1", "QuadrupedIconFrame2"),
}
for filename, labels in icon_frames.items():
raw = bytearray()
for label in labels:
symbol = _symbol(symbols, label)
raw.extend(rom.bytes(symbol.bank, symbol.address, 32))
half = _decode_2bpp(bytes(raw), 8, 32, transparent_color0=True)
image = Image.new("RGBA", (16, 32), (255, 255, 255, 0))
for frame in range(2):
crop = half.crop((0, frame * 16, 8, frame * 16 + 16))
image.paste(crop, (0, frame * 16))
image.paste(
crop.transpose(Image.Transpose.FLIP_LEFT_RIGHT),
(8, frame * 16))
_save_png(
image, os.path.join(assets_dir, "icons", filename + ".png"))
data = {
"source": "ROM:MonPartyData",
"byDex": by_dex,
"icons": icons,
}
util.write_lua(
os.path.join(out_dir, "icons.lua"), data,
header="Source: canonical Pokemon Red ROM (MonPartyData)")
return data
def _species(manifest, value):
order = manifest["constants"]["speciesOrder"]
if not 1 <= value <= len(order):
return f"SPECIES_{value:02X}"
return order[value - 1]
def _item(manifest, value):
order = manifest["items"]
if not 1 <= value <= len(order):
return f"ITEM_{value:02X}"
return order[value - 1]
def _move(manifest, value):
order = manifest["constants"]["moveOrder"]
if value == 0:
return None
if not 1 <= value <= len(order):
return f"MOVE_{value:02X}"
return order[value - 1]
def _types_by_id(manifest):
return {
int(value): name
for name, value in manifest["constants"]["types"].items()
}
def _decode_evos_moves(rom, symbols, manifest, index):
table = _symbol(symbols, "EvosMovesPointerTable")
address = rom.word(table.bank, table.address + index * 2)
evolutions = []
while True:
method = rom.byte(table.bank, address)
address += 1
if method == 0:
break
if method == 1:
level, species = rom.bytes(table.bank, address, 2)
address += 2
evolutions.append({
"method": "LEVEL",
"level": level,
"species": _species(manifest, species),
})
elif method == 2:
item, level, species = rom.bytes(table.bank, address, 3)
address += 3
evolutions.append({
"method": "ITEM",
"item": _item(manifest, item),
"level": level,
"species": _species(manifest, species),
})
elif method == 3:
level, species = rom.bytes(table.bank, address, 2)
address += 2
evolutions.append({
"method": "TRADE",
"level": level,
"species": _species(manifest, species),
})
else:
raise ValueError(
f"unknown evolution method {method} for species index {index + 1}")
learnset = []
while True:
level = rom.byte(table.bank, address)
address += 1
if level == 0:
break
move = rom.byte(table.bank, address)
address += 1
learnset.append({"level": level, "move": _move(manifest, move)})
return evolutions, learnset
def _dex_entry(rom, symbols, manifest, index, species):
table = _symbol(symbols, "PokedexEntryPointers")
address = rom.word(table.bank, table.address + index * 2)
kind, consumed = read_string(
rom, table.bank, address, manifest["charmap"], max_length=32)
address += consumed
height_ft, height_in = rom.bytes(table.bank, address, 2)
weight = rom.word(table.bank, address + 2)
address += 4
if rom.byte(table.bank, address) != 0x17:
raise ValueError(
f"dex entry {index + 1} has no TX_FAR command")
text_address = rom.word(table.bank, address + 1)
text_bank = rom.byte(table.bank, address + 3)
text_label = manifest["dexEntryLabels"].get(species)
if text_label is None:
text_label = f"_DexEntry_{text_bank:02X}_{text_address:04X}"
return {
"kind": kind,
"heightFt": height_ft,
"heightIn": height_in,
"weight": weight,
"text": text_label,
}
def extract_pokemon(rom, symbols, manifest, out_dir, assets_dir):
species_order = manifest["constants"]["speciesOrder"]
dex_order = manifest["dexOrder"]
dex_by_species = {
species: index for index, species in enumerate(dex_order, start=1)
}
type_by_id = _types_by_id(manifest)
names = _symbol(symbols, "MonsterNames")
base_stats = _symbol(symbols, "BaseStats")
# Red/Blue keep Mew outside BaseStats at MewBaseStats; Yellow folds Mew
# into BaseStats at dex 151 (pret/pokeyellow), so the symbol is optional.
mew_stats = symbols.by_name.get("MewBaseStats")
decoded_names = []
for index in range(len(species_order)):
raw = rom.bytes(names.bank, names.address + index * 10, 10)
decoded_names.append(
decode_text(raw, manifest["charmap"]))
out = {}
written_front = set()
written_back = set()
for index, species in enumerate(species_order):
if species.startswith(
("MISSINGNO", "UNUSED", "FOSSIL_", "MON_GHOST")):
continue
dex = dex_by_species[species]
if species == "MEW" and mew_stats is not None:
row = rom.bytes(mew_stats.bank, mew_stats.address, 28)
else:
row = rom.bytes(
base_stats.bank, base_stats.address + (dex - 1) * 28, 28)
if row[0] != dex:
raise ValueError(
f"{species} base stats store dex number {row[0]}, expected {dex}")
level1_moves = [
_move(manifest, value) for value in row[15:19] if value
]
tmhm = []
for bit, move in enumerate(manifest["tmhmMoves"]):
if row[20 + bit // 8] & (1 << (bit % 8)):
tmhm.append(move)
evolutions, learnset = _decode_evos_moves(
rom, symbols, manifest, index)
asset = manifest["pokemonAssets"][species]
front = asset["front"]
back = asset["back"]
if front and front not in written_front:
decoded_size = _write_compressed_pic(
rom, symbols, asset["frontLabel"],
os.path.join(
assets_dir, "battle", "front", front + ".png"))
if decoded_size != row[10] >> 4:
raise ValueError(
f"{species}: front picture size {decoded_size} does not "
f"match base stats {row[10] >> 4}")
written_front.add(front)
if back and back not in written_back:
_write_compressed_pic(
rom, symbols, asset["backLabel"],
os.path.join(
assets_dir, "battle", "back", back + ".png"))
written_back.add(back)
out[species] = {
"id": species,
"index": index + 1,
"dex": dex,
"name": decoded_names[index],
"source": f"ROM:BaseStats[{dex}]",
"types": list(dict.fromkeys(
type_by_id.get(value, f"TYPE_{value:02X}")
for value in row[6:8])),
"baseStats": {
"hp": row[1],
"attack": row[2],
"defense": row[3],
"speed": row[4],
"special": row[5],
},
"catchRate": row[8],
"baseExp": row[9],
"level1Moves": level1_moves,
"growthRate": manifest["growthRates"][row[19]],
"tmhm": tmhm,
"learnset": learnset,
"evolutions": evolutions,
"spriteFront": (
f"assets/generated/battle/front/{front}.png"
if front else None),
"spriteBack": (
f"assets/generated/battle/back/{back}.png"
if back else None),
"frontSize": row[10] >> 4,
"dexEntry": _dex_entry(
rom, symbols, manifest, index, species),
}
for label, filename in (
("FossilAerodactylPic", "fossilaerodactyl"),
("FossilKabutopsPic", "fossilkabutops"),
("GhostPic", "ghost")):
_write_compressed_pic(
rom, symbols, label,
os.path.join(
assets_dir, "battle", "front", filename + ".png"))
for label, filename in (
("RedPicBack", "redb"),
("OldManPicBack", "oldmanb")):
_write_compressed_pic(
rom, symbols, label,
os.path.join(assets_dir, "battle", filename + ".png"))
balls = _symbol(symbols, "PokeballTileGraphics")
_write_2bpp_png(
rom.bytes(balls.bank, balls.address, 64), 32, 8,
os.path.join(assets_dir, "battle", "balls.png"),
transparent_color0=True)
trainer_card = (
("TrainerInfoTextBoxTileGraphics", "trainer_info.png", 24, 24, False),
("GymLeaderFaceAndBadgeTileGraphics", "badges.png", 16, 256, True),
("BadgeNumbersTileGraphics", "badge_numbers.png", 16, 32, True),
("CircleTile", "circle_tile.png", 8, 8, True),
)
for label, filename, width, height, transparent in trainer_card:
symbol = _symbol(symbols, label)
length = width * height // 4
_write_2bpp_png(
rom.bytes(symbol.bank, symbol.address, length), width, height,
os.path.join(assets_dir, "trainer_card", filename),
transparent_color0=transparent)
_write_compressed_pic(
rom, symbols, "RedPicFront",
os.path.join(assets_dir, "trainer_card", "red.png"))
util.write_lua(
os.path.join(out_dir, "pokemon.lua"), out,
header="Source: canonical Pokemon Red ROM (BaseStats, MonsterNames,\n"
"EvosMovesPointerTable, PokedexEntryPointers)")
return out
def _trainer_parties(rom, bank, start, end, manifest):
parties = []
address = start
while address < end:
first = rom.byte(bank, address)
address += 1
party = []
if first == 0xFF:
while True:
level = rom.byte(bank, address)
address += 1
if level == 0:
break
species = rom.byte(bank, address)
address += 1
party.append({
"level": level,
"species": _species(manifest, species),
})
else:
level = first
while True:
species = rom.byte(bank, address)
address += 1
if species == 0:
break
party.append({
"level": level,
"species": _species(manifest, species),
})
parties.append(party)
if address != end:
raise ValueError(
f"trainer party data overran {bank:02X}:{end:04X}")
return parties
def extract_trainers(rom, symbols, manifest, out_dir, assets_dir):
order = manifest["trainers"]
charmap = manifest["charmap"]
names = _symbol(symbols, "TrainerNames")
pointers = _symbol(symbols, "TrainerDataPointers")
money = _symbol(symbols, "TrainerPicAndMoneyPointers")
choices = _symbol(symbols, "TrainerClassMoveChoiceModifications")
decoded_names = []
address = names.address
for _ in order:
name, consumed = read_string(
rom, names.bank, address, charmap, max_length=32)
decoded_names.append(name)
address += consumed
ai_mods = []
address = choices.address
for _ in order:
mods = []
while True:
value = rom.byte(choices.bank, address)
address += 1
if value == 0:
break
mods.append(value)
ai_mods.append(mods)
party_starts = [
rom.word(pointers.bank, pointers.address + index * 2)
for index in range(len(order))
]
party_ends = party_starts[1:] + [_symbol(symbols, "TrainerAI").address]
out = {}
written_pics = set()
for index, label in enumerate(order):
trainer_id = "OPP_" + label
raw_money = rom.bytes(
money.bank, money.address + index * 5 + 2, 3)
pic = manifest["trainerPics"][index]
if pic and pic["imageBase"] not in written_pics:
_write_compressed_pic(
rom, symbols, pic["label"],
os.path.join(
assets_dir, "battle", "trainers",
pic["imageBase"] + ".png"))
written_pics.add(pic["imageBase"])
out[trainer_id] = {
"id": trainer_id,
"index": index + 1,
"name": decoded_names[index],
"source": "ROM:TrainerDataPointers",
"pic": pic["path"] if pic else None,
"baseMoney": bcd(raw_money) // 100,
"aiMods": ai_mods[index],
"parties": _trainer_parties(
rom, pointers.bank, party_starts[index],
party_ends[index], manifest),
}
util.write_lua(
os.path.join(out_dir, "trainers.lua"), out,
header="Source: canonical Pokemon Red ROM (TrainerDataPointers,\n"
"TrainerNames, TrainerPicAndMoneyPointers)")
return out
def _wild_table(rom, bank, address, manifest):
grass_rate = rom.byte(bank, address)
address += 1
grass = {"rate": grass_rate, "slots": []}
if grass_rate:
for _ in range(10):
level, species = rom.bytes(bank, address, 2)
grass["slots"].append({
"level": level,
"species": _species(manifest, species),
})
address += 2
water_rate = rom.byte(bank, address)
address += 1
water = {"rate": water_rate, "slots": []}
if water_rate:
for _ in range(10):
level, species = rom.bytes(bank, address, 2)
water["slots"].append({
"level": level,
"species": _species(manifest, species),
})
address += 2
return grass, water
def extract_encounters(rom, symbols, manifest, out_dir):
maps = manifest["constants"]["mapOrder"]
pointers = _symbol(symbols, "WildDataPointers")
nothing = _symbol(symbols, "NothingWildMons")
out = {}
for index, map_id in enumerate(maps):
address = rom.word(
pointers.bank, pointers.address + index * 2)
if address == nothing.address:
continue
grass, water = _wild_table(
rom, pointers.bank, address, manifest)
entry = {"source": f"ROM:{pointers.bank:02X}:{address:04X}"}
if grass["rate"] or grass["slots"]:
entry["grass"] = grass
if water["rate"] or water["slots"]:
entry["water"] = water
out[map_id] = entry
util.write_lua(
os.path.join(out_dir, "encounters.lua"), out,
header="Source: canonical Pokemon Red ROM (WildDataPointers)")
return out
TEXT_GLYPH_OVERRIDES = {
0x4B: "{_CONT}",
0x4C: "{SCROLL}",
0x6D: "{COLON}",
0xF0: "¥",
}
def _text_glyph(value, charmap):
if value in TEXT_GLYPH_OVERRIDES:
return TEXT_GLYPH_OVERRIDES[value]
glyph = charmap.get(str(value), f"{{BYTE:{value:02X}}}")
if glyph.startswith("<") and glyph.endswith(">"):
return "{" + glyph[1:-1] + "}"
return glyph
def _decode_text_commands(rom, symbol, charmap, substitutions):
address = symbol.address
pending = deque(substitutions)
out = []
for _ in range(4096):
command = rom.byte(symbol.bank, address)
address += 1
if command == 0x50:
if pending:
raise ValueError(
f"{symbol.name}: unused dynamic text substitutions")
return "".join(out)
if command == 0:
while True:
value = rom.byte(symbol.bank, address)
address += 1
if value == 0x50:
break
if value in (0x57, 0x58, 0x5F):
if pending:
raise ValueError(
f"{symbol.name}: unused dynamic text substitutions")
return "".join(out)
out.append(_text_glyph(value, charmap))
continue
if command in (1, 2, 9):
if not pending:
raise ValueError(
f"{symbol.name}: missing substitution for command "
f"${command:02X}")
expected, token = pending.popleft()
if command != expected:
raise ValueError(
f"{symbol.name}: expected command ${expected:02X}, "
f"found ${command:02X}")
out.append(token)
address += 2 if command == 1 else 3
continue
raise ValueError(
f"{symbol.name}: unsupported text command ${command:02X}")
raise ValueError(f"{symbol.name}: text command stream is too long")
def extract_text(rom, symbols, manifest, out_dir):
metadata = manifest["text"]
charmap = manifest["charmap"]
dynamic = metadata["dynamic"]
trainer_headers = {
map_label: {
int(index): header for index, header in headers.items()
}
for map_label, headers in metadata["trainerHeaders"].items()
}
texts = {}
for label in metadata["labels"]:
texts[label] = _decode_text_commands(
rom, _symbol(symbols, label), charmap, dynamic.get(label, []))
util.write_lua(
os.path.join(out_dir, "text.lua"), texts,
header="Source: canonical Pokemon Red ROM text command streams")
util.write_lua(
os.path.join(out_dir, "text_pointers.lua"), metadata["pointers"],
header="Map text integration metadata; dialogue is decoded from ROM")
util.write_lua(
os.path.join(out_dir, "trainer_headers.lua"),
trainer_headers,
header="Trainer integration metadata; dialogue is decoded from ROM")
return {
"texts": texts,
"pointers": metadata["pointers"],
"trainerHeaders": trainer_headers,
}
def extract_field(rom, symbols, manifest, out_dir, assets_dir):
def raw_2bpp(
label, width, height, relative, transparent=False, matte=False,
columns=False, stored_length=None):
expected = width * height // 4
length = expected if stored_length is None else stored_length
symbol = _symbol(symbols, label)
raw = rom.bytes(symbol.bank, symbol.address, length)
if len(raw) < expected:
raw += bytes(expected - len(raw))
if columns:
raw = _columns_to_rows(raw, width // 8, height // 8)
image = _decode_2bpp(raw, width, height, transparent)
if matte:
image = _matte_color0(image)
_save_png(image, os.path.join(assets_dir, relative))
return image
def raw_1bpp(label, width, height, relative, transparent=False):
symbol = _symbol(symbols, label)
raw = rom.bytes(symbol.bank, symbol.address, width * height // 8)
image = _decode_1bpp(raw, width, height, transparent)
_save_png(image, os.path.join(assets_dir, relative))
return image
raw_2bpp(
"PokemonLogoGraphics", 128, 56, "title/pokemon_logo.png")
raw_1bpp("Version_GFX", 80, 8, "title/red_version.png")
raw_2bpp(
"PlayerCharacterTitleGraphics", 40, 56, "title/player.png",
matte=True)
raw_2bpp(
"NintendoCopyrightLogoGraphics", 152, 8,
"title/copyright.png")
raw_2bpp(
"GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png")
# Yellow NineTile (pokeyellow gfx/font.asm): final "9" of (c)1995-1999,
# parked in the 16 bytes between GameFreakLogoGraphics and TextBoxGraphics.
if _has_symbol(symbols, "GameFreakLogoGraphics") \
and _has_symbol(symbols, "TextBoxGraphics"):
gf = _symbol(symbols, "GameFreakLogoGraphics")
tb = _symbol(symbols, "TextBoxGraphics")
if tb.address == gf.address + 9 * 16 + 16:
nine_raw = rom.bytes(gf.bank, gf.address + 9 * 16, 16)
_save_png(
_decode_2bpp(nine_raw, 8, 8, False),
os.path.join(assets_dir, "title/nine.png"))
# Yellow fixed Pikachu title (pret/pokeyellow title_yellow.asm): tilemap
# composition over both tile banks -- PokemonLogoGraphics in vChars2
# (BG ids $00-$7F), TitlePikachuBGGraphics in vChars1 (ids $80-$EF),
# TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
# OAM tiles), PokemonLogoCornerGraphics at vChars1 tile $7D ($FD-$FF).
# Mirrors RomExtractor:extractYellowTitleArt.
if _has_symbol(symbols, "TitlePikachuBGGraphics"):
raw_2bpp(
"TitlePikachuBGGraphics", 128, 32, "title/pikachu_bg.png",
transparent=True)
raw_2bpp(
"TitlePikachuOBGraphics", 96, 8, "title/pikachu_ob.png",
transparent=True)
def sheet_tiles(label, count, transparent=False):
symbol = _symbol(symbols, label)
raw = rom.bytes(symbol.bank, symbol.address, count * 16)
return [
_decode_2bpp(raw[index:index + 16], 8, 8, transparent)
for index in range(0, len(raw), 16)
]
# tile counts = Graphics..GraphicsEnd symbol gaps in pokeyellow.sym
logo_tiles = sheet_tiles("PokemonLogoGraphics", 115)
corner_tiles = sheet_tiles("PokemonLogoCornerGraphics", 3)
bg_tiles = sheet_tiles("TitlePikachuBGGraphics", 64)
ob_tiles = sheet_tiles("TitlePikachuOBGraphics", 12)
ob_clear = sheet_tiles("TitlePikachuOBGraphics", 12, True)
def tile_for(tid):
if tid < 0x80:
return logo_tiles[tid]
if tid < 0xF0:
return bg_tiles[tid - 0x80]
if tid < 0xFD:
return ob_tiles[tid - 0xF0]
return corner_tiles[tid - 0xFD]
def matte_color0(pose):
from collections import deque
w, h = pose.size
seen = set()
q = deque()
def add(x, y):
if (x, y) in seen or not (0 <= x < w and 0 <= y < h):
return
if pose.getpixel((x, y)) == (255, 255, 255, 255):
seen.add((x, y))
q.append((x, y))
for x in range(w):
add(x, 0); add(x, h - 1)
for y in range(h):
add(0, y); add(w - 1, y)
while q:
x, y = q.popleft()
pose.putpixel((x, y), (255, 255, 255, 0))
add(x - 1, y); add(x + 1, y); add(x, y - 1); add(x, y + 1)
return pose
def compose(cols, rows, cells):
pose = Image.new("RGBA", (cols * 8, rows * 8), (255, 255, 255, 0))
for tid, cx, cy in cells:
pose.paste(tile_for(tid), (cx * 8, cy * 8))
return pose
def map_cells(label, cols, rows):
loc = _symbol(symbols, label)
ids = list(rom.bytes(loc.bank, loc.address, cols * rows))
return [
(tid, index % cols, index // cols)
for index, tid in enumerate(ids)
]
# 16x7 logo box at (2,1); overwrites the scrambled sequential rip
# above (Yellow's logo sheet is deduplicated, Red's is not)
_save_png(
compose(16, 7, map_cells("TitleScreenPokemonLogoTilemap", 16, 7)),
os.path.join(assets_dir, "title/pokemon_logo.png"))
# 7x4 bubble at (6,4) + the two tail tiles poked at (9,8)
bubble_cells = map_cells("TitleScreenPikaBubbleTilemap", 7, 4)
bubble_cells += [(0x64, 3, 4), (0x65, 4, 4)]
_save_png(
matte_color0(compose(7, 5, bubble_cells)),
os.path.join(assets_dir, "title/pika_bubble.png"))
# 12x9 Pikachu at (4,8) + right-ear edge tiles down column 16 and
# the baked OAM eyes (TitleScreenPikachuEyesOAMData, left eye
# x-flipped, attr $22)
pika_cells = map_cells("TitleScreenPikachuTilemap", 12, 9)
pika_cells += [(0x96, 12, 2), (0x9d, 12, 3),
(0xa7, 12, 4), (0xb1, 12, 5)]
pikachu = matte_color0(compose(13, 9, pika_cells))
for ob_index, px, py, flip in (
(1, 24, 16, True), (0, 32, 16, True),
(3, 24, 24, True), (2, 32, 24, True),
(0, 56, 16, False), (1, 64, 16, False),
(2, 56, 24, False), (3, 64, 24, False)):
eye = ob_clear[ob_index]
if flip:
eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
pikachu.paste(eye, (px, py), eye)
_save_png(pikachu, os.path.join(assets_dir, "title/pikachu.png"))
falling_star = raw_2bpp(
"FallingStar", 8, 8, "intro/falling_star.png",
transparent=True)
blink = Image.new("RGBA", falling_star.size, (255, 255, 255, 0))
for y in range(falling_star.height):
for x in range(falling_star.width):
pixel = falling_star.getpixel((x, y))
if pixel[3] and pixel[0] == 170:
blink.putpixel((x, y), pixel)
_save_png(blink, os.path.join(
assets_dir, "intro/falling_star_blink.png"))
gamefreak = _symbol(symbols, "GameFreakIntro")
presents_raw = rom.bytes(
gamefreak.bank, gamefreak.address, 104 * 8 // 4)
presents = _decode_2bpp(
presents_raw, 104, 8, transparent_color0=True)
_save_png(
presents, os.path.join(
assets_dir, "intro/gamefreak_presents.png"))
logo_raw = rom.bytes(
gamefreak.bank, gamefreak.address + len(presents_raw),
16 * 24 // 4)
_save_png(
_decode_2bpp(logo_raw, 16, 24, transparent_color0=True),
os.path.join(assets_dir, "intro/gamefreak_logo.png"))
text_image = Image.new("RGBA", (80, 8), (255, 255, 255, 0))
for index, tile in enumerate((0, 1, 2, 3, None, 4, 5, 3, 1, 6)):
if tile is not None:
text_image.paste(
presents.crop((tile * 8, 0, tile * 8 + 8, 8)),
(index * 8, 0))
_save_png(
text_image,
os.path.join(assets_dir, "intro/gamefreak_text.png"))
move_tiles = _symbol(symbols, "MoveAnimationTiles1")
star = Image.new("RGBA", (16, 16), (255, 255, 255, 0))
for row, tile in ((0, 3), (1, 19)):
tile_raw = rom.bytes(
move_tiles.bank, move_tiles.address + tile * 16, 16)
image = _decode_2bpp(
tile_raw, 8, 8, transparent_color0=True)
star.paste(image, (0, row * 8))
star.paste(
image.transpose(Image.Transpose.FLIP_LEFT_RIGHT),
(8, row * 8))
_save_png(star, os.path.join(assets_dir, "intro/big_star.png"))
# Yellow replaces the Gengar/Nidorino fight intro (no FightIntro* symbols).
# Still emit the Red/Blue asset paths so Title/Intro loaders stay happy.
if _has_symbol(symbols, "FightIntroBackMon"):
gengar = _symbol(symbols, "FightIntroBackMon")
gengar_raw = rom.bytes(gengar.bank, gengar.address, 96 * 16)
gengar_tiles = [
_decode_2bpp(gengar_raw[index:index + 16], 8, 8)
for index in range(0, len(gengar_raw), 16)
]
for number in (1, 2, 3):
tilemap = _symbol(symbols, f"GengarIntroTiles{number}")
tile_ids = rom.bytes(tilemap.bank, tilemap.address, 49)
pose = Image.new("RGBA", (56, 56))
for index, tile_id in enumerate(tile_ids):
pose.paste(
gengar_tiles[tile_id],
((index % 7) * 8, (index // 7) * 8))
pose = _matte_color0(pose)
_save_png(
pose, os.path.join(
assets_dir, "intro", f"gengar_{number}.png"))
else:
blank = Image.new("RGBA", (56, 56), (0, 0, 0, 0))
for number in (1, 2, 3):
_save_png(
blank, os.path.join(
assets_dir, "intro", f"gengar_{number}.png"))
if _has_symbol(symbols, "FightIntroFrontMon"):
for number, label in enumerate((
"FightIntroFrontMon", "FightIntroFrontMon2",
"FightIntroFrontMon3"), start=1):
raw_2bpp(
label, 48, 48, f"intro/red_nidorino_{number}.png",
transparent=True, columns=True)
else:
blank = Image.new("RGBA", (48, 48), (255, 255, 255, 0))
for number in (1, 2, 3):
_save_png(
blank, os.path.join(
assets_dir, "intro", f"red_nidorino_{number}.png"))
for number in (1, 2):
_write_compressed_pic(
rom, symbols, f"ShrinkPic{number}",
os.path.join(assets_dir, "intro", f"shrink{number}.png"))
raw_2bpp(
"SlotMachineTiles1", 128, 24, "slots/red_slots_1.png",
stored_length=0x250)
slot_sheet = raw_2bpp(
"SlotMachineTiles2", 32, 48, "slots/red_slots_2.png")
transparent_slots = slot_sheet.copy()
for y in range(transparent_slots.height):
for x in range(transparent_slots.width):
if transparent_slots.getpixel((x, y)) == (255, 255, 255, 255):
transparent_slots.putpixel((x, y), (255, 255, 255, 0))
slot_order = manifest["field"]["slotSymbols"]["order"]
symbol_sheet = Image.new(
"RGBA", (16 * len(slot_order), 16), (255, 255, 255, 0))
for index, name in enumerate(slot_order):
value = manifest["field"]["slotSymbols"]["symbols"][name]["tiles"]
high, low = value >> 8, value & 0xFF
for row, tile in ((0, high), (1, low)):
x = (tile % 4) * 8
y = (tile // 4) * 8
symbol_sheet.paste(
transparent_slots.crop((x, y, x + 16, y + 8)),
(index * 16, row * 8))
_save_png(
symbol_sheet, os.path.join(assets_dir, "slots/symbols.png"))
emotes = Image.new("RGBA", (48, 16), (255, 255, 255, 0))
for index, label in enumerate(
("ShockEmote", "QuestionEmote", "HappyEmote")):
symbol = _symbol(symbols, label)
image = _decode_2bpp(
rom.bytes(symbol.bank, symbol.address, 64), 16, 16,
transparent_color0=True)
emotes.paste(image, (index * 16, 0))
_save_png(emotes, os.path.join(assets_dir, "emotes.png"))
raw_1bpp(
"LedgeHoppingShadow", 8, 8, "fx/shadow.png",
transparent=True)
for label, width, height, filename in (
("RedFishingRodTiles", 8, 24, "fishing_rod.png"),
("RedFishingTilesSide", 16, 8, "red_fish_side.png"),
("RedFishingTilesFront", 16, 8, "red_fish_front.png"),
("RedFishingTilesBack", 16, 8, "red_fish_back.png"),
("PokeCenterFlashingMonitorAndHealBall", 8, 16,
"heal_machine.png"),
("SSAnneSmokePuffTile", 8, 8, "smoke.png")):
raw_2bpp(
label, width, height, "fx/" + filename,
transparent=True)
raw_2bpp(
"BattleTransitionTile", 8, 8, "fx/battle_transition.png")
raw_2bpp(
"PokedexTileGraphics", 24, 48, "fx/pokedex.png")
raw_2bpp(
"HpBarAndStatusGraphics", 120, 16,
"battle/font_battle_extra.png", transparent=True)
for number, label in enumerate(
("BattleHudTiles1", "BattleHudTiles2", "BattleHudTiles3"),
start=1):
raw_1bpp(
label, 24, 8, f"battle/battle_hud_{number}.png",
transparent=True)
the_end = _symbol(symbols, "TheEndGfx")
interleaved = rom.bytes(the_end.bank, the_end.address, 160)
reordered = bytearray(160)
for column in range(5):
reordered[column * 16:(column + 1) * 16] = \
interleaved[column * 32:column * 32 + 16]
reordered[(column + 5) * 16:(column + 6) * 16] = \
interleaved[column * 32 + 16:column * 32 + 32]
_save_png(
_decode_2bpp(bytes(reordered), 40, 16),
os.path.join(assets_dir, "credits/the_end.png"))
if _has_symbol(symbols, "SurfingPikachu1Graphics1"):
raw_2bpp("SurfingPikachu1Graphics1", 40, 104, "minigame/surf_1a.png", transparent=False)
raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True)
raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True)
beach_intro = rom.bytes(62, 0x50bc, 240)
use_ctrl_pad = rom.bytes(62, 0x51ac, 15)
to_surf_rad = rom.bytes(62, 0x51bb, 13)
title_map = rom.bytes(62, 0x51c8, 72)
screen = [0xff] * (20 * 18)
for i in range(240):
screen[6 * 20 + i] = beach_intro[i]
for r in range(6):
for c in range(12):
screen[r * 20 + (4 + c)] = title_map[r * 12 + c]
for r in range(3):
for c in range(15):
screen[(7 + r) * 20 + (3 + c)] = 0xff
for i in range(15):
screen[7 * 20 + 3 + i] = use_ctrl_pad[i]
for i in range(13):
screen[9 * 20 + 4 + i] = to_surf_rad[i]
sym3 = _symbol(symbols, "SurfingPikachu1Graphics3")
raw_gfx3 = rom.bytes(sym3.bank, sym3.address, 144 * 16)
tiles = [_decode_2bpp(raw_gfx3[i*16:(i+1)*16], 8, 8) for i in range(144)]
blank = Image.new("RGBA", (8, 8), (255, 255, 255, 255))
title_bg = Image.new("RGBA", (160, 144), (255, 255, 255, 255))
for r in range(18):
for c in range(20):
t_id = screen[r * 20 + c]
if t_id == 0xff:
tile_img = blank
elif t_id >= 0x80:
idx = t_id - 0x80
tile_img = tiles[idx] if idx < 144 else blank
else:
idx = 128 + t_id
tile_img = tiles[idx] if idx < 144 else blank
title_bg.paste(tile_img, (c * 8, r * 8))
_save_png(title_bg, os.path.join(assets_dir, "minigame/title_bg.png"))
raw_2bpp("WorldMapTileGraphics", 32, 32, "townmap/tiles.png")
raw_1bpp(
"TownMapCursor", 16, 16, "townmap/cursor.png",
transparent=True)
data = copy.deepcopy(manifest["field"])
adjacency = data["hiddenExtras"]["trashCans"]["adjacent"]
data["hiddenExtras"]["trashCans"]["adjacent"] = {
int(index): values for index, values in adjacency.items()
}
data["source"] = "canonical Pokemon Red ROM + bundled port metadata"
util.write_lua(
os.path.join(out_dir, "field.lua"), data,
header="Field integration metadata; all referenced artwork is "
"decoded from ROM")
return data
def build(rom, symbols, manifest, out_dir, assets_dir, datasets):
results = {}
if "constants" in datasets:
results["constants"] = extract_constants(manifest, out_dir)
if "tilesets" in datasets:
results["tilesets"] = extract_tilesets(
rom, symbols, manifest, out_dir, assets_dir)
if "maps" in datasets:
results["maps"] = extract_maps(
rom, symbols, manifest, out_dir)
if "font" in datasets:
results["font"] = extract_font(
rom, symbols, manifest, out_dir, assets_dir)
if "sprites" in datasets:
results["sprites"] = extract_sprites(
rom, symbols, manifest, out_dir, assets_dir)
if "moves" in datasets:
results["moves"] = extract_moves(
rom, symbols, manifest, out_dir)
if "battle_anims" in datasets:
results["battle_anims"] = extract_battle_anims(
rom, symbols, manifest, out_dir, assets_dir)
if "items" in datasets:
results["items"] = extract_items(
rom, symbols, manifest, out_dir)
if "type_chart" in datasets:
results["type_chart"] = extract_type_chart(
rom, symbols, manifest, out_dir)
if "palettes" in datasets:
results["palettes"] = extract_palettes(
rom, symbols, manifest, out_dir)
if "icons" in datasets:
results["icons"] = extract_icons(
rom, symbols, manifest, out_dir, assets_dir)
if "pokemon" in datasets:
results["pokemon"] = extract_pokemon(
rom, symbols, manifest, out_dir, assets_dir)
if "trainers" in datasets:
results["trainers"] = extract_trainers(
rom, symbols, manifest, out_dir, assets_dir)
if "encounters" in datasets:
results["encounters"] = extract_encounters(
rom, symbols, manifest, out_dir)
if "text" in datasets:
results["text"] = extract_text(
rom, symbols, manifest, out_dir)
if "field" in datasets:
results["field"] = extract_field(
rom, symbols, manifest, out_dir, assets_dir)
return results
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--rom", required=True,
help="canonical US Pokemon Red, Blue, or Yellow ROM")
parser.add_argument(
"--version", choices=["auto", *sorted(VERSION_MANIFESTS)], default="auto",
help="select the shipped manifest for this version (default: detect from ROM)")
parser.add_argument(
"--manifest", default=None,
help="explicit manifest path (overrides --version default path; "
"RomImage hash still comes from the file's romSha1)")
parser.add_argument(
"--out", default=None,
help="generated data directory (default: version-specific cache path)")
parser.add_argument(
"--assets", default=None,
help="generated assets directory (default: version-specific cache path)")
parser.add_argument("--clean", action="store_true")
parser.add_argument(
"--only", action="append", choices=DATASETS,
help="build one dataset (repeatable); default builds all implemented")
args = parser.parse_args(argv)
try:
manifest_explicit = args.manifest is not None
requested_version = None if args.version == "auto" else args.version
if manifest_explicit:
manifest_path = resolve_manifest_path(
requested_version or "red", args.manifest)
manifest = load_manifest(manifest_path)
version = version_for_manifest(
manifest, requested_version, manifest_explicit=True)
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
rom = RomImage(args.rom, expected_sha1)
elif requested_version is None:
version, rom = detect_rom_version(args.rom)
manifest_path = resolve_manifest_path(version, None)
manifest = load_manifest(manifest_path)
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
if rom.sha1 != expected_sha1:
raise ValueError(
f"unsupported ROM SHA-1 {rom.sha1}; expected {expected_sha1}")
else:
version = requested_version
manifest_path = resolve_manifest_path(version, None)
manifest = load_manifest(manifest_path)
version = version_for_manifest(manifest, version)
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
rom = RomImage(args.rom, expected_sha1)
symbols = SymbolTable(manifest["symbols"])
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
prefix = "" if version == "red" else version + os.sep
out_dir = args.out or prefix + os.path.join("data", "generated")
assets_dir = args.assets or prefix + os.path.join("assets", "generated")
if args.clean:
kept = clean_generated(out_dir, assets_dir)
for path in kept:
print(f"kept {path} (not produced by this tool)")
os.makedirs(out_dir, exist_ok=True)
os.makedirs(assets_dir, exist_ok=True)
datasets = tuple(args.only) if args.only else DATASETS
try:
build(rom, symbols, manifest, out_dir, assets_dir, datasets)
except (ValueError, KeyError, IndexError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(
f"\ndone: decoded {', '.join(datasets)} from {version} ROM {rom.sha1}")
return 0
if __name__ == "__main__":
sys.exit(main())