mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
ca4d3d283c
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.
2250 lines
84 KiB
Python
Executable File
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())
|