initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
+472
View File
@@ -0,0 +1,472 @@
"""Extract composed battle move animations (beams, blobs, projectiles...).
Sources:
data/moves/animations.asm
AttackAnimationPointers: one label per move (id order, NUM_ATTACKS=165).
Each block is battle_anim rows terminated by `db -1`. The battle_anim
macro (defined in the same file) has two forms:
4 args: battle_anim sound_move, subanim_id, tileset_id, frame_delay
-> db (tileset << 6) | delay, sound - 1, subanim
2 args: battle_anim sound_move, special_effect_id (SE_*, >= $C0)
-> db effect, sound - 1
(PlayAnimation in engine/battle/animations.asm:164 dispatches on the
first byte: >= FIRST_SE_ID is a special effect.)
data/battle_anims/subanimations.asm
SubanimationPointers + per subanimation:
db (SUBANIMTYPE_* << 5) | frame_block_count (`subanim` macro)
then count * `db frame_block_id, base_coord_id, frame_block_mode`
(decoded by LoadSubanimation, engine/battle/animations.asm:270.)
data/battle_anims/frame_blocks.asm
FrameBlockPointers + per frame block: db tile_count, then tile_count *
dbsprite x_tile, y_tile, x_px, y_px, tile, attrs -> OAM entry
(y offset, x offset, tile, attrs); macros/gfx.asm:19. Offsets are
relative to the base coordinate; attrs use OAM_XFLIP/OAM_YFLIP/OAM_PRIO.
(drawn by DrawFrameBlock, engine/battle/animations.asm:3.)
data/battle_anims/base_coords.asm
FrameBlockBaseCoords: db y, x pairs in OAM space (screen y+16, x+8).
constants/move_animation_constants.asm
SE_* / SUBANIM_* / FRAMEBLOCK_* / BASECOORD_* / FRAMEBLOCKMODE_* /
SUBANIMTYPE_* values.
engine/battle/animations.asm
MoveAnimationTilesPointers (anim_tileset count, gfx label) + INCBINs
-> which PNG each tileset id (upper 2 bits of the battle_anim first
byte) uses and how many tiles are loaded.
gfx/battle/move_anim_0.png, move_anim_1.png -> tilesheets (16 tiles/row).
Output:
data/generated/battle_anims.lua
assets/generated/battle/anims/move_anim_*.png (color 0 transparent; these
are OAM sprites)
Playback semantics (subanimation types, frame block modes, enemy-turn
mirroring) are implemented in src/battle/AnimPlayer.lua.
"""
import os
import re
from . import gfx, util
from .util import parse_number, read_asm, split_args
NUM_ATTACKS = 165
SUBANIMTYPE_NAMES = [
"NORMAL", "HVFLIP", "HFLIP", "COORDFLIP", "REVERSE", "ENEMY",
]
OAM_FLAGS = {"OAM_XFLIP": 0x20, "OAM_YFLIP": 0x40, "OAM_PRIO": 0x80,
"OAM_PAL0": 0x00, "OAM_PAL1": 0x10}
def parse_anim_constants(pokered):
"""name -> value for every const in constants/move_animation_constants.asm
(multiple const_def blocks; handles const_def N and const_skip N)."""
path = os.path.join(pokered, "constants/move_animation_constants.asm")
values = {}
value = None
for lineno, line in read_asm(path):
s = line.strip()
if not s:
continue
m = re.match(r"const_def(?:\s+(\S+))?$", s)
if m:
value = parse_number(m.group(1)) if m.group(1) else 0
continue
m = re.match(r"const_skip(?:\s+(\S+))?$", s)
if m and value is not None:
value += parse_number(m.group(1)) if m.group(1) else 1
continue
m = re.match(r"const\s+(\w+)$", s)
if m and value is not None:
values[m.group(1)] = value
value += 1
if "SE_SHAKE_SCREEN" not in values or "FRAMEBLOCKMODE_04" not in values:
util.die("move_animation_constants.asm: expected constants not found")
return values
def parse_pointer_table(lines, table_label, path, whole_table=False):
"""Labels of a `dw` pointer table, up to the first assert_table_length
(or, with whole_table, through interior asserts to the table's end --
AttackAnimationPointers continues past NUM_ATTACKS with the ball
toss/poof and status animation entries)."""
labels = []
in_table = False
for lineno, line in lines:
s = line.strip()
if s == table_label + ":":
in_table = True
continue
if in_table:
if s.startswith("assert_table_length"):
if not whole_table:
return labels
continue
m = re.match(r"dw\s+(\w+)$", s)
if m:
labels.append(m.group(1))
continue
if s and not s.startswith("table_width"):
return labels # end of table (whole_table)
util.die(f"{path}: pointer table {table_label} not found/unterminated")
def parse_base_coords(pokered):
path = os.path.join(pokered, "data/battle_anims/base_coords.asm")
coords = []
for lineno, line in read_asm(path):
s = line.strip()
if s.startswith("assert_table_length"):
break
m = re.match(r"db\s+(\S+)\s*,\s*(\S+)$", s)
if m:
coords.append({"y": parse_number(m.group(1)),
"x": parse_number(m.group(2))})
if len(coords) != 0xB1: # BASECOORD_00..BASECOORD_B0
util.die(f"base_coords.asm: expected 177 coords, got {len(coords)}")
return coords
def _parse_attrs(argstr):
flags = 0
for tok in argstr.split("|"):
tok = tok.strip()
if tok in OAM_FLAGS:
flags |= OAM_FLAGS[tok]
else:
flags |= parse_number(tok)
return flags
def parse_frame_blocks(pokered):
"""FrameBlockPointers order -> list of frame blocks; each is a list of
{ y, x, tile, xflip, yflip [, prio] } OAM entries (offsets mod 256)."""
path = os.path.join(pokered, "data/battle_anims/frame_blocks.asm")
lines = read_asm(path)
order = parse_pointer_table(lines, "FrameBlockPointers", path)
bodies = {} # label -> list of entries
counts = {} # label -> declared tile count
cur = None # list currently being filled
cur_labels = [] # labels awaiting their `db count` line
for lineno, line in lines:
s = line.strip()
if not s or s.startswith(("dw ", "table_width", "assert_table_length",
"INCLUDE")):
continue
m = re.match(r"(\w+)::?$", s)
if m:
if m.group(1) in order:
if m.group(1) in bodies:
util.die(f"{path}:{lineno}: duplicate body {m.group(1)}")
cur_labels.append(m.group(1))
else:
cur_labels = [] # FrameBlockBaseCoords etc.
cur = None
continue
m = re.match(r"dbsprite\s+(.*)$", s)
if m:
if cur is None:
continue
a = split_args(m.group(1))
if len(a) != 6:
util.die(f"{path}:{lineno}: dbsprite wants 6 args, got {a}")
attrs = _parse_attrs(a[5])
entry = {
# macros/gfx.asm dbsprite: db (ytile*8)+ypx, (xtile*8)+xpx,
# tile, attrs -- i.e. (y offset, x offset, tile, attrs)
"y": (parse_number(a[1]) * 8 + parse_number(a[3])) & 0xFF,
"x": (parse_number(a[0]) * 8 + parse_number(a[2])) & 0xFF,
"tile": parse_number(a[4]),
"xflip": bool(attrs & OAM_FLAGS["OAM_XFLIP"]),
"yflip": bool(attrs & OAM_FLAGS["OAM_YFLIP"]),
}
if attrs & OAM_FLAGS["OAM_PRIO"]:
entry["prio"] = True
if attrs & OAM_FLAGS["OAM_PAL1"]:
entry["pal1"] = True # drawn with OBP1 ($6c) on the GB
cur.append(entry)
continue
m = re.match(r"db\s+(\S+)$", s)
if m:
if cur_labels:
cur = []
n = parse_number(m.group(1))
for label in cur_labels:
bodies[label] = cur
counts[label] = n
cur_labels = []
# else: trailing `db $00 ; unused` filler -- ignore
continue
blocks = []
for label in order:
if label not in bodies:
util.die(f"{path}: missing body for {label}")
if len(bodies[label]) < counts[label]:
util.die(f"{path}: {label} declares {counts[label]} tiles "
f"but has {len(bodies[label])}")
if len(bodies[label]) > counts[label]:
# FrameBlock62 has 16 dbsprite rows but a count byte of 15; the
# engine only ever draws the declared count.
util.warn(f"frame_blocks.asm: {label} declares {counts[label]} "
f"tiles but has {len(bodies[label])}; truncating")
blocks.append(bodies[label][:counts[label]])
return blocks
def parse_subanimations(pokered, n_frame_blocks, n_base_coords, consts):
"""SubanimationPointers order ->
{ type = SUBANIMTYPE name, blocks = [{ block, coord, mode }, ...] }.
First byte is (SUBANIMTYPE << 5) | count (`subanim` macro,
data/battle_anims/subanimations.asm:97)."""
path = os.path.join(pokered, "data/battle_anims/subanimations.asm")
lines = read_asm(path)
order = parse_pointer_table(lines, "SubanimationPointers", path)
bodies = {}
cur = None
cur_labels = []
for lineno, line in lines:
s = line.strip()
if not s:
continue
m = re.match(r"(\w+)::?$", s)
if m:
if m.group(1) in order:
if m.group(1) in bodies:
util.die(f"{path}:{lineno}: duplicate body {m.group(1)}")
cur_labels.append(m.group(1))
else:
cur_labels = []
cur = None
continue
m = re.match(r"subanim\s+(\w+)\s*,\s*(\S+)$", s)
if m:
if not cur_labels:
continue # the macro definition body itself
if m.group(1) not in consts:
util.die(f"{path}:{lineno}: unknown type {m.group(1)}")
cur = {
"type": SUBANIMTYPE_NAMES[consts[m.group(1)]],
"count": parse_number(m.group(2)),
"blocks": [],
}
for label in cur_labels:
bodies[label] = cur
cur_labels = []
continue
m = re.match(r"db\s+(\w+)\s*,\s*(\w+)\s*,\s*(\w+)$", s)
if m:
if cur is None:
continue
for name in m.groups():
if name not in consts:
util.die(f"{path}:{lineno}: unknown constant {name}")
block, coord, mode = (consts[n] for n in m.groups())
if block >= n_frame_blocks:
util.die(f"{path}:{lineno}: frame block {block} out of range")
if coord >= n_base_coords:
util.die(f"{path}:{lineno}: base coord {coord} out of range")
cur["blocks"].append({"block": block, "coord": coord,
"mode": mode})
continue
subanims = []
for label in order:
if label not in bodies:
util.die(f"{path}: missing body for {label}")
body = bodies[label]
if len(body["blocks"]) != body["count"]:
util.die(f"{path}: {label} declares {body['count']} frame blocks "
f"but has {len(body['blocks'])}")
subanims.append({"type": body["type"], "blocks": body["blocks"]})
return subanims
def parse_move_anims(pokered, move_order, consts, n_subanims):
"""Per move constant: source line + list of rows
{ subanim, tileset, delay [, sound] } or { effect = "SE_*" [, sound] }."""
path = os.path.join(pokered, "data/moves/animations.asm")
lines = read_asm(path)
pointers = parse_pointer_table(lines, "AttackAnimationPointers", path,
whole_table=True)
if len(pointers) < len(move_order):
util.die(f"{path}: {len(pointers)} anim pointers < "
f"{len(move_order)} moves")
anims = {} # label -> (start lineno, list of rows)
cur = None
prev_was_label = False
for lineno, line in lines:
s = line.strip()
if not s:
continue
m = re.match(r"(\w+)::?$", s)
if m:
if not prev_was_label:
cur = (lineno, [])
anims[m.group(1)] = cur # consecutive labels alias one block
prev_was_label = True
continue
prev_was_label = False
m = re.match(r"battle_anim\s+(.*)$", s)
if m and cur is not None:
a = split_args(m.group(1))
if len(a) == 2:
if not a[1].startswith("SE_") or a[1] not in consts:
util.die(f"{path}:{lineno}: unknown special effect {a[1]}")
row = {"effect": a[1]}
elif len(a) == 4:
if a[1] not in consts:
util.die(f"{path}:{lineno}: unknown subanimation {a[1]}")
subanim = consts[a[1]]
if subanim >= n_subanims:
util.die(f"{path}:{lineno}: subanim {subanim} "
f"out of range")
delay = parse_number(a[3])
if not 0 < delay <= 63:
util.die(f"{path}:{lineno}: delay {delay} out of range")
row = {
"subanim": subanim,
"tileset": parse_number(a[2]),
"delay": delay,
}
else:
util.die(f"{path}:{lineno}: battle_anim wants 2 or 4 args")
if a[0] != "NO_MOVE":
row["sound"] = a[0]
cur[1].append(row)
out = {}
for i, move in enumerate(move_order):
label = pointers[i]
if label not in anims:
util.die(f"{path}: missing animation block {label}")
start, rows = anims[label]
out[move] = {
"source": f"data/moves/animations.asm:{start}",
"seq": rows,
}
if len(out) != len(move_order):
util.die(f"{path}: extracted {len(out)} move anims, "
f"expected {len(move_order)}")
return out
def parse_tilesheets(pokered, assets_dir):
"""MoveAnimationTilesPointers (engine/battle/animations.asm) -> per
battle-anim tileset id: converted PNG path + tile count. Tileset ids 0
and 2 share gfx/battle/move_anim_0.png (2 loads only 64 tiles)."""
path = os.path.join(pokered, "engine/battle/animations.asm")
lines = read_asm(path)
rows = [] # (tile count, gfx label) in tileset id order
incbins = {} # gfx label -> source png (relative to pokered)
pending = []
for lineno, line in lines:
s = line.strip()
m = re.match(r"anim_tileset\s+(\S+)\s*,\s*(\w+)$", s)
if m:
rows.append((parse_number(m.group(1)), m.group(2)))
continue
m = re.match(r"(\w+)::?$", s)
if m:
pending.append(m.group(1))
continue
m = re.match(r'INCBIN\s+"([^"]+)"$', s)
if m:
for label in pending:
incbins[label] = re.sub(r"\.2bpp$", ".png", m.group(1))
pending = []
continue
if s:
pending = []
if len(rows) != 3:
util.die(f"{path}: expected 3 anim_tileset rows, got {len(rows)}")
sheets = {}
converted = {}
for tileset_id, (n_tiles, label) in enumerate(rows):
if label not in incbins:
util.die(f"{path}: no INCBIN found for {label}")
src_rel = incbins[label]
base = os.path.basename(src_rel)
if src_rel not in converted:
size = gfx.convert_png(
os.path.join(pokered, src_rel),
os.path.join(assets_dir, "battle", "anims", base),
transparent_color0=True)
converted[src_rel] = size
w, h = converted[src_rel]
sheets[tileset_id] = {
"path": f"assets/generated/battle/anims/{base}",
"width": w,
"height": h,
"tiles": n_tiles,
"source": src_rel,
}
return sheets
# animation ids past the moves (constants/move_constants.asm after
# STRUGGLE): ball tosses, the send-out POOF, status/trade animations
MISC_ANIMS = [
"SHOWPIC_ANIM", "STATUS_AFFECTED_ANIM", "ANIM_A8",
"ENEMY_HUD_SHAKE_ANIM", "TRADE_BALL_DROP_ANIM",
"TRADE_BALL_SHAKE_ANIM", "TRADE_BALL_TILT_ANIM",
"TRADE_BALL_POOF_ANIM", "XSTATITEM_ANIM", "XSTATITEM_DUPLICATE_ANIM",
"SHRINKING_SQUARE_ANIM", "ANIM_B1", "ANIM_B2", "ANIM_B3", "ANIM_B4",
"ANIM_B5", "ANIM_B6", "ANIM_B7", "ANIM_B8", "ANIM_B9",
"BURN_PSN_ANIM", "ANIM_BB", "SLP_PLAYER_ANIM", "SLP_ANIM",
"CONF_PLAYER_ANIM", "CONF_ANIM", "SLIDE_DOWN_ANIM", "TOSS_ANIM",
"SHAKE_ANIM", "POOF_ANIM", "BLOCKBALL_ANIM", "GREATTOSS_ANIM",
"ULTRATOSS_ANIM", "SHAKE_SCREEN_ANIM", "HIDEPIC_ANIM", "ROCK_ANIM",
"BAIT_ANIM",
]
def extract(pokered, out_dir, assets_dir, move_order):
if len(move_order) != NUM_ATTACKS:
util.die(f"battle_anims: expected {NUM_ATTACKS} moves, "
f"got {len(move_order)}")
move_order = list(move_order) + MISC_ANIMS
consts = parse_anim_constants(pokered)
base_coords = parse_base_coords(pokered)
frame_blocks = parse_frame_blocks(pokered)
subanims = parse_subanimations(pokered, len(frame_blocks),
len(base_coords), consts)
move_anims = parse_move_anims(pokered, move_order, consts, len(subanims))
tilesheets = parse_tilesheets(pokered, assets_dir)
# sanity: every referenced tile must fit its sheet
for move, anim in move_anims.items():
for row in anim["seq"]:
if "subanim" not in row:
continue
sheet = tilesheets[row["tileset"]]
for entry in subanims[row["subanim"]]["blocks"]:
for t in frame_blocks[entry["block"]]:
if t["tile"] >= sheet["tiles"]:
util.die(f"{move}: tile {t['tile']} out of range for "
f"tileset {row['tileset']}")
out = {
# indexes are the ROM's 0-based ids throughout
"tilesheets": tilesheets,
"baseCoords": {i: c for i, c in enumerate(base_coords)},
"frameBlocks": {i: b for i, b in enumerate(frame_blocks)},
"subanims": {i: s for i, s in enumerate(subanims)},
"moveAnims": move_anims,
}
util.write_lua(
os.path.join(out_dir, "battle_anims.lua"), out,
header="Sources: data/moves/animations.asm (battle_anim rows),\n"
"data/battle_anims/{subanimations,frame_blocks,base_coords}"
".asm,\n"
"constants/move_animation_constants.asm, "
"engine/battle/animations.asm,\n"
"gfx/battle/move_anim_*.png.\n"
"Coordinates are OAM-space (screen x+8, y+16); offsets and\n"
"flip math are 8-bit like the GB. Playback: "
"src/battle/AnimPlayer.lua.")
return out
+97
View File
@@ -0,0 +1,97 @@
"""Extract constants from pret/pokered.
Sources:
constants/map_constants.asm -> map ids + block dimensions
constants/tileset_constants.asm -> tileset ids
constants/sprite_constants.asm -> overworld sprite ids
constants/pokemon_constants.asm -> internal species order
constants/pokedex_constants.asm -> dex order
constants/move_constants.asm -> move ids
constants/item_constants.asm -> item ids
constants/type_constants.asm -> type ids
constants/hide_show_constants.asm-> toggleable object ids (unused for now)
"""
import os
import re
from . import util
from .util import parse_number, read_asm
def extract_map_constants(pokered):
"""Parse map_const NAME, width, height entries in id order."""
path = os.path.join(pokered, "constants/map_constants.asm")
order, dims = [], {}
value = None
for lineno, line in read_asm(path):
s = line.strip()
if re.match(r"const_def", s):
value = 0
continue
m = re.match(r"map_const\s+(\w+),\s*([\d$%-]+),\s*([\d$%-]+)", s)
if m and value is not None:
name = m.group(1)
order.append(name)
dims[name] = {
"index": value,
"width": parse_number(m.group(2)),
"height": parse_number(m.group(3)),
}
value += 1
if not order or order[0] != "PALLET_TOWN":
util.die("map_constants.asm did not parse as expected")
return order, dims
def extract_simple(pokered, relpath, stop_at=None):
return util.parse_const_block(os.path.join(pokered, relpath), stop_at=stop_at)
def extract_types(pokered):
"""Type constants are physical IDs, a gap, then special IDs at $14."""
path = os.path.join(pokered, "constants/type_constants.asm")
types = {}
value = None
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"const_def(?:\s+(\$?\w+))?$", s)
if m:
value = parse_number(m.group(1)) if m.group(1) else 0
continue
m = re.match(r"const_next\s+(\$?\w+)$", s)
if m:
value = parse_number(m.group(1))
continue
m = re.match(r"const\s+(\w+)", s)
if m and value is not None:
types[m.group(1)] = value
value += 1
if types.get("NORMAL") != 0 or "PSYCHIC_TYPE" not in types:
util.die("type_constants.asm did not parse as expected")
return types
def extract(pokered, out_dir):
map_order, map_dims = extract_map_constants(pokered)
tilesets = [n for n in extract_simple(pokered, "constants/tileset_constants.asm") if n]
sprites = extract_simple(pokered, "constants/sprite_constants.asm")
species = extract_simple(pokered, "constants/pokemon_constants.asm")
moves = extract_simple(pokered, "constants/move_constants.asm", stop_at="NUM_ATTACKS")
types = extract_types(pokered)
# index 0 is the null entry (NO_MON / NO_MOVE / SPRITE_NONE); dropping it
# makes the Lua arrays line up so array index == game id.
data = {
"source": "constants/*.asm",
"mapOrder": map_order,
"maps": map_dims,
"tilesetOrder": tilesets,
"spriteOrder": [n or "UNUSED" for n in sprites[1:]],
"speciesOrder": [n or "UNUSED" for n in species[1:]],
"moveOrder": [n or "UNUSED" for n in moves[1:]],
"types": types,
}
util.write_lua(os.path.join(out_dir, "constants.lua"), data,
header="Source: pret/pokered constants/*.asm")
return data
+88
View File
@@ -0,0 +1,88 @@
"""Extract wild encounter tables.
Sources:
data/wild/grass_water.asm -> WildDataPointers (one entry per map id)
data/wild/maps/*.asm -> def_grass_wildmons rate / db level, species x10
Output: data/generated/encounters.lua (keyed by map constant)
"""
import os
import re
from . import util
from .util import parse_number, read_asm, split_args, warn
def parse_wild_file(path, rel):
grass = {"rate": 0, "slots": []}
water = {"rate": 0, "slots": []}
current = None
label = None
out = {}
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"(\w+):{1,2}\s*$", s)
if m:
label = m.group(1)
grass, water = {"rate": 0, "slots": []}, {"rate": 0, "slots": []}
out[label] = {"grass": grass, "water": water, "source": rel}
continue
m = re.match(r"def_grass_wildmons\s+(\d+)", s)
if m:
grass["rate"] = int(m.group(1))
current = grass
continue
m = re.match(r"def_water_wildmons\s+(\d+)", s)
if m:
water["rate"] = int(m.group(1))
current = water
continue
if s.startswith(("end_grass_wildmons", "end_water_wildmons")):
current = None
continue
m = re.match(r"db\s+(.*)$", s)
if m and current is not None:
a = split_args(m.group(1))
if len(a) == 2:
current["slots"].append({"level": parse_number(a[0]), "species": a[1]})
return out
def extract(pokered, out_dir, map_order):
tables = {}
wild_dir = os.path.join(pokered, "data/wild/maps")
for fname in sorted(os.listdir(wild_dir)):
if fname.endswith(".asm"):
tables.update(parse_wild_file(os.path.join(wild_dir, fname),
f"data/wild/maps/{fname}"))
pointers = []
for lineno, line in read_asm(os.path.join(pokered, "data/wild/grass_water.asm")):
m = re.match(r"dw\s+(\w+)$", line.strip())
if m:
pointers.append(m.group(1))
out = {}
for i, label in enumerate(pointers):
if i >= len(map_order):
break
if label == "NothingWildMons":
continue
t = tables.get(label)
if t is None:
warn(f"grass_water.asm: no wild table {label}")
continue
entry = {"source": t["source"]}
if t["grass"]["rate"] > 0 or t["grass"]["slots"]:
entry["grass"] = t["grass"]
if t["water"]["rate"] > 0 or t["water"]["slots"]:
entry["water"] = t["water"]
out[map_order[i]] = entry
if "ROUTE_1" not in out or out["ROUTE_1"]["grass"]["rate"] != 25:
util.die("encounter extraction sanity check failed (ROUTE_1)")
util.write_lua(os.path.join(out_dir, "encounters.lua"), out,
header="Sources: data/wild/grass_water.asm, data/wild/maps/*.asm\n"
"10 grass slots; slot probabilities live in the engine (Gen 1 buckets).")
return out
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
"""Extract the text font and character map.
Sources:
gfx/font/font.png -> glyphs for codes $80-$FF (16 per row, 8x8)
gfx/font/font_extra.png -> glyphs for codes $60-$7F (border tiles etc.)
constants/charmap.asm -> printable char/token -> glyph code
Output:
assets/generated/fonts/font.png (black ink on transparent)
assets/generated/fonts/font_extra.png
data/generated/font.lua (charmap sorted longest-first)
"""
import os
import re
from PIL import Image
from . import util
from .util import parse_number, read_asm, warn
# Tokens the runtime substitutes rather than draws.
RUNTIME_TOKENS = {"<NULL>", "<PAGE>", "<PKMN>", "<_CONT>", "<SCROLL>", "<NEXT>",
"<LINE>", "@", "<PARA>", "<PLAYER>", "<RIVAL>", "#", "<CONT>",
"<……>", "<DONE>", "<PROMPT>", "<TARGET>", "<USER>", "<PC>",
"<TM>", "<TRAINER>", "<ROCKET>", "<DEXEND>"}
def _ink(src):
"""1bpp/2bpp font PNG -> black ink with transparent background."""
im = Image.open(src).convert("L")
out = Image.new("RGBA", im.size, (0, 0, 0, 0))
sp, dp = im.load(), out.load()
for y in range(im.size[1]):
for x in range(im.size[0]):
if sp[x, y] < 128:
dp[x, y] = (0, 0, 0, 255)
return out
def convert_font(src, dst, patches=None):
"""Convert a font sheet; patches = [(png, src_tile, dst_tile), ...]
overwrite 8x8 tiles with tiles taken from another sheet."""
out = _ink(src)
per_row = out.size[0] // 8
for png, src_tile, dst_tile in patches or []:
pat = _ink(png)
pr = pat.size[0] // 8
sx, sy = (src_tile % pr) * 8, (src_tile // pr) * 8
dx, dy = (dst_tile % per_row) * 8, (dst_tile // per_row) * 8
out.paste(pat.crop((sx, sy, sx + 8, sy + 8)), (dx, dy))
os.makedirs(os.path.dirname(dst), exist_ok=True)
out.save(dst, optimize=True)
return out.size
def parse_charmap(pokered):
"""charmap.asm entries for the main font range ($60-$FF)."""
entries = []
seen = set()
for lineno, line in read_asm(os.path.join(pokered, "constants/charmap.asm")):
m = re.match(r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$\w+)', line.strip())
if not m:
continue
seq = m.group(1).replace('\\"', '"')
code = parse_number(m.group(2))
if seq in seen:
continue # later blocks redefine codes for other gfx files
seen.add(seq)
if seq in RUNTIME_TOKENS:
continue
if 0x60 <= code <= 0xFF:
entries.append({"seq": seq, "code": code})
# ASCII double quote has no charmap.asm entry (the original writes the
# curly “/” glyphs, and the dex height's inch mark is ″); alias it to
# the closing-quote glyph $73 so hand-written port text renders a quote
# instead of a blank + warning.
entries.append({"seq": '"', "code": 0x73})
# longest-first so the renderer can greedily match 'd 'l 's etc.
entries.sort(key=lambda e: (-len(e["seq"]), e["seq"]))
return entries
def extract(pokered, out_dir, assets_dir):
fonts_dir = os.path.join(assets_dir, "fonts")
main = convert_font(os.path.join(pokered, "gfx/font/font.png"),
os.path.join(fonts_dir, "font.png"))
# The dex screen loads /″ over vChars2 tiles $60/$61 (engine/gfx/
# load_pokedex_tiles.asm; charmap.asm maps ->$60 ″->$61 for
# gfx/pokedex/pokedex.png). font_extra.png's own $60/$61 are the
# unused <BOLD_A>/<BOLD_B>, so bake the dex glyphs into those slots.
pokedex_png = os.path.join(pokered, "gfx/pokedex/pokedex.png")
extra = convert_font(os.path.join(pokered, "gfx/font/font_extra.png"),
os.path.join(fonts_dir, "font_extra.png"),
patches=[(pokedex_png, 0, 0x60 - 0x60),
(pokedex_png, 1, 0x61 - 0x60)])
charmap = parse_charmap(pokered)
data = {
"source": "constants/charmap.asm, gfx/font/font.png, gfx/font/font_extra.png",
"image": "assets/generated/fonts/font.png",
"imageExtra": "assets/generated/fonts/font_extra.png",
# font.png holds codes $80..$FF, font_extra.png holds $60..$7F
"mainBase": 0x80,
"extraBase": 0x60,
"glyphsPerRow": main[0] // 8,
"charmap": charmap,
}
util.write_lua(os.path.join(out_dir, "font.lua"), data,
header="Charmap sorted longest-first for greedy matching.")
return data
+795
View File
@@ -0,0 +1,795 @@
"""Graphics conversion for pret/pokered PNGs.
The repo stores Game Boy graphics as 2-bit (or 1-bit) grayscale PNGs where
the *lightest* gray level corresponds to GB color 0 (this matches rgbgfx's
convention). We convert them to RGBA PNGs using the classic DMG green-less
grayscale palette so LÖVE can load them directly.
Transparency modes:
* oam , every GB color 0 pixel becomes alpha 0 (hardware OAM rule;
overworld people, emotes, battle anim sprites).
* matte, only color-0 pixels connected to the image edge become alpha 0.
Interior whites (hat highlights, Articuno's body, etc.) stay
opaque. Use this for BG-style plates that need a clear
background without punching holes in white artwork. This is
the RGBA equivalent of remapping shades into [0,127] and
keeping 255 as a color key.
"""
import os
import re
from collections import deque
from PIL import Image
from . import util
from .util import parse_number, read_asm, split_args
# GB color 0..3 -> RGBA, lightest to darkest.
GB_SHADES = [
(255, 255, 255, 255),
(170, 170, 170, 255),
(85, 85, 85, 255),
(0, 0, 0, 255),
]
def _gray_to_index(v):
"""Map a grayscale byte (0/85/170/255) to a GB color index (0 = lightest).
1-bit sources only use 0/255, which map to 3/0, the same rounding
formula covers both depths.
"""
return 3 - round(v / 85)
def _matte_color0(out):
"""Flood-fill edge-connected opaque white (GB color 0) to alpha 0."""
w, h = out.size
px = out.load()
q = deque()
seen = set()
def is_opaque_white(x, y):
r, g, b, a = px[x, y]
return a == 255 and r == 255 and g == 255 and b == 255
for x in range(w):
for y in (0, h - 1):
if is_opaque_white(x, y):
seen.add((x, y))
q.append((x, y))
for y in range(h):
for x in (0, w - 1):
if (x, y) not in seen and is_opaque_white(x, y):
seen.add((x, y))
q.append((x, y))
while q:
x, y = q.popleft()
px[x, y] = (255, 255, 255, 0)
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen \
and is_opaque_white(nx, ny):
seen.add((nx, ny))
q.append((nx, ny))
return out
def _convert_image(im, transparent_color0=False, transparent_matte=False):
"""Convert a grayscale PIL image to an RGBA image with the GB palette."""
im = im.convert("L")
out = Image.new("RGBA", im.size)
src_px = im.load()
dst_px = out.load()
# Matte needs opaque whites first, then edge flood-fill. OAM clears
# every color-0 pixel up front.
clear_color0 = transparent_color0 and not transparent_matte
for y in range(im.size[1]):
for x in range(im.size[0]):
idx = _gray_to_index(src_px[x, y])
if idx == 0 and clear_color0:
dst_px[x, y] = (255, 255, 255, 0)
else:
dst_px[x, y] = GB_SHADES[idx]
if transparent_matte:
_matte_color0(out)
return out
def _save_png(out, dst):
os.makedirs(os.path.dirname(dst), exist_ok=True)
out.save(dst, optimize=True)
def convert_png(src, dst, transparent_color0=False, transparent_matte=False):
"""Convert a pokered grayscale PNG to an RGBA PNG with the GB palette."""
im = Image.open(src)
_save_png(_convert_image(im, transparent_color0, transparent_matte), dst)
return im.size
# Title screen graphics (drawn by engine/movie/title.asm):
# PokemonLogoGraphics gfx/title/pokemon_logo.png (128x56, 2bpp)
# Version_GFX (Red) gfx/title/red_version.png (80x8, 1bpp)
# PlayerCharacterTitleGraphics gfx/title/player.png (40x56, 2bpp)
# NintendoCopyrightLogoGraphics gfx/splash/copyright.png (152x8, 2bpp)
# The Red front pic on the title screen is NOT a trainer pic (the player is
# not in data/trainers/), so it is converted here from gfx/title/player.png.
# player is OAM in the ROM, but color 0 is also used for hat/vest/shoe
# highlights that read as white against the title's white BG, matte keeps
# those while clearing the surrounding plate.
TITLE_GRAPHICS = [
("logo", "gfx/title/pokemon_logo.png", False),
("version", "gfx/title/red_version.png", False),
("player", "gfx/title/player.png", True),
("copyright", "gfx/splash/copyright.png", False),
# GameFreakLogoGraphics: the "GAME FREAK inc." row of the copyright
# block (tiles $73-$7B, drawn by LoadCopyrightTiles and reused by the
# end credits' CRED_COPYRIGHT screen)
("gamefreakInc", "gfx/title/gamefreak_inc.png", False),
]
def extract_title(pokered, assets_dir):
"""Convert the title-screen graphics to assets/generated/title/.
Returns a manifest dict (key -> {path, width, height, source}); there is
no separate gfx manifest file, so the caller embeds this in field.lua
under the `title` key.
"""
out = {}
for key, src_rel, matte in TITLE_GRAPHICS:
base = os.path.basename(src_rel)
size = convert_png(os.path.join(pokered, src_rel),
os.path.join(assets_dir, "title", base),
transparent_matte=matte)
out[key] = {
"path": f"assets/generated/title/{base}",
"width": size[0],
"height": size[1],
"source": src_rel,
}
return out
# ---------------------------------------------------------------------------
# Boot splash + attract-movie graphics
# (engine/movie/splash.asm + engine/movie/intro.asm)
# ---------------------------------------------------------------------------
def _read_text(pokered, rel):
with open(os.path.join(pokered, rel), encoding="utf-8") as f:
return f.read()
def _parse_oam_block(pokered, label):
"""The dbsprite entries of a labelled OAM block in splash.asm.
dbsprite x, y, xpix, ypix, tile, attrs (macros/gfx.asm) -> the sprite's
top-left lands at screen pixel (x*8 + xpix, y*8 + ypix). Returns
(x, y, tile, attrs-string) tuples.
"""
out = []
in_block = False
for lineno, line in read_asm(os.path.join(pokered, "engine/movie/splash.asm")):
s = line.strip()
if s == f"{label}:":
in_block = True
continue
if in_block:
m = re.match(r"dbsprite\s+(\d+),\s*(\d+),\s*(\d+),\s*(\d+),"
r"\s*(\$\w+),\s*(.*)$", s)
if not m:
break
if m.group(3) != "0" or m.group(4) != "0":
util.die(f"splash.asm:{lineno}: unexpected dbsprite pixel offset")
out.append((int(m.group(1)), int(m.group(2)),
parse_number(m.group(5)), m.group(6).strip()))
if not out:
util.die(f"splash.asm: OAM block {label} not found")
return out
def _rebuild_gengar_poses(pokered):
"""The three Gengar intro poses as 56x56 grayscale images.
gfx/intro/gengar.png (168x56) holds the three poses, but the ROM tile
sheet is built with `rgbgfx --columns` (column-major tile order) and
`tools/gfx --remove-duplicates --preserve=0x19,0x76` (Makefile:176-177),
and each pose is drawn by recomposing that deduplicated sheet through a
gfx/intro/gengar_N.tilemap: 49 bytes = 7x7 row-major tile indices
(tile_ids GengarIntroTiles{1,2,3}, 7, 7 in data/tilemaps.asm), written
to the screen at hlcoord 13,7 by IntroCopyTiles -> CopyTileIDs
(engine/movie/intro.asm:271-276, engine/battle/animations.asm:2284).
Tile 0 is blank (white) and tile 1 is the solid black tile reused for
the intro's letterbox bars (IntroPlaceBlackTiles, intro.asm:227-233).
"""
makefile = _read_text(pokered, "Makefile")
if "gfx/intro/gengar.2bpp: RGBGFXFLAGS += --columns" not in makefile \
or "gfx/intro/gengar.2bpp: tools/gfx += --remove-duplicates " \
"--preserve=0x19,0x76" not in makefile:
util.die("Makefile: gengar.2bpp build flags changed")
tilemaps_asm = _read_text(pokered, "data/tilemaps.asm")
for n in (1, 2, 3):
if not re.search(rf"tile_ids GengarIntroTiles{n},\s*7,\s*7", tilemaps_asm):
util.die(f"tilemaps.asm: GengarIntroTiles{n} is no longer 7x7")
intro_asm = _read_text(pokered, "engine/movie/intro.asm")
if "hlcoord 13, 7" not in intro_asm:
util.die("intro.asm: IntroCopyTiles destination changed")
im = Image.open(os.path.join(pokered, "gfx/intro/gengar.png")).convert("L")
if im.size != (168, 56):
util.die(f"gengar.png: expected 168x56, got {im.size}")
# rgbgfx --columns tile order, then tools/gfx remove_duplicates: a tile
# is dropped if an earlier kept tile is identical, unless its original
# index is in the --preserve list
tiles = [im.crop((tx * 8, ty * 8, tx * 8 + 8, ty * 8 + 8))
for tx in range(21) for ty in range(7)]
kept, seen = [], []
for idx, t in enumerate(tiles):
b = t.tobytes()
if b in seen and idx not in (0x19, 0x76):
continue
kept.append(t)
seen.append(b)
if len(kept) != 95 or set(kept[0].tobytes()) != {255} \
or set(kept[1].tobytes()) != {0}:
util.die(f"gengar.png: deduplicated to {len(kept)} tiles "
"(expected 95 with blank tile 0 / black tile 1)")
poses = []
for n in (1, 2, 3):
with open(os.path.join(pokered, f"gfx/intro/gengar_{n}.tilemap"),
"rb") as f:
tilemap = f.read()
if len(tilemap) != 49 or max(tilemap) >= len(kept):
util.die(f"gengar_{n}.tilemap: not 49 in-range tile ids")
pose = Image.new("L", (56, 56), 255)
for i, tid in enumerate(tilemap):
pose.paste(kept[tid], ((i % 7) * 8, (i // 7) * 8))
# each pose must reproduce its 56x56 slice of the source PNG; the
# only known exception is pose 1's tile (0,1), where the PNG stores
# the solid black bar tile but the tilemap places blank
crop = im.crop(((n - 1) * 56, 0, n * 56, 56))
for ty in range(7):
for tx in range(7):
box = (tx * 8, ty * 8, tx * 8 + 8, ty * 8 + 8)
if pose.crop(box).tobytes() != crop.crop(box).tobytes() \
and not (n == 1 and (tx, ty) == (0, 1)):
util.die(f"gengar pose {n}: tile ({tx},{ty}) does not "
"match the source PNG")
poses.append(pose)
return poses
def extract_intro(pokered, assets_dir):
"""Splash + intro fight graphics -> assets/generated/intro/.
Splash (PlayShootingStar, intro.asm:305-341 + splash.asm):
* falling_star.png: the small-stars OAM tile $A2 (splash.asm:148-150,
237-239) that rains from the logo in 4 waves.
* big_star.png: the big shooting star is NOT falling_star -- it is
two tiles of the battle move animation sheet, MoveAnimationTiles1
tiles 3 and 19 (gfx/battle/move_anim_1.png), left column plus an
X-flipped right column (splash.asm:6-13, 230-235).
* gamefreak_logo.png (16x24) drawn at screen (72,56) and the "GAME
FREAK" letter row at (40,80), both OAM (GameFreakLogoOAMData,
splash.asm:211-228). The letter row reuses tiles of
gamefreak_presents.png; gamefreak_text.png is that row pre-composed
(80x8). The "presents" tiles themselves are unused in the English
release (LoadPresentsGraphic dummied out, intro.asm:359-364).
All splash graphics are OAM sprites -> color 0 transparent.
Fight (PlayIntroScene, intro.asm:23-141):
* gengar_{1,2,3}.png: 56x56 poses rebuilt from gengar.png through the
gengar_{1,2,3}.tilemap files (see _rebuild_gengar_poses). The port
moves each pose as one image, so edge-connected color 0 is matted
like the title-screen Red portrait while interior whites remain.
* red_nidorino_{1,2,3}.png: 48x48 OAM poses -> color 0 transparent.
"""
out_dir = os.path.join(assets_dir, "intro")
def entry(base, size, source):
return {"path": f"assets/generated/intro/{base}",
"width": size[0], "height": size[1], "source": source}
manifest = {}
for key, rel in (("fallingStar", "gfx/splash/falling_star.png"),
("gamefreakLogo", "gfx/splash/gamefreak_logo.png"),
("gamefreakPresents", "gfx/splash/gamefreak_presents.png")):
base = os.path.basename(rel)
size = convert_png(os.path.join(pokered, rel),
os.path.join(out_dir, base), transparent_color0=True)
manifest[key] = entry(base, size, rel)
if manifest["fallingStar"]["width"] != 8 \
or (manifest["gamefreakLogo"]["width"],
manifest["gamefreakLogo"]["height"]) != (16, 24) \
or manifest["gamefreakPresents"]["width"] != 104:
util.die("splash graphics: unexpected sizes")
# falling_star.png holds two small stars: the upper one in GB color 1,
# the lower one in color 2. MoveDownSmallStars toggles OBP1 with
# %10100000 every step (splash.asm:199-203), blanking colors 2/3 so the
# lower star blinks; falling_star_blink.png is that toggled state
# (color >= 2 hidden) for pixel-exact blinking.
star_src = Image.open(
os.path.join(pokered, "gfx/splash/falling_star.png")).convert("L")
blink = Image.new("RGBA", star_src.size, (255, 255, 255, 0))
n_hidden = 0
for y in range(star_src.size[1]):
for x in range(star_src.size[0]):
idx = _gray_to_index(star_src.getpixel((x, y)))
if idx == 1:
blink.putpixel((x, y), GB_SHADES[1])
elif idx >= 2:
n_hidden += 1
if not n_hidden:
util.die("falling_star.png: no color-2 (blinking) star pixels found")
_save_png(blink, os.path.join(out_dir, "falling_star_blink.png"))
manifest["fallingStarBlink"] = entry(
"falling_star_blink.png", blink.size,
"gfx/splash/falling_star.png with OBP1 colors 2/3 blanked "
"(MoveDownSmallStars, engine/movie/splash.asm:199-203)")
# the "GAME FREAK" letter row: OAM entries on grid row 12 place
# gamefreak_presents tiles $80.. plus the blank tile $93 (splash.asm)
oam = _parse_oam_block(pokered, "GameFreakLogoOAMData")
text_row = sorted((x, tile) for x, y, tile, _ in oam if y == 12)
logo_row = sorted((y, x, tile) for x, y, tile, _ in oam if y != 12)
if [t for _, _, t in logo_row] != [0x8D + i for i in range(6)] \
or [(y, x) for y, x, _ in logo_row] != \
[(y, x) for y in (9, 10, 11) for x in (10, 11)]:
util.die("splash.asm: GameFreakLogoOAMData logo arrangement changed")
presents = _convert_image(
Image.open(os.path.join(pokered, "gfx/splash/gamefreak_presents.png")),
transparent_color0=True)
text_img = Image.new("RGBA", (8 * len(text_row), 8), (255, 255, 255, 0))
for i, (x, tile) in enumerate(text_row):
if x != text_row[0][0] + i:
util.die("splash.asm: GAME FREAK letter row not contiguous")
if tile != 0x93: # $93 = the blank tile after the logo tiles
if not 0x80 <= tile <= 0x8C:
util.die(f"splash.asm: letter tile ${tile:02x} out of range")
col = tile - 0x80
text_img.paste(presents.crop((col * 8, 0, col * 8 + 8, 8)),
(i * 8, 0))
_save_png(text_img, os.path.join(out_dir, "gamefreak_text.png"))
manifest["gamefreakText"] = entry(
"gamefreak_text.png", text_img.size,
"gfx/splash/gamefreak_presents.png via GameFreakLogoOAMData "
"(engine/movie/splash.asm:218-227)")
# big shooting star: MoveAnimationTiles1 tiles 3 (top left) and 19
# (bottom left), right column X-flipped (splash.asm:6-13, 230-235)
splash_asm = _read_text(pokered, "engine/movie/splash.asm")
if "MoveAnimationTiles1 tile 3" not in splash_asm \
or "MoveAnimationTiles1 tile 19" not in splash_asm:
util.die("splash.asm: big star tile sources changed")
anim = _convert_image(
Image.open(os.path.join(pokered, "gfx/battle/move_anim_1.png")),
transparent_color0=True)
if anim.size[0] != 128:
util.die(f"move_anim_1.png: expected width 128, got {anim.size}")
star = Image.new("RGBA", (16, 16), (255, 255, 255, 0))
for row, tile in ((0, 3), (1, 19)):
x, y = (tile % 16) * 8, (tile // 16) * 8
quad = anim.crop((x, y, x + 8, y + 8))
star.paste(quad, (0, row * 8))
star.paste(quad.transpose(Image.FLIP_LEFT_RIGHT), (8, row * 8))
_save_png(star, os.path.join(out_dir, "big_star.png"))
manifest["bigStar"] = entry(
"big_star.png", star.size,
"gfx/battle/move_anim_1.png tiles 3/19 via "
"GameFreakShootingStarOAMData (engine/movie/splash.asm:6-13,230-235)")
manifest["gengar"] = {}
for n, pose in enumerate(_rebuild_gengar_poses(pokered), 1):
base = f"gengar_{n}.png"
_save_png(
_convert_image(pose, transparent_matte=True),
os.path.join(out_dir, base))
manifest["gengar"][f"frame{n}"] = entry(
base, pose.size,
f"gfx/intro/gengar.png via gfx/intro/gengar_{n}.tilemap "
"(TILEMAP_GENGAR_INTRO_*, engine/movie/intro.asm)")
manifest["nidorino"] = {}
for n in (1, 2, 3):
rel = f"gfx/intro/red_nidorino_{n}.png"
base = os.path.basename(rel)
size = convert_png(os.path.join(pokered, rel),
os.path.join(out_dir, base), transparent_color0=True)
if size != (48, 48):
util.die(f"{rel}: expected 48x48, got {size}")
manifest["nidorino"][f"frame{n}"] = entry(base, size, rel)
manifest["source"] = (
"gfx/splash/*.png, gfx/intro/*.png + gengar_{1,2,3}.tilemap, "
"gfx/battle/move_anim_1.png, engine/movie/splash.asm, "
"engine/movie/intro.asm (PlayShootingStar, PlayIntroScene)")
return manifest
# ---------------------------------------------------------------------------
# Slot machine wheel symbols
# ---------------------------------------------------------------------------
def extract_slots(pokered, assets_dir):
"""Slot machine graphics and the wheel-symbol crop table.
LoadSlotMachineTiles (engine/slots/slot_machine.asm) copies
SlotMachineTiles2 (gfx/slots/red_slots_2.png, 32x48 = 24 tiles, via
engine/battle/animations.asm) into vChars0, so the spinning wheel
symbols are OAM sprites with tile ids $00-$17. SlotMachine_AnimWheel
draws one byte of the wheel list per 8-pixel row, bottom-up
(wBaseCoordY starts at $58 and shrinks by 8), as two side-by-side
sprites with tiles t and t+1. Each `dw SLOTS*` wheel entry
(constants/script_constants.asm) is therefore one 16x16 symbol: the
LOW byte is its bottom tile pair and the HIGH byte its top tile pair
(SLOTS7 EQU $0200 -> top tiles $02/$03, bottom tiles $00/$01).
In the 32x48 source sheet tile n sits at ((n % 4) * 8, (n // 4) * 8),
so each symbol occupies one full 32x8 strip: the right 16x8 half is
the symbol's top row and the left half its bottom row. We reassemble
the six symbols into contiguous 16x16 crops in symbols.png (color 0
transparent, since the wheels are OAM sprites) and also convert both
raw sheets.
"""
path = os.path.join(pokered, "constants/script_constants.asm")
order = []
consts = {}
for lineno, line in read_asm(path):
m = re.match(r"DEF\s+SLOTS(\w+)\s+EQU\s+(\$\w+)", line.strip())
if m and not m.group(1).startswith("_"):
order.append(m.group(1))
consts[m.group(1)] = parse_number(m.group(2))
if order != ["7", "BAR", "CHERRY", "FISH", "BIRD", "MOUSE"]:
util.die(f"script_constants.asm: unexpected SLOTS* symbols {order}")
engine = "\n".join(l.strip() for _, l in read_asm(
os.path.join(pokered, "engine/slots/slot_machine.asm")))
if not re.search(r"ld hl, SlotMachineTiles2\s+ld de, vChars0", engine) \
or "ld a, $58" not in engine:
util.die("slot_machine.asm: wheel tile loading/drawing code changed")
src = Image.open(os.path.join(pokered, "gfx/slots/red_slots_2.png"))
if src.size != (32, 48):
util.die(f"red_slots_2.png: expected 32x48, got {src.size}")
rgba = _convert_image(src, transparent_color0=True)
def tile_pair(n):
"""16x8 strip for OAM tiles n, n+1."""
x, y = (n % 4) * 8, (n // 4) * 8
return rgba.crop((x, y, x + 16, y + 8))
sheet = Image.new("RGBA", (16 * len(order), 16), (255, 255, 255, 0))
symbols = {}
for i, name in enumerate(order):
value = consts[name]
hi, lo = value >> 8, value & 0xFF
if hi != lo + 2 or lo % 4 != 0 or hi + 1 >= 24:
util.die(f"SLOTS{name} = ${value:04x}: not a 2x2 tile pair in the sheet")
sheet.paste(tile_pair(hi), (i * 16, 0)) # high byte = top row
sheet.paste(tile_pair(lo), (i * 16, 8)) # low byte = bottom row
symbols[name] = {
"sheet": "assets/generated/slots/symbols.png",
"x": i * 16, "y": 0, "w": 16, "h": 16,
"tiles": value, # dw SLOTS* value: high/low = top/bottom tile pair
}
_save_png(sheet, os.path.join(assets_dir, "slots", "symbols.png"))
sheets = {}
for key, rel in (("background", "gfx/slots/red_slots_1.png"),
("wheel", "gfx/slots/red_slots_2.png")):
base = os.path.basename(rel)
size = convert_png(os.path.join(pokered, rel),
os.path.join(assets_dir, "slots", base))
sheets[key] = {"path": f"assets/generated/slots/{base}",
"width": size[0], "height": size[1], "source": rel}
# Static machine background tilemap (SlotMachineMap, INCBIN'd from
# gfx/slots/slots.tilemap by slot_machine.asm and copied to the BG map by
# LoadSlotMachineTiles). It is 20xN tile ids that index the vChars2
# background tiles; LoadSlotMachineTiles fills vChars2 with SlotMachineTiles1
# (red_slots_1.png) first, so every id < $25 is one tile of that sheet. We
# store the grid plus the sheet's tile-atlas stride so the port can blit the
# frame straight from red_slots_1.png.
if not re.search(r'SlotMachineMap:\s*INCBIN "gfx/slots/slots\.tilemap"',
engine):
util.die("slot_machine.asm: SlotMachineMap tilemap include changed")
with open(os.path.join(pokered, "gfx/slots/slots.tilemap"), "rb") as fh:
raw = list(fh.read())
cols = 20 # SCREEN_WIDTH
if not raw or len(raw) % cols != 0:
util.die(f"slots.tilemap: {len(raw)} bytes is not a whole 20-col grid")
rows = len(raw) // cols
bg_tile_cols = sheets["background"]["width"] // 8
bg_tile_count = bg_tile_cols * (sheets["background"]["height"] // 8)
if max(raw) >= 0x25 or max(raw) >= bg_tile_count:
util.die("slots.tilemap: tile id outside red_slots_1 / vChars2 range")
tilemap = {
"cols": cols, "rows": rows,
"sheet": sheets["background"]["path"],
"tileCols": bg_tile_cols, # red_slots_1.png is a tileCols-wide atlas
"tiles": [raw[r * cols:(r + 1) * cols] for r in range(rows)],
"source": "gfx/slots/slots.tilemap (SlotMachineMap)",
}
return {
"sheet": "assets/generated/slots/symbols.png",
"width": 16 * len(order), "height": 16,
"order": order, # constant definition order
"symbols": symbols, # keys match field.lua's slotWheels names
"sheets": sheets,
"tilemap": tilemap, # 20x12 static machine frame (red_slots_1)
"source": "constants/script_constants.asm (SLOTS*), "
"engine/slots/slot_machine.asm (LoadSlotMachineTiles, "
"SlotMachine_AnimWheel), gfx/slots/red_slots_{1,2}.png, "
"gfx/slots/slots.tilemap",
}
# ---------------------------------------------------------------------------
# Oak speech shrink frames
# ---------------------------------------------------------------------------
def extract_oak_speech(pokered, assets_dir):
"""The player-pic shrink frames from the end of the Oak speech.
OakSpeech (engine/movie/oak_speech/oak_speech.asm .next) collapses
RedPicFront through ShrinkPic1 and ShrinkPic2 (gfx/player.asm ->
gfx/player/shrink{1,2}.png, 7x7-tile pics like the trainer pics)
into the overworld walking sprite. Converted like gfx/player/red.png
(the trainer-card front pic): whites matted transparent.
"""
out = {}
for name in ("shrink1", "shrink2"):
size = convert_png(os.path.join(pokered, f"gfx/player/{name}.png"),
os.path.join(assets_dir, "intro", f"{name}.png"),
transparent_matte=True)
if size != (56, 56):
util.die(f"gfx/player/{name}.png: expected 56x56, got {size}")
out[name] = f"assets/generated/intro/{name}.png"
out["source"] = ("gfx/player/shrink{1,2}.png "
"(engine/movie/oak_speech/oak_speech.asm ShrinkPic1/2)")
return out
# ---------------------------------------------------------------------------
# Emotion bubbles
# ---------------------------------------------------------------------------
def extract_emotes(pokered, assets_dir):
"""The overworld emotion bubbles (engine/overworld/emotion_bubbles.asm).
EmotionBubble copies 4 tiles (one 16x16 OAM block) from the entry of
EmotionBubblesPointerTable selected by wWhichEmotionBubble; the indexes
are the *_BUBBLE constants at the top of constants/script_constants.asm
(EXCLAMATION_BUBBLE=0 -> ShockEmote, QUESTION_BUBBLE=1 -> QuestionEmote,
SMILE_BUBBLE=2 -> HappyEmote). The three 16x16 PNGs are packed into
one sheet, color 0 transparent (they are OAM sprites).
"""
consts = util.parse_const_block(
os.path.join(pokered, "constants/script_constants.asm"), stop_at="SLOTS7")
ptr = []
incbins = {}
path = os.path.join(pokered, "engine/overworld/emotion_bubbles.asm")
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"dw\s+(\w+Emote)$", s)
if m:
ptr.append(m.group(1))
continue
m = re.match(r'(\w+Emote):\s*INCBIN\s+"(gfx/emotes/\w+)\.2bpp"', s)
if m:
incbins[m.group(1)] = m.group(2) + ".png"
if consts != ["EXCLAMATION_BUBBLE", "QUESTION_BUBBLE", "SMILE_BUBBLE"] \
or len(ptr) != 3 or set(ptr) != set(incbins):
util.die("emotion bubble constants/pointer table changed")
sheet = Image.new("RGBA", (16 * len(ptr), 16), (255, 255, 255, 0))
bubbles = []
for i, label in enumerate(ptr):
rel = incbins[label]
im = Image.open(os.path.join(pokered, rel))
if im.size != (16, 16):
util.die(f"{rel}: expected 16x16, got {im.size}")
sheet.paste(_convert_image(im, transparent_color0=True), (i * 16, 0))
bubbles.append({"name": consts[i], "x": i * 16, "y": 0, "w": 16, "h": 16,
"source": rel})
_save_png(sheet, os.path.join(assets_dir, "emotes.png"))
return {
"path": "assets/generated/emotes.png",
"width": 16 * len(ptr), "height": 16,
"bubbles": bubbles, # index = *_BUBBLE constant value
"source": "engine/overworld/emotion_bubbles.asm, gfx/emotes/*.png, "
"constants/script_constants.asm (*_BUBBLE)",
}
# ---------------------------------------------------------------------------
# Overworld effect art (gfx/overworld/*.png): the ledge-hop shadow, the
# fishing rod + player-fishing overlays, the Pokémon Center heal
# machine, and the battle-transition tile. The pokedex frame tiles
# ride along (gfx/pokedex/pokedex.png).
# ---------------------------------------------------------------------------
def extract_overworld_fx(pokered, assets_dir):
out = {}
fx = [
("shadow", "gfx/overworld/shadow.png", True),
("fishingRod", "gfx/overworld/fishing_rod.png", True),
("redFishSide", "gfx/overworld/red_fish_side.png", True),
("redFishFront", "gfx/overworld/red_fish_front.png", True),
("redFishBack", "gfx/overworld/red_fish_back.png", True),
# OAM tiles: color 0 is transparent (the ball tile's corners)
("healMachine", "gfx/overworld/heal_machine.png", True),
# one 8x8 tile drawn as a 2x2 block (LoadSmokeTileFourTimes):
# the Cut / boulder-push dust puff
("smoke", "gfx/overworld/smoke.png", True),
("battleTransition", "gfx/overworld/battle_transition.png", False),
("pokedexFrame", "gfx/pokedex/pokedex.png", False),
]
os.makedirs(os.path.join(assets_dir, "fx"), exist_ok=True)
for key, rel, transparent in fx:
base = os.path.splitext(os.path.basename(rel))[0]
dst = os.path.join(assets_dir, "fx", base + ".png")
size = convert_png(os.path.join(pokered, rel), dst,
transparent_color0=transparent)
out[key] = {
"path": f"assets/generated/fx/{base}.png",
"width": size[0], "height": size[1], "source": rel,
}
return out
# ---------------------------------------------------------------------------
# Credits "THE END" graphic
# ---------------------------------------------------------------------------
def extract_the_end(pokered, assets_dir):
"""gfx/credits/the_end.png, drawn by Credits .showTheEnd.
The Makefile builds the_end.2bpp with `tools/gfx --interleave`, which
stores each pair of vertically stacked 8x8 tiles consecutively; the
40x16 PNG is therefore the natural image of five 8x16 letters
T, H, E, N, D left to right, and 2bpp tile $60+2c / $60+2c+1 is the
top/bottom half of PNG column c. TheEndTextString
(engine/movie/credits.asm) lays those columns out as "T H E E N D".
`pattern` lists, per screen column, which 8x16 letter column of the
PNG to draw (-1 = blank).
"""
with open(os.path.join(pokered, "Makefile"), encoding="utf-8") as f:
if not any("the_end.2bpp" in l and "--interleave" in l for l in f):
util.die("Makefile: the_end.2bpp is no longer interleaved")
rows = []
current = None
for lineno, line in read_asm(os.path.join(pokered, "engine/movie/credits.asm")):
s = line.strip()
if s == "TheEndTextString:":
rows = []
current = rows
continue
if current is None:
continue
m = re.match(r"db\s+(.+)$", s)
if not m:
if s:
current = None
continue
row = []
for tok in split_args(m.group(1)):
if tok.startswith('"'):
for ch in tok[1:-1]:
if ch == " ":
row.append(-1)
elif ch != "@":
util.die(f"credits.asm:{lineno}: unexpected char {ch!r} in THE END")
else:
row.append(parse_number(tok))
rows.append(row)
if len(rows) != 2 or len(rows[0]) != len(rows[1]):
util.die("credits.asm: TheEndTextString shape changed")
pattern = []
for top, bottom in zip(rows[0], rows[1]):
if top == -1:
if bottom != -1:
util.die("credits.asm: THE END rows misaligned")
pattern.append(-1)
else:
if bottom != top + 1 or top % 2 != 0 or not 0x60 <= top <= 0x68:
util.die(f"credits.asm: THE END tiles {top:#x}/{bottom:#x} not a column pair")
pattern.append((top - 0x60) // 2)
letters = "THEND"
display = "".join(letters[c] if c >= 0 else " " for c in pattern)
if display != "T H E E N D":
util.die(f"credits.asm: THE END layout changed: {display!r}")
size = convert_png(os.path.join(pokered, "gfx/credits/the_end.png"),
os.path.join(assets_dir, "credits", "the_end.png"))
if size != (40, 16):
util.die(f"the_end.png: expected 40x16, got {size}")
return {
"path": "assets/generated/credits/the_end.png",
"width": size[0], "height": size[1],
"letters": letters, # PNG columns, each 8x16 (x = index * 8)
"letterWidth": 8, "letterHeight": 16,
"pattern": pattern, # screen columns -> PNG letter column (-1 = blank)
"display": display,
"source": "gfx/credits/the_end.png (Makefile --interleave), "
"engine/movie/credits.asm (TheEndTextString, .showTheEnd)",
}
# ---------------------------------------------------------------------------
# In-battle HUD tiles
# ---------------------------------------------------------------------------
# During battles the GB overlays the $62-$7F font area with the HP bar /
# status sheet (home/load_font.asm LoadHpBarAndStatusTilePatterns -> tile
# $62) and the HUD line tiles (engine/battle/core.asm LoadHudTilePatterns:
# battle_hud_1 -> tile $6D, battle_hud_2+3 -> tile $73). Color 0 is
# exported transparent so the underlines can overlap the mon pics.
BATTLE_HUD_GRAPHICS = [
("fontBattleExtra", "gfx/font/font_battle_extra.png", 0x62),
("hud1", "gfx/battle/battle_hud_1.png", 0x6D),
("hud2", "gfx/battle/battle_hud_2.png", 0x73),
("hud3", "gfx/battle/battle_hud_3.png", 0x76),
]
def extract_battle_hud(pokered, assets_dir):
"""Convert the battle HUD tile sheets to assets/generated/battle/."""
out = {}
for key, src_rel, base in BATTLE_HUD_GRAPHICS:
name = os.path.basename(src_rel)
size = convert_png(os.path.join(pokered, src_rel),
os.path.join(assets_dir, "battle", name),
transparent_color0=True)
out[key] = {
"path": f"assets/generated/battle/{name}",
"width": size[0],
"height": size[1],
"tileBase": base,
"source": src_rel,
}
return out
# ---------------------------------------------------------------------------
# Town map background (engine/items/town_map.asm)
# ---------------------------------------------------------------------------
def extract_town_map_bg(pokered, assets_dir):
"""gfx/town_map/town_map.{rle,png}: the 20x18 Kanto map background.
The RLE stream is one byte per run -- high nibble = tile index into
town_map.png, low nibble = run length -- terminated by $00
(LoadTownMap's decompression loop).
"""
with open(os.path.join(pokered, "gfx/town_map/town_map.rle"), "rb") as f:
data = f.read()
tiles = []
for b in data:
if b == 0:
break
tiles.extend([b >> 4] * (b & 0x0F))
if len(tiles) != 20 * 18:
util.die(f"town_map.rle decoded to {len(tiles)} tiles (want 360)")
size = convert_png(os.path.join(pokered, "gfx/town_map/town_map.png"),
os.path.join(assets_dir, "townmap", "tiles.png"))
cursor = convert_png(os.path.join(pokered, "gfx/town_map/town_map_cursor.png"),
os.path.join(assets_dir, "townmap", "cursor.png"),
transparent_color0=True)
return {
"tiles": {"path": "assets/generated/townmap/tiles.png",
"width": size[0], "height": size[1]},
"cursor": {"path": "assets/generated/townmap/cursor.png",
"width": cursor[0], "height": cursor[1]},
"map": tiles,
"source": "gfx/town_map/town_map.rle + town_map.png "
"(engine/items/town_map.asm LoadTownMap)",
}
+86
View File
@@ -0,0 +1,86 @@
"""Extract the party-menu mon icons.
Sources:
data/pokemon/menu_icons.asm MonPartyData: one ICON_* nybble per
species in Pokédex order
gfx/icons/*.png the bug/plant/quadruped/snake icons as
8x32 columns of two 8x16 left halves
(animation frames 1+2); the other icons
reuse overworld sprites
(engine/menus/party_menu.asm)
Output: data/generated/icons.lua (byDex list + icon -> asset paths)
assets/generated/icons/{bug,plant,quadruped,snake}.png
(16x32: two 16x16 frames stacked)
"""
import os
import re
from PIL import Image
from . import gfx, util
# ICON_* -> already-extracted overworld sprite sheets (16x16 frame 0)
SPRITE_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",
}
SHEET_ICONS = ("BUG", "GRASS", "SNAKE", "QUADRUPED")
SHEET_FILES = { "BUG": "bug", "GRASS": "plant", "SNAKE": "snake",
"QUADRUPED": "quadruped" }
def _reassemble_icon(src, dst):
"""8x32 column = two 8x16 LEFT halves (frames 1+2); each frame is
the half plus its X-mirror (AnimatePartyMon swaps tile frames; the
icons are symmetric). Output: 16x32, frames stacked."""
im = Image.open(src).convert("L")
if im.size != (8, 32):
util.die(f"{src}: expected 8x32 icon column, got {im.size}")
out = Image.new("L", (16, 32), 255)
for f in range(2):
half = im.crop((0, f * 16, 8, (f + 1) * 16))
out.paste(half, (0, f * 16))
out.paste(half.transpose(Image.FLIP_LEFT_RIGHT), (8, f * 16))
os.makedirs(os.path.dirname(dst), exist_ok=True)
gfx._save_png(gfx._convert_image(out, transparent_color0=True), dst)
def extract(pokered, out_dir, assets_dir):
by_dex = []
started = False
for lineno, line in util.read_asm(
os.path.join(pokered, "data/pokemon/menu_icons.asm")):
s = line.strip()
if s.startswith("MonPartyData:"):
started = True
continue
m = re.match(r"nybble\s+ICON_(\w+)", s)
if started and m:
by_dex.append(m.group(1))
if len(by_dex) != 151:
util.die(f"menu_icons.asm parsed {len(by_dex)} icons (want 151)")
icons = dict(SPRITE_ICONS)
for name in SHEET_ICONS:
fn = SHEET_FILES[name]
_reassemble_icon(os.path.join(pokered, f"gfx/icons/{fn}.png"),
os.path.join(assets_dir, f"icons/{fn}.png"))
icons[name] = f"assets/generated/icons/{fn}.png"
util.write_lua(os.path.join(out_dir, "icons.lua"),
{"source": "data/pokemon/menu_icons.asm + gfx/icons/ "
"(engine/menus/party_menu.asm icon sprites)",
"byDex": by_dex,
"icons": icons},
header="Party menu icons: ICON name per Pokédex number and\n"
"the image each icon draws from (sprite sheets use\n"
"frame 0; the 16x32 icon sheets stack two real\n"
"animation frames).")
return by_dex
+108
View File
@@ -0,0 +1,108 @@
"""Extract item data, including TM/HM machine items.
Sources:
constants/item_constants.asm -> item ids (const list, 1-based) and
add_tm/add_hm machine definitions
data/items/names.asm -> names (li "..." in id order)
data/items/prices.asm -> bcd3 prices in id order
data/items/tm_prices.asm -> TM prices in thousands (nybbles)
data/items/key_items.asm -> key item bitfield (dbit_env)
Output: data/generated/items.lua
"""
import os
import re
from . import util
from .util import parse_number, read_asm, warn
def parse_machines(pokered):
"""add_hm/add_tm rows: item ids HM_CUT.., TM_MEGA_PUNCH.. -> move."""
hms, tms = [], []
for lineno, line in read_asm(os.path.join(pokered, "constants/item_constants.asm")):
s = line.strip()
m = re.match(r"add_hm\s+(\w+)", s)
if m:
hms.append(m.group(1))
m = re.match(r"add_tm\s+(\w+)", s)
if m:
tms.append(m.group(1))
return hms, tms
def parse_tm_prices(pokered):
prices = []
for lineno, line in read_asm(os.path.join(pokered, "data/items/tm_prices.asm")):
m = re.match(r"nybble\s+(\d+)", line.strip())
if m:
prices.append(int(m.group(1)) * 1000)
return prices
def extract(pokered, out_dir):
consts = util.parse_const_block(os.path.join(pokered, "constants/item_constants.asm"))
consts = [c for c in consts[1:] if c] # drop NO_ITEM slot 0
names = []
for lineno, line in read_asm(os.path.join(pokered, "data/items/names.asm")):
m = re.match(r'li\s+"([^"]*)"', line.strip())
if m:
names.append(m.group(1))
prices = []
for lineno, line in read_asm(os.path.join(pokered, "data/items/prices.asm")):
m = re.match(r"bcd3\s+([\d]+)", line.strip())
if m:
prices.append(int(m.group(1)))
# KeyItemFlags bit array (toss/deposit eligibility uses THIS, not
# price==0: e.g. MOON_STONE has price 0 but is tossable)
key_flags = []
for lineno, line in read_asm(os.path.join(pokered, "data/items/key_items.asm")):
m = re.match(r"dbit\s+(TRUE|FALSE)", line.strip())
if m:
key_flags.append(m.group(1) == "TRUE")
out = {}
for i, const in enumerate(consts):
if i >= len(names):
break # named items only; machines are added below
out[const] = {
"id": const,
"index": i + 1,
"name": names[i].replace("#", "POKé"),
"price": prices[i] if i < len(prices) else 0,
"source": f"data/items/names.asm (entry {i + 1})",
}
if i < len(key_flags) and key_flags[i]:
out[const]["keyItem"] = True
if "POKE_BALL" not in out or out["POKE_BALL"]["price"] != 200:
util.die("item extraction sanity check failed (POKE_BALL price != 200)")
hms, tms = parse_machines(pokered)
tm_prices = parse_tm_prices(pokered)
for n, move in enumerate(hms, start=1):
out["HM_" + move] = {
"id": "HM_" + move,
"name": "HM%02d" % n,
"price": 0,
"machine": {"kind": "HM", "number": n, "move": move},
"source": "constants/item_constants.asm (add_hm)",
}
for n, move in enumerate(tms, start=1):
out["TM_" + move] = {
"id": "TM_" + move,
"name": "TM%02d" % n,
"price": tm_prices[n - 1] if n - 1 < len(tm_prices) else 0,
"machine": {"kind": "TM", "number": n, "move": move},
"source": "constants/item_constants.asm (add_tm)",
}
if len(tms) != 50 or len(hms) != 5:
warn(f"expected 50 TMs / 5 HMs, got {len(tms)}/{len(hms)}")
util.write_lua(os.path.join(out_dir, "items.lua"), out,
header="Sources: constants/item_constants.asm, data/items/names.asm,\n"
"prices.asm, tm_prices.asm")
return out
+237
View File
@@ -0,0 +1,237 @@
"""Extract map data: headers, block layouts, objects, warps, signs.
Sources:
data/maps/headers/<Map>.asm -> map_header (tileset), connection directives
data/maps/objects/<Map>.asm -> border block, warp/bg/object events
maps/<Map>.blk -> width*height block indices
data/maps/names.asm -> display names (town map names, where mapped)
constants/map_constants.asm -> dimensions (parsed by constants.py)
Output: data/generated/maps.lua
Coordinates are in 16x16 "walk grid" cells, matching the macros' arguments.
"""
import os
import re
from . import util
from .util import parse_number, read_asm, split_args, warn
def parse_header(path):
hdr = {"connections": {}}
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"map_header\s+(\w+),\s*(\w+),\s*(\w+)", s)
if m:
hdr["label"] = m.group(1)
hdr["const"] = m.group(2)
hdr["tileset"] = m.group(3)
hdr["line"] = lineno
continue
m = re.match(r"connection\s+(\w+),\s*(\w+),\s*(\w+),\s*(-?[\w$%]+)", s)
if m:
hdr["connections"][m.group(1)] = {
"map": m.group(3),
"offset": parse_number(m.group(4)),
}
return hdr
def parse_objects(path):
"""Parse a data/maps/objects/*.asm file."""
out = {"warps": [], "signs": [], "objects": [], "borderBlock": 0,
"objectNames": []}
obj_index = 0
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"const_export\s+(\w+)$", s)
if m:
out["objectNames"].append(m.group(1))
continue
m = re.match(r"db\s+(\$?[0-9a-fA-F]+)$", s)
if m:
out["borderBlock"] = parse_number(m.group(1))
continue
m = re.match(r"warp_event\s+(.*)$", s)
if m:
a = split_args(m.group(1))
out["warps"].append({
"x": parse_number(a[0]),
"y": parse_number(a[1]),
"destMap": a[2],
"destWarp": parse_number(a[3]),
})
continue
m = re.match(r"bg_event\s+(.*)$", s)
if m:
a = split_args(m.group(1))
out["signs"].append({
"x": parse_number(a[0]),
"y": parse_number(a[1]),
"text": a[2],
})
continue
m = re.match(r"object_event\s+(.*)$", s)
if m:
a = split_args(m.group(1))
obj_index += 1
obj = {
"index": obj_index,
"x": parse_number(a[0]),
"y": parse_number(a[1]),
"sprite": a[2],
"movement": a[3], # STAY / WALK
"range": a[4], # facing (STAY) or roam range (WALK)
"text": a[5],
}
# Extra args: trainers carry (OPP_class, party), static wild
# Pokémon carry (species, level), items carry (item).
if len(a) == 8:
if a[6].startswith("OPP_"):
obj["trainerClass"] = a[6]
obj["trainerParty"] = parse_number(a[7]) if re.match(r"^[\d$%]", a[7]) else a[7]
else:
obj["pokemon"] = a[6]
obj["level"] = parse_number(a[7])
elif len(a) == 7:
obj["item"] = a[6]
out["objects"].append(obj)
continue
return out
def read_blk(path, width, height, border_block):
with open(path, "rb") as f:
raw = f.read()
if len(raw) < width * height:
# e.g. UndergroundPathNorthSouth.blk is 92 bytes for a 4x24 map; the
# original ROM reads past the file into whatever data follows it.
warn(f"{path}: expected {width * height} blocks, got {len(raw)}; padding with border block")
raw = raw + bytes([border_block]) * (width * height - len(raw))
elif len(raw) > width * height:
util.die(f"{path}: expected {width * height} blocks, got {len(raw)}")
return list(raw)
def parse_toggleable_objects(pokered):
"""data/maps/toggleable_objects.asm: initial ON/OFF state per object.
Objects marked OFF exist in the object list but start hidden (e.g. Oak
in his lab, cuttable trees' post-cut states...).
"""
path = os.path.join(pokered, "data/maps/toggleable_objects.asm")
states = {}
current = None
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"toggleable_objects_for\s+(\w+)$", s)
if m:
current = m.group(1)
states[current] = {}
continue
m = re.match(r"toggle_object_state\s+(\w+),\s*(ON|OFF)$", s)
if m and current:
states[current][m.group(1)] = m.group(2)
return states
def parse_blocks_files(pokered):
"""maps.asm: `<Label>_Blocks:` labels (possibly several, shared) -> INCBIN file."""
files = {}
pending = []
for lineno, line in read_asm(os.path.join(pokered, "maps.asm")):
s = line.strip()
m = re.match(r'(?:(\w+)_Blocks:{1,2}\s*)?(?:INCBIN\s+"([^"]+)")?$', s)
if not s or not m:
continue
if m.group(1):
pending.append(m.group(1))
if m.group(2):
for lbl in pending:
files[lbl] = m.group(2)
pending = []
return files
def parse_display_names(pokered):
"""data/maps/names.asm: map display names in constant order via names list."""
path = os.path.join(pokered, "data/maps/names.asm")
names = []
for lineno, line in read_asm(path):
m = re.match(r'db\s+"([^"]*)@?"', line.strip())
if m:
names.append(m.group(1).replace("@", ""))
return names
def extract(pokered, out_dir, map_dims):
headers_dir = os.path.join(pokered, "data/maps/headers")
blocks_files = parse_blocks_files(pokered)
toggles = parse_toggleable_objects(pokered)
out = {}
for fname in sorted(os.listdir(headers_dir)):
if not fname.endswith(".asm"):
continue
hdr = parse_header(os.path.join(headers_dir, fname))
if "const" not in hdr:
warn(f"data/maps/headers/{fname}: no map_header found")
continue
const = hdr["const"]
if const not in map_dims:
warn(f"{fname}: unknown map constant {const}")
continue
dims = map_dims[const]
label = hdr["label"]
# Two header files may declare the same map_header const (the unused
# UndergroundPathRoute7Copy.asm shadows UndergroundPathRoute7.asm);
# keep the file whose label spells the constant -- that is the one
# the ROM's map_header_pointers.asm actually uses.
if const in out:
def spells_const(lbl):
return lbl.upper() == const.replace("_", "")
if spells_const(out[const]["label"]) == spells_const(label):
util.die(f"duplicate map_header const {const}: "
f"{out[const]['label']} vs {label}")
if spells_const(out[const]["label"]):
continue
obj_path = os.path.join(pokered, "data/maps/objects", f"{label}.asm")
objects = parse_objects(obj_path)
blk_rel = blocks_files.get(label, f"maps/{label}.blk")
blocks = read_blk(os.path.join(pokered, blk_rel),
dims["width"], dims["height"], objects["borderBlock"])
# attach export names + initial visibility to object events
names = objects.pop("objectNames")
map_toggles = toggles.get(const, {})
for obj in objects["objects"]:
name = names[obj["index"] - 1] if obj["index"] - 1 < len(names) else None
if name:
obj["name"] = name
if map_toggles.get(name) == "OFF":
obj["hidden"] = True
out[const] = {
"id": const,
"label": label,
"index": dims["index"],
"source": f"data/maps/headers/{label}.asm",
"tileset": hdr["tileset"],
"width": dims["width"],
"height": dims["height"],
"blocks": blocks,
"borderBlock": objects["borderBlock"],
"connections": hdr["connections"],
"warps": objects["warps"],
"signs": objects["signs"],
"objects": objects["objects"],
}
util.write_lua(os.path.join(out_dir, "maps.lua"), out,
header="Sources: data/maps/headers/*.asm, data/maps/objects/*.asm, maps/*.blk\n"
"Coordinates are 16x16 walk-grid cells; width/height are in 32x32 blocks.")
return out
+196
View File
@@ -0,0 +1,196 @@
"""Extract move data.
Sources:
data/moves/moves.asm -> move macro: animation, effect, power, type, acc, pp
data/moves/names.asm -> names in move id order
data/moves/sfx.asm -> MoveSoundTable: db sfx_id, pitch mod, tempo mod
(played by GetMoveSound, engine/battle/animations.asm)
constants/music_constants.asm -> music_const SFX_X, SFX_Label (id -> header label)
data/moves/animations.asm -> AttackAnimationPointers + battle_anim lists
(screen shake / flash special effects)
Output: data/generated/moves.lua
"""
import os
import re
from . import util
from .util import parse_number, read_asm, split_args
def parse_names(pokered):
names = []
for lineno, line in read_asm(os.path.join(pokered, "data/moves/names.asm")):
m = re.match(r'li\s+"([^"]*)"', line.strip())
if m:
names.append(m.group(1))
return names
def parse_sfx_keys(pokered):
"""SFX constant name -> the key used in data/generated/audio.lua's sfx table.
constants/music_constants.asm maps each SFX_* constant to its sound header
label (e.g. `music_const SFX_POUND, SFX_Pound`). The audio extractor keys
its sfx table by header label with the SFX_ prefix and the trailing bank
suffix (_1/_2/_3) stripped (SFX_Pound_1 -> "Pound", SFX_Battle_09 ->
"Battle_09"); apply the same transform here so anim.sound indexes
audio.lua's sfx table directly. The constants' labels carry no bank
suffix, so only a bare _1/_2/_3 is stripped, matching sfx_key() in
tools/extract/audio.py.
"""
path = os.path.join(pokered, "constants/music_constants.asm")
keys = {}
for lineno, line in read_asm(path):
m = re.match(r"music_const\s+(SFX_\w+),\s*(\w+)", line.strip())
if m:
keys[m.group(1)] = re.sub(r"_[123]$", "",
m.group(2).removeprefix("SFX_"))
if not keys:
util.die("music_constants.asm: no music_const SFX entries found")
return keys
def parse_move_sounds(pokered, n_moves):
"""data/moves/sfx.asm MoveSoundTable: per move (id order)
`db SFX_CONST, pitch mod, tempo mod`. Rows after assert_table_length
(the out-of-range fallback entry) are ignored."""
path = os.path.join(pokered, "data/moves/sfx.asm")
rows = []
for lineno, line in read_asm(path):
s = line.strip()
if s.startswith("assert_table_length"):
break
m = re.match(r"db\s+(SFX_\w+)\s*,\s*(\S+)\s*,\s*(\S+)$", s)
if m:
rows.append({
"sfx": m.group(1),
"pitch": parse_number(m.group(2)),
"tempo": parse_number(m.group(3)),
"line": lineno,
})
if len(rows) != n_moves:
util.die(f"sfx.asm MoveSoundTable rows {len(rows)} != moves {n_moves}")
return rows
# battle_anim special effects that imply whole-screen shake / flash
SHAKE_EFFECTS = {"SE_SHAKE_SCREEN"}
FLASH_EFFECTS = {"SE_FLASH_SCREEN_LONG", "SE_DARK_SCREEN_FLASH"}
def parse_anim_effects(pokered, n_moves):
"""Per move (id order), the set of SE_* special effects its animation
uses (data/moves/animations.asm: AttackAnimationPointers -> battle_anim
lists). 2-arg battle_anim lines are special effects; 4-arg lines are
subanimations (constants/move_animation_constants.asm)."""
path = os.path.join(pokered, "data/moves/animations.asm")
lines = read_asm(path)
pointers = []
in_table = False
for lineno, line in lines:
s = line.strip()
if s == "AttackAnimationPointers:":
in_table = True
continue
if in_table:
if s.startswith("assert_table_length"):
break
m = re.match(r"dw\s+(\w+)$", s)
if m:
pointers.append(m.group(1))
if len(pointers) < n_moves:
util.die(f"animations.asm: {len(pointers)} anim pointers < {n_moves} moves")
anims = {} # label -> shared list of battle_anim arg lists
cur = None
prev_was_label = False
for lineno, line in lines:
s = line.strip()
if not s:
continue
m = re.match(r"(\w+)::?$", s)
if m:
if not prev_was_label:
cur = []
anims[m.group(1)] = cur # consecutive labels alias one block
prev_was_label = True
continue
prev_was_label = False
m = re.match(r"battle_anim\s+(.*)$", s)
if m and cur is not None:
cur.append(split_args(m.group(1)))
effects = []
for label in pointers[:n_moves]:
if label not in anims:
util.die(f"animations.asm: missing animation block {label}")
effects.append({a[1] for a in anims[label]
if len(a) == 2 and a[1].startswith("SE_")})
return effects
def extract(pokered, out_dir, move_order):
names = parse_names(pokered)
rows = []
for lineno, line in read_asm(os.path.join(pokered, "data/moves/moves.asm")):
m = re.match(r"move\s+(.*)$", line.strip())
if not m:
continue
a = split_args(m.group(1))
if len(a) != 6:
util.die(f"moves.asm:{lineno}: expected 6 args, got {a}")
rows.append({
"id": a[0],
"effect": a[1],
"power": parse_number(a[2]),
"type": a[3],
"accuracy": parse_number(re.sub(r"\s*percent$", "", a[4])),
"pp": parse_number(a[5]),
"line": lineno,
})
if len(rows) != len(move_order):
util.die(f"moves.asm rows {len(rows)} != move constants {len(move_order)}")
sfx_keys = parse_sfx_keys(pokered)
sounds = parse_move_sounds(pokered, len(rows))
anim_effects = parse_anim_effects(pokered, len(rows))
out = {}
for i, row in enumerate(rows):
const = move_order[i]
if const != row["id"]:
util.warn(f"moves.asm order mismatch at {i}: {const} vs {row['id']}")
snd = sounds[i]
if snd["sfx"] not in sfx_keys:
util.die(f"sfx.asm:{snd['line']}: unknown sfx constant {snd['sfx']}")
anim = {
"sound": sfx_keys[snd["sfx"]],
"pitch": snd["pitch"],
"tempo": snd["tempo"],
}
if anim_effects[i] & SHAKE_EFFECTS:
anim["shake"] = True
if anim_effects[i] & FLASH_EFFECTS:
anim["flash"] = True
out[row["id"]] = {
"id": row["id"],
"index": i + 1,
"name": names[i] if i < len(names) else row["id"],
"source": f"data/moves/moves.asm:{row['line']}",
"effect": row["effect"],
"power": row["power"],
"type": row["type"],
"accuracy": row["accuracy"],
"pp": row["pp"],
"anim": anim,
}
util.write_lua(os.path.join(out_dir, "moves.lua"), out,
header="Sources: data/moves/moves.asm, data/moves/names.asm,\n"
"data/moves/sfx.asm (MoveSoundTable), "
"data/moves/animations.asm.\n"
"anim.sound keys the sfx table in audio.lua; "
"pitch/tempo are the raw GetMoveSound modifiers.")
return out
+93
View File
@@ -0,0 +1,93 @@
"""Extract the Super Game Boy colorization palettes.
Sources:
data/sgb/sgb_palettes.asm SuperPalettes: 4 colors per PAL_* entry,
5-bit RGB (IF DEF(_RED) variants are used;
this is a Red port)
data/pokemon/palettes.asm MonsterPalettes: species -> PAL_* name,
in Pokédex order with species names in the
line comments
Output: data/generated/palettes.lua
palettes[NAME] = { {r,g,b} x4 } with 8-bit components, color 0 first
pokemon[SPECIES] = NAME (PAL_ prefix stripped)
The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR /
PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor
(>= 27 pixels green, >= 10 yellow, else red).
"""
import os
import re
from . import util
def _scale5(v):
"""5-bit (0-31) -> 8-bit color component."""
return round(int(v) * 255 / 31)
def _read_raw(path):
"""Raw lines WITH comments (palette / species names live in them)."""
with open(path, encoding="utf-8") as f:
return list(enumerate((l.rstrip("\n") for l in f), 1))
def extract(pokered, out_dir):
# ---- SuperPalettes ---------------------------------------------------
palettes = {}
order = []
in_blue = False
for lineno, line in _read_raw(os.path.join(pokered, "data/sgb/sgb_palettes.asm")):
s = line.strip()
if s.startswith("IF DEF(_BLUE)"):
in_blue = True
continue
if s.startswith("ENDC") or s.startswith("IF DEF(_RED)"):
in_blue = False
continue
m = re.match(r"RGB\s+([\d,\s]+);\s*PAL_(\w+)", s)
if not m or in_blue:
continue
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
if len(nums) != 12:
util.die(f"sgb_palettes.asm:{lineno}: expected 12 components, got {len(nums)}")
name = m.group(2)
palettes[name] = [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
for i in range(0, 12, 3)]
order.append(name)
# ---- MonsterPalettes -------------------------------------------------
mon_pals = {}
in_table = False
for lineno, line in _read_raw(os.path.join(pokered, "data/pokemon/palettes.asm")):
s = line.strip()
if s.startswith("MonsterPalettes:"):
in_table = True
continue
if not in_table:
continue
m = re.match(r"db\s+PAL_(\w+)\s*;\s*(\w+)", s)
if not m:
continue
pal, species = m.group(1), m.group(2)
if pal not in palettes:
util.die(f"palettes.asm:{lineno}: unknown palette PAL_{pal}")
if species != "MISSINGNO":
mon_pals[species] = pal
if len(mon_pals) != 151:
util.die(f"MonsterPalettes parsed {len(mon_pals)} species (want 151)")
for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR"):
if name not in palettes:
util.die(f"sgb_palettes.asm: PAL_{name} missing")
util.write_lua(os.path.join(out_dir, "palettes.lua"),
{"source": "data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
"palettes": palettes,
"order": order,
"pokemon": mon_pals},
header="SGB colorization: 4 8-bit RGB colors per palette (color 0\n"
"first) and the per-species palette assignment.")
return palettes, mon_pals
+305
View File
@@ -0,0 +1,305 @@
"""Extract Pokémon species data.
Sources:
data/pokemon/base_stats/*.asm -> dex id, base stats, types, catch rate,
base exp, level-1 moves, growth rate, TM/HM
data/pokemon/names.asm -> names in internal order
data/pokemon/evos_moves.asm -> evolutions + level-up learnsets
constants/pokemon_constants.asm -> internal order (via constants.py)
constants/pokedex_constants.asm -> dex order
gfx/pics.asm -> pic label -> PNG file
gfx/pokemon/front|back/*.png -> battle sprites
Output:
data/generated/pokemon.lua
assets/generated/battle/front/*.png, back/*.png
"""
import os
import re
from . import gfx, util
from .util import parse_number, read_asm, split_args, warn
def parse_names(pokered):
names = []
for lineno, line in read_asm(os.path.join(pokered, "data/pokemon/names.asm")):
m = re.match(r'dname\s+"([^"]*)"', line.strip())
if m:
names.append(m.group(1))
return names # internal order, 1-based
def parse_pic_files(pokered):
files = {}
# Mew's pics live in data/pokemon/mew.asm, squeezed into bank 1
for rel in ("gfx/pics.asm", "data/pokemon/mew.asm"):
for lineno, line in read_asm(os.path.join(pokered, rel)):
m = re.match(r'(\w+)::?\s+INCBIN\s+"([^"]+\.pic)"', line.strip())
if m:
files[m.group(1)] = re.sub(r"\.pic$", ".png", m.group(2))
return files
def parse_base_stats_file(path, rel):
"""One data/pokemon/base_stats/<name>.asm file."""
out = {"source": rel}
db_index = 0
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"dw\s+(\w+PicFront),\s*(\w+PicBack)", s)
if m:
out["picFront"], out["picBack"] = m.group(1), m.group(2)
continue
# NO_MOVE/UNUSED are filler tokens, not learnable moves (Mew's
# list ends with UNUSED)
m = re.match(r"tmhm\s+(.*)$", s)
if m:
out.setdefault("tmhm", []).extend(
a.rstrip("\\").strip() for a in split_args(m.group(1))
if a.rstrip("\\").strip() not in ("", "NO_MOVE", "UNUSED"))
out["_in_tmhm"] = True
continue
if out.pop("_in_tmhm", False) and s and not s.startswith(("db", "dw", "INCBIN")):
# tmhm continuation lines (backslash-continued macro args)
out.setdefault("tmhm", []).extend(
a.rstrip("\\").strip() for a in split_args(s)
if a.rstrip("\\").strip() not in ("", "NO_MOVE", "UNUSED"))
out["_in_tmhm"] = True
continue
m = re.match(r"db\s+(.*)$", s)
if not m:
continue
args = split_args(m.group(1))
if db_index == 0:
out["dexConst"] = args[0]
elif db_index == 1:
st = [parse_number(a) for a in args]
out["baseStats"] = {"hp": st[0], "attack": st[1], "defense": st[2],
"speed": st[3], "special": st[4]}
elif db_index == 2:
out["types"] = args if args[0] != args[1] else [args[0]]
elif db_index == 3:
out["catchRate"] = parse_number(args[0])
elif db_index == 4:
out["baseExp"] = parse_number(args[0])
elif db_index == 5:
out["level1Moves"] = [a for a in args if a != "NO_MOVE"]
elif db_index == 6:
out["growthRate"] = args[0].removeprefix("GROWTH_")
db_index += 1
out.pop("_in_tmhm", None)
return out
def parse_dex_entries(pokered, species_order):
"""data/pokemon/dex_entries.asm: kind, height ft/in, weight (0.1 lb),
dex text label -- pointer table is in internal species order."""
lines = read_asm(os.path.join(pokered, "data/pokemon/dex_entries.asm"))
pointer_order = []
bodies = {}
current = None
for lineno, line in lines:
s = line.strip()
m = re.match(r"dw\s+(\w+DexEntry)$", s)
if m and current is None:
pointer_order.append(m.group(1))
continue
m = re.match(r"(\w+DexEntry):{1,2}\s*$", s)
if m:
current = m.group(1)
bodies[current] = {}
continue
if not current:
continue
m = re.match(r'db\s+"([^"@]*)@?"', s)
if m:
bodies[current]["kind"] = m.group(1)
continue
m = re.match(r"db\s+(\d+),\s*(\d+)$", s)
if m:
bodies[current]["heightFt"] = int(m.group(1))
bodies[current]["heightIn"] = int(m.group(2))
continue
m = re.match(r"dw\s+(\d+)$", s)
if m:
bodies[current]["weight"] = int(m.group(1))
continue
m = re.match(r"text_far\s+(\w+)", s)
if m:
bodies[current]["text"] = m.group(1)
out = {}
for i, label in enumerate(pointer_order):
if i < len(species_order):
out[species_order[i]] = bodies.get(label, {})
return out
def parse_evos_moves(pokered, species_order):
"""evos_moves.asm: pointer table in internal order, then labeled bodies."""
path = os.path.join(pokered, "data/pokemon/evos_moves.asm")
lines = read_asm(path)
pointer_order = []
bodies = {}
current = None
for lineno, line in lines:
s = line.strip()
m = re.match(r"dw\s+(\w+EvosMoves)$", s)
if m and current is None:
pointer_order.append(m.group(1))
continue
m = re.match(r"(\w+EvosMoves):{1,2}\s*$", s)
if m:
current = m.group(1)
bodies[current] = []
continue
m = re.match(r"db\s+(.*)$", s)
if m and current:
bodies[current].append([a for a in split_args(m.group(1))])
result = {}
for i, label in enumerate(pointer_order):
if i >= len(species_order):
break
species = species_order[i]
rows = bodies.get(label, [])
evolutions, learnset = [], []
section = 0 # 0 = evolutions, 1 = learnset
for args in rows:
if args == ["0"]:
section += 1
continue
if section == 0:
kind = args[0]
if kind == "EVOLVE_LEVEL":
evolutions.append({"method": "LEVEL", "level": parse_number(args[1]),
"species": args[2]})
elif kind == "EVOLVE_ITEM":
evolutions.append({"method": "ITEM", "item": args[1],
"level": parse_number(args[2]), "species": args[3]})
elif kind == "EVOLVE_TRADE":
evolutions.append({"method": "TRADE", "level": parse_number(args[1]),
"species": args[2]})
else:
warn(f"evos_moves.asm ({label}): unknown evolution row {args}")
elif section == 1:
learnset.append({"level": parse_number(args[0]), "move": args[1]})
result[species] = {"evolutions": evolutions, "learnset": learnset}
return result
def extract(pokered, out_dir, assets_dir, species_order):
names = parse_names(pokered)
pics = parse_pic_files(pokered)
evos = parse_evos_moves(pokered, species_order)
dex_entries = parse_dex_entries(pokered, species_order)
# the tower Ghost battle pic (gfx/pics.asm GhostPic)
# the museum's fossil exhibit pics (DisplayMonFrontSpriteInBox)
# BG-style plates: matte clears the surrounding color-0 field without
# punching holes in white artwork (eyes, Articuno, Red's hat, etc.).
for fossil in ("fossilaerodactyl", "fossilkabutops"):
gfx.convert_png(os.path.join(pokered, f"gfx/pokemon/front/{fossil}.png"),
os.path.join(assets_dir, "battle/front", fossil + ".png"),
transparent_matte=True)
gfx.convert_png(os.path.join(pokered, "gfx/battle/ghost.png"),
os.path.join(assets_dir, "battle/front/ghost.png"),
transparent_matte=True)
# trainer-side battle pics: Red's back (RedPicBack), the old man's
# back (OldManPicBack); party pokeball tiles are OAM-style
gfx.convert_png(os.path.join(pokered, "gfx/player/redb.png"),
os.path.join(assets_dir, "battle/redb.png"),
transparent_matte=True)
gfx.convert_png(os.path.join(pokered, "gfx/battle/oldmanb.png"),
os.path.join(assets_dir, "battle/oldmanb.png"),
transparent_matte=True)
gfx.convert_png(os.path.join(pokered, "gfx/battle/balls.png"),
os.path.join(assets_dir, "battle/balls.png"),
transparent_color0=True)
# trainer card badges, numbered tabs, frame tiles, circle and the
# player's front pic (gfx/trainer_card/ + gfx/player/red.png)
gfx.convert_png(os.path.join(pokered, "gfx/trainer_card/badges.png"),
os.path.join(assets_dir, "trainer_card/badges.png"),
transparent_color0=True)
gfx.convert_png(os.path.join(pokered, "gfx/trainer_card/badge_numbers.png"),
os.path.join(assets_dir, "trainer_card/badge_numbers.png"),
transparent_color0=True)
gfx.convert_png(os.path.join(pokered, "gfx/trainer_card/trainer_info.png"),
os.path.join(assets_dir, "trainer_card/trainer_info.png"))
gfx.convert_png(os.path.join(pokered, "gfx/trainer_card/circle_tile.png"),
os.path.join(assets_dir, "trainer_card/circle_tile.png"),
transparent_color0=True)
gfx.convert_png(os.path.join(pokered, "gfx/player/red.png"),
os.path.join(assets_dir, "trainer_card/red.png"),
transparent_matte=True)
# dex const -> dex number
dex_names = util.parse_const_block(os.path.join(pokered, "constants/pokedex_constants.asm"))
dex_num = {n: i for i, n in enumerate(dex_names) if n}
base_dir = os.path.join(pokered, "data/pokemon/base_stats")
by_dex_const = {}
for fname in sorted(os.listdir(base_dir)):
if not fname.endswith(".asm"):
continue
rel = f"data/pokemon/base_stats/{fname}"
st = parse_base_stats_file(os.path.join(base_dir, fname), rel)
if "dexConst" not in st:
warn(f"{rel}: no dex id found")
continue
by_dex_const[st["dexConst"]] = st
out = {}
for idx, species in enumerate(species_order, start=1):
if species.startswith(("MISSINGNO", "UNUSED", "FOSSIL_", "MON_GHOST")):
continue # glitch/placeholder slots have no base stats
name = names[idx - 1] if idx - 1 < len(names) else species
dex_const = "DEX_" + species
st = by_dex_const.get(dex_const)
if st is None:
warn(f"species {species}: no base stats file for {dex_const}")
continue
front = pics.get(st.get("picFront", ""), "")
back = pics.get(st.get("picBack", ""), "")
front_dst = back_dst = None
if front:
base = os.path.splitext(os.path.basename(front))[0]
front_dst = f"assets/generated/battle/front/{base}.png"
size = gfx.convert_png(os.path.join(pokered, front),
os.path.join(assets_dir, "battle/front", base + ".png"),
transparent_matte=True)
st["frontSize"] = size[0] // 8 # sprite dimension in tiles
if back:
base = os.path.splitext(os.path.basename(back))[0]
back_dst = f"assets/generated/battle/back/{base}.png"
gfx.convert_png(os.path.join(pokered, back),
os.path.join(assets_dir, "battle/back", base + ".png"),
transparent_matte=True)
ev = evos.get(species, {"evolutions": [], "learnset": []})
out[species] = {
"id": species,
"index": idx, # internal id
"dex": dex_num.get(dex_const),
"name": name,
"source": st["source"],
"types": st["types"],
"baseStats": st["baseStats"],
"catchRate": st["catchRate"],
"baseExp": st["baseExp"],
"level1Moves": st.get("level1Moves", []),
"growthRate": st.get("growthRate"),
"tmhm": st.get("tmhm", []),
"learnset": ev["learnset"],
"evolutions": ev["evolutions"],
"spriteFront": front_dst,
"spriteBack": back_dst,
"frontSize": st.get("frontSize"),
"dexEntry": dex_entries.get(species),
}
util.write_lua(os.path.join(out_dir, "pokemon.lua"), out,
header="Sources: data/pokemon/base_stats/*.asm, names.asm, evos_moves.asm")
return out
+89
View File
@@ -0,0 +1,89 @@
"""Extract overworld sprite sheets.
Sources:
data/sprites/sprites.asm -> SpriteSheetPointerTable (sprite id order)
gfx/sprites.asm -> label -> PNG file (INCBIN .2bpp -> .png)
gfx/sprites/*.png -> 16xN 2bpp sheets
A 12-tile sheet is 6 16x16 frames: stand down/up/left, walk down/up/left
(right facing = horizontal flip of left; see data/sprites/facings.asm).
A 4-tile sheet is a single immobile 16x16 frame.
Output:
data/generated/sprites.lua
assets/generated/sprites/<name>.png (GB color 0 -> transparent)
"""
import os
import re
from . import gfx, util
from .util import read_asm, warn
def parse_sprite_files(pokered):
"""gfx/sprites.asm: RedSprite:: INCBIN "gfx/sprites/red.2bpp"."""
files = {}
for lineno, line in read_asm(os.path.join(pokered, "gfx/sprites.asm")):
m = re.match(r'(\w+)::?\s+INCBIN\s+"([^"]+)"', line.strip())
if m:
files[m.group(1)] = m.group(2)
return files
def parse_sheet_table(pokered):
"""data/sprites/sprites.asm: overworld_sprite Label, tilecount entries."""
sheets = []
for lineno, line in read_asm(os.path.join(pokered, "data/sprites/sprites.asm")):
m = re.match(r"overworld_sprite\s+(\w+),\s*(\d+)", line.strip())
if m:
sheets.append((m.group(1), int(m.group(2)), lineno))
return sheets
def extract(pokered, out_dir, assets_dir, sprite_order):
files = parse_sprite_files(pokered)
sheets = parse_sheet_table(pokered)
if len(sheets) != len(sprite_order):
util.die(f"sprite sheet count {len(sheets)} != sprite constant count {len(sprite_order)}")
out = {}
for (label, tiles, lineno), const in zip(sheets, sprite_order):
src = files.get(label)
if not src:
warn(f"sprite {label}: no INCBIN in gfx/sprites.asm")
continue
png_src = os.path.join(pokered, re.sub(r"\.2bpp$", ".png", src))
base = os.path.splitext(os.path.basename(png_src))[0]
dst = os.path.join(assets_dir, "sprites", base + ".png")
size = gfx.convert_png(png_src, dst, transparent_color0=True)
frames = size[1] // 16
out[const] = {
"id": const,
"source": f"data/sprites/sprites.asm:{lineno}",
"image": f"assets/generated/sprites/{base}.png",
"frames": frames, # 6 = walker, 1 = immobile
"walker": frames >= 6,
}
# the cycling sheet isn't a map SPRITE_ constant -- the engine swaps
# it into the player's VRAM slot (LoadPlayerSpriteGraphics); extract
# it under a synthetic id so the port can do the same swap
bike_src = files.get("RedBikeSprite")
if bike_src:
png_src = os.path.join(pokered, re.sub(r"\.2bpp$", ".png", bike_src))
dst = os.path.join(assets_dir, "sprites", "red_bike.png")
size = gfx.convert_png(png_src, dst, transparent_color0=True)
frames = size[1] // 16
out["SPRITE_RED_BIKE"] = {
"id": "SPRITE_RED_BIKE",
"source": "gfx/sprites.asm RedBikeSprite (LoadPlayerSpriteGraphics)",
"image": "assets/generated/sprites/red_bike.png",
"frames": frames,
"walker": frames >= 6,
}
util.write_lua(os.path.join(out_dir, "sprites.lua"), out,
header="Sources: data/sprites/sprites.asm, gfx/sprites/*.png\n"
"Frame order (walker): stand D/U/L, walk D/U/L; right = flipped left.")
return out
+365
View File
@@ -0,0 +1,365 @@
"""Extract dialogue text and the character map.
Sources:
text/*.asm -> _SomeText:: labels with text/line/cont/para macros
constants/charmap.asm -> character -> glyph code mapping (for the font)
scripts/<Map>.asm -> TEXT_* constant -> text label resolution
Text encoding in the generated tables:
\n new line inside a page (`line`)
\v scrolled line (`cont`)
\f new page (`para` / <PAGE>)
{PLAYER} {RIVAL} {TARGET} {USER} ... runtime string tokens
`#` expands to "POKé" and <PKMN>/<PC>/<TM>/... expand per the charmap, so
generated strings contain plain (UTF-8) text renderable by the glyph font.
Unknown text commands emit warnings and a {UNSUPPORTED:...} token so nothing
is silently dropped (docs/extraction-notes.md lists the known ones).
"""
import os
import re
from . import util
from .util import read_asm, warn
# Multi-char charmap sequences that expand to plain text.
EXPANSIONS = {
"#": "POKé",
"<PKMN>": "POKéMON",
"<PC>": "PC",
"<TM>": "TM",
"<TRAINER>": "TRAINER",
"<ROCKET>": "ROCKET",
"<……>": "……",
"<LV>": "{LV}",
"<PLAYER>": "{PLAYER}",
"<RIVAL>": "{RIVAL}",
"<TARGET>": "{TARGET}",
"<USER>": "{USER}",
"<ID>": "{ID}",
"<PARA>": "\f",
"<PAGE>": "\f",
"<LINE>": "\n",
"<CONT>": "\v",
"<NEXT>": "\n",
"<DONE>": "",
"<PROMPT>": "",
"<NULL>": "",
"@": "",
}
# text macro -> separator prepended before its string argument
STRING_MACROS = {
"text": "",
"next": "\n", # not used in red, but harmless
"line": "\n",
"cont": "\v",
"para": "\f",
"page": "\f",
"text_start": "",
}
# Macros that end a text block.
END_MACROS = {"done", "prompt", "text_end", "text_promptbutton",
"text_waitbutton", "dex"}
# Macros we understand but represent as tokens (dynamic content).
DYNAMIC_MACROS = {
"text_ram": "RAM",
"text_decimal": "NUM",
"text_bcd": "NUM",
"text_low": "",
"text_pause": "",
"text_dots": "DOTS",
}
def decode_string(s, lineno, path):
"""Expand charmap sequences inside a quoted asm string."""
out = []
i = 0
while i < len(s):
ch = s[i]
if ch == "<":
end = s.find(">", i)
if end == -1:
warn(f"{path}:{lineno}: unterminated <...> in string")
out.append(ch)
i += 1
continue
tok = s[i:end + 1]
if tok in EXPANSIONS:
out.append(EXPANSIONS[tok])
else:
# single glyph tokens like <BOLD_V>, <COLON>, <ED>
out.append("{" + tok[1:-1] + "}")
i = end + 1
elif ch in EXPANSIONS:
out.append(EXPANSIONS[ch])
i += 1
else:
out.append(ch)
i += 1
return "".join(out)
def parse_text_file(path, texts, rel):
"""Parse one text/*.asm file into texts[label] = string."""
label = None
parts = []
unsupported = set()
skip_vc_branch = False
def flush():
nonlocal label, parts
if label is not None:
texts[label] = {"text": "".join(parts), "source": rel}
label, parts = None, []
for lineno, line in read_asm(path):
s = line.strip()
if not s:
continue
if s.startswith("vc_patch "):
continue
if s.startswith("IF DEF(_RED_VC) || DEF(_BLUE_VC)"):
skip_vc_branch = True
continue
if skip_vc_branch:
if s == "ELSE":
skip_vc_branch = False
continue
if s in ("ENDC", "vc_patch_end"):
continue
m = re.match(r"(_\w+)::?\s*$", s)
if m:
flush()
label = m.group(1)
continue
if label is None:
continue
m = re.match(r"(\w+)(?:\s+(.*))?$", s)
if not m:
continue
macro, rest = m.group(1), (m.group(2) or "").strip()
if macro in STRING_MACROS:
sm = re.match(r'"((?:[^"\\]|\\.)*)"', rest)
if sm:
parts.append(STRING_MACROS[macro] + decode_string(sm.group(1), lineno, rel))
elif rest:
warn(f"{rel}:{lineno}: {macro} without string literal: {rest!r}")
continue
if macro in END_MACROS:
flush()
continue
if macro in DYNAMIC_MACROS:
tokname = DYNAMIC_MACROS[macro]
if tokname:
parts.append("{" + tokname + ":" + rest.replace('"', "") + "}")
continue
if macro in ("text_far", "text_asm"):
# text banks sometimes chain; record a link token
parts.append("{FAR:" + rest + "}" if macro == "text_far" else "{ASM}")
continue
unsupported.add(macro)
parts.append("{UNSUPPORTED:" + macro + "}")
flush()
for macro in sorted(unsupported):
warn(f"{rel}: unsupported text macro '{macro}'")
def parse_marts(pokered):
"""data/items/marts.asm: clerk text label -> script_mart item list."""
marts = {}
label = None
for lineno, line in read_asm(os.path.join(pokered, "data/items/marts.asm")):
s = line.strip()
m = re.match(r"(\w+)::?\s*$", s)
if m:
label = m.group(1)
continue
m = re.match(r"script_mart\s+(.*)$", s)
if m and label:
marts[label] = [a for a in re.split(r",\s*", m.group(1)) if a]
label = None
return marts
def parse_script_text_pointers(pokered):
"""Resolve TEXT_* constants to text labels via scripts/*.asm.
A `dw_const SomeText, TEXT_FOO` entry points at a local label whose body
is usually `text_far _SomeText` + `text_end`. Bodies containing
text_asm are flagged so the runtime knows a hand-ported script owns
them. Special TX_SCRIPT macros are recognized: script_mart item lists
(also resolved from data/items/marts.asm for labels defined there),
script_pokecenter_nurse, script_pokecenter_pc and
script_cable_club_receptionist (engine/link/cable_club_npc.asm).
Returns {map_label: {TEXT_CONST: {text=..., asm=bool, mart=..., ...}}}.
"""
scripts_dir = os.path.join(pokered, "scripts")
marts = parse_marts(pokered)
result = {}
for fname in sorted(os.listdir(scripts_dir)):
if not fname.endswith(".asm"):
continue
map_label = fname[:-4]
path = os.path.join(scripts_dir, fname)
lines = read_asm(path)
pointers = {} # TEXT_CONST -> local label
for lineno, line in lines:
m = re.match(r"dw_const\s+(\w+),\s*(TEXT_\w+)", line.strip())
if m:
pointers[m.group(2)] = m.group(1)
# index label -> line span
label_at = {}
for i, (lineno, line) in enumerate(lines):
m = re.match(r"(\w+):{1,2}\s*$", line.strip())
if m:
label_at[m.group(1)] = i
entries = {}
for const, label in pointers.items():
info = {"label": label}
i = label_at.get(label)
if i is None:
if label in marts:
info["mart"] = marts[label]
else:
info["asm"] = True
else:
j = i + 1
fars = []
is_asm = False
while j < len(lines):
s = lines[j][1].strip()
j += 1
if not s:
continue
if re.match(r"\w+:{1,2}\s*$", s): # next top-level label
break
m = re.match(r"text_far\s+(\w+)", s)
if m:
fars.append(m.group(1))
continue
if s.startswith("text_asm"):
is_asm = True
continue
m = re.match(r"script_mart\s+(.*)$", s)
if m:
info["mart"] = [a for a in re.split(r",\s*", m.group(1)) if a]
continue
if s.startswith("script_pokecenter_nurse"):
info["nurse"] = True
continue
if s.startswith("script_pokecenter_pc"):
info["pc"] = True
continue
if s.startswith("script_cable_club_receptionist"):
# TX_SCRIPT_CABLE_CLUB_RECEPTIONIST -> CableClubNPC
# (home/text_script.asm, engine/link/cable_club_npc.asm)
info["cableClub"] = True
continue
if s.startswith("text_end") or s == "done":
break
if fars:
info["text"] = fars[0]
if is_asm:
info["asm"] = True
entries[const] = info
if entries:
result[map_label] = entries
return result
def parse_trainer_headers(pokered):
"""Extract per-map trainer headers from scripts/*.asm.
`def_trainers N` gives the object index of the first trainer (default
1); each `trainer EVENT, range, BattleText, EndBattleText,
AfterBattleText` row applies to consecutive objects. The three text
labels are local labels resolved through their `text_far` bodies.
Returns {map_label: {objIndex: {event, range, battle, won, after}}}.
"""
scripts_dir = os.path.join(pokered, "scripts")
result = {}
for fname in sorted(os.listdir(scripts_dir)):
if not fname.endswith(".asm"):
continue
map_label = fname[:-4]
lines = read_asm(os.path.join(scripts_dir, fname))
# local label -> first text_far target
far_of = {}
current = None
for lineno, line in lines:
s = line.strip()
m = re.match(r"(\w+):{1,2}\s*$", s)
if m:
current = m.group(1)
continue
m = re.match(r"text_far\s+(\w+)", s)
if m and current and current not in far_of:
far_of[current] = m.group(1)
start = None
headers = {}
idx = 0
for lineno, line in lines:
s = line.strip()
m = re.match(r"def_trainers(?:\s+(\d+))?$", s)
if m:
start = int(m.group(1)) if m.group(1) else 1
idx = 0
continue
m = re.match(r"trainer\s+(EVENT_\w+),\s*(\d+),\s*(\w+),\s*(\w+),\s*(\w+)", s)
if m and start is not None:
obj_index = start + idx
idx += 1
headers[obj_index] = {
"event": m.group(1),
"range": int(m.group(2)),
"battle": far_of.get(m.group(3)),
"won": far_of.get(m.group(4)),
"after": far_of.get(m.group(5)),
"source": f"scripts/{fname}:{lineno}",
}
if headers:
result[map_label] = headers
return result
def extract(pokered, out_dir):
texts = {}
text_dir = os.path.join(pokered, "text")
for fname in sorted(os.listdir(text_dir)):
if fname.endswith(".asm"):
parse_text_file(os.path.join(text_dir, fname), texts, f"text/{fname}")
# engine strings (nurse dialogue, battle messages, ...) live in
# data/text/text_*.asm with the same macro format
data_text_dir = os.path.join(pokered, "data/text")
for fname in sorted(os.listdir(data_text_dir)):
if re.match(r"text_\d+\.asm$", fname):
parse_text_file(os.path.join(data_text_dir, fname), texts,
f"data/text/{fname}")
# Pokédex descriptions
parse_text_file(os.path.join(pokered, "data/pokemon/dex_text.asm"), texts,
"data/pokemon/dex_text.asm")
pointers = parse_script_text_pointers(pokered)
trainer_headers = parse_trainer_headers(pokered)
util.write_lua(os.path.join(out_dir, "text.lua"),
{k: v["text"] for k, v in sorted(texts.items())},
header="Source: pret/pokered text/*.asm")
util.write_lua(os.path.join(out_dir, "text_pointers.lua"), pointers,
header="Source: pret/pokered scripts/*.asm (def_text_pointers tables)\n"
"Entries may carry mart/nurse/pc/cableClub markers from TX_SCRIPT macros.")
util.write_lua(os.path.join(out_dir, "trainer_headers.lua"), trainer_headers,
header="Source: pret/pokered scripts/*.asm (def_trainers tables)\n"
"Keyed by map label, then object index; range is sight distance.")
return texts, pointers
+238
View File
@@ -0,0 +1,238 @@
"""Extract tilesets: headers, blocksets, collision lists, converted graphics.
Sources:
data/tilesets/tileset_headers.asm -> tileset table (gfx, blockset, coll,
counter tiles, grass tile, anim)
data/tilesets/collision_tile_ids.asm -> WALKABLE tile ids per collision set
data/tilesets/warp_tile_ids.asm -> warp-activating tile ids
gfx/tilesets.asm -> label -> file mapping (INCBIN)
gfx/blocksets/*.bst -> 16 bytes per block: 4x4 tile ids
gfx/tilesets/*.png -> 2bpp tile graphics (16 tiles/row)
Output:
data/generated/tilesets.lua
assets/generated/tilesets/<name>.png
"""
import os
import re
from . import gfx, util
from .util import parse_number, read_asm, split_args, warn
def parse_incbin_labels(pokered):
"""Map asm labels like Overworld_Block/Overworld_GFX to repo file paths."""
labels = {}
path = os.path.join(pokered, "gfx/tilesets.asm")
pending = []
for lineno, line in read_asm(path):
m = re.match(r"(\w+)::?\s*$", line.strip())
if m:
pending.append(m.group(1))
continue
m = re.match(r'(?:(\w+)::?\s+)?INCBIN\s+"([^"]+)"', line.strip())
if m:
if m.group(1):
pending.append(m.group(1))
src = m.group(2)
for lbl in pending:
labels[lbl] = src
pending = []
elif line.strip():
pending = []
return labels
def parse_collision(pokered):
"""Parse coll_tiles lists. Multiple labels may share one list."""
path = os.path.join(pokered, "data/tilesets/collision_tile_ids.asm")
colls = {}
pending = []
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"(\w+_Coll)::?\s*$", s)
if m:
pending.append(m.group(1))
continue
m = re.match(r"coll_tiles\s*(.*)$", s)
if m:
tiles = [parse_number(t) for t in split_args(m.group(1)) if t]
for lbl in pending:
colls[lbl] = tiles
pending = []
if "Overworld_Coll" not in colls:
util.die("collision_tile_ids.asm did not parse as expected")
return colls
def _parse_tile_id_lists(path, macro):
"""Parse `.SomeLabel:` groups whose bodies are `<macro> $xx, ...` lines.
Plain `db` lines do not terminate a group (the source uses fallthrough:
e.g. GateWarpTileIDs is `db $3B` falling through into the RedsHouse
list), so a group stays open and keeps collecting until a terminated
`<macro>` line is seen. Returns label -> [tile ids].
"""
groups = [] # (labels, tiles, open)
open_groups = []
last_was_label = False
for lineno, line in read_asm(path):
s = line.strip()
if not s:
continue
m = re.match(r"\.(\w+):?\s*$", s)
if m:
if last_was_label and open_groups and not open_groups[-1][1]:
open_groups[-1][0].append(m.group(1))
else:
g = ([m.group(1)], [], True)
groups.append(g)
open_groups.append(g)
last_was_label = True
continue
last_was_label = False
m = re.match(rf"(?:{macro}|db)\s+(.*)$", s)
if m and open_groups:
tiles = []
for t in split_args(m.group(1)):
try:
v = parse_number(t)
except ValueError:
continue
if v >= 0:
tiles.append(v)
for g in open_groups:
g[1].extend(tiles)
if s.startswith(macro):
open_groups = []
out = {}
for labels, tiles, _ in groups:
for lbl in labels:
out[lbl] = tiles
return out
def parse_warp_tiles(pokered):
"""warp_tile_ids.asm: tile ids that trigger a warp when stood on.
Labels are `.<TilesetName>WarpTileIDs` where TilesetName matches the
tileset_headers.asm macro name (Overworld, RedsHouse1, ...).
"""
path = os.path.join(pokered, "data/tilesets/warp_tile_ids.asm")
raw = _parse_tile_id_lists(path, "warp_tiles")
return {lbl.removesuffix("WarpTileIDs"): tiles for lbl, tiles in raw.items()}
def parse_door_tiles(pokered):
"""door_tile_ids.asm: keyed by tileset CONSTANT via a dbw pointer table."""
path = os.path.join(pokered, "data/tilesets/door_tile_ids.asm")
by_label = _parse_tile_id_lists(path, "door_tiles")
doors = {}
for lineno, line in read_asm(path):
m = re.match(r"dbw\s+(\w+),\s*\.(\w+)", line.strip())
if m:
const, label = m.groups()
if label not in by_label:
warn(f"door_tile_ids.asm:{lineno}: unknown label .{label}")
continue
doors[const] = by_label[label]
return doors
def read_blockset(path):
"""A blockset is a flat list of blocks; each block is 16 tile ids (4x4)."""
with open(path, "rb") as f:
raw = f.read()
if len(raw) % 16 != 0:
util.die(f"blockset {path} size {len(raw)} is not a multiple of 16")
return [list(raw[i:i + 16]) for i in range(0, len(raw), 16)]
TILESET_RE = re.compile(
r"tileset\s+(\w+),\s*([\w$-]+),\s*([\w$-]+),\s*([\w$-]+),\s*([\w$-]+),\s*(\w+)"
)
def extract_flower_frames(pokered, assets_dir):
"""gfx/tilesets/flower/flower{1,2,3}.png: the animated flower tile
frames cycled by home/vcopy.asm's tile animation."""
from . import gfx
for i in (1, 2, 3):
gfx.convert_png(os.path.join(pokered, f"gfx/tilesets/flower/flower{i}.png"),
os.path.join(assets_dir, f"tilesets/flower{i}.png"))
def extract_spinner_tiles(pokered, assets_dir):
"""gfx/overworld/spinners.png (SpinnerArrowAnimTiles): the shared
'blur' graphic engine/overworld/spinners.asm's LoadSpinnerArrowTiles
VRAM-patches over the Gym/Facility spinner-arrow tile IDs while
wMovementFlags.BIT_SPINNING is set (see data/tilesets/spinner_tiles.asm
for the per-tileset dest tile mapping)."""
from . import gfx
gfx.convert_png(os.path.join(pokered, "gfx/overworld/spinners.png"),
os.path.join(assets_dir, "tilesets/spinners.png"))
def extract(pokered, out_dir, assets_dir, tileset_order):
extract_flower_frames(pokered, assets_dir)
extract_spinner_tiles(pokered, assets_dir)
labels = parse_incbin_labels(pokered)
colls = parse_collision(pokered)
doors = parse_door_tiles(pokered)
warps = parse_warp_tiles(pokered)
headers = []
path = os.path.join(pokered, "data/tilesets/tileset_headers.asm")
for lineno, line in read_asm(path):
m = TILESET_RE.match(line.strip())
if m:
headers.append((lineno, m))
if len(headers) != len(tileset_order):
util.die(f"tileset header count {len(headers)} != constant count {len(tileset_order)}")
out = {}
converted = {}
for (lineno, m), const_name in zip(headers, tileset_order):
name = m.group(1) # e.g. Overworld
counters = [parse_number(m.group(i)) for i in (2, 3, 4)]
grass = parse_number(m.group(5))
anim = m.group(6)
gfx_src = labels.get(f"{name}_GFX")
blk_src = labels.get(f"{name}_Block")
if not gfx_src or not blk_src:
util.die(f"tileset {name}: missing INCBIN labels in gfx/tilesets.asm")
png_src = os.path.join(pokered, re.sub(r"\.2bpp$", ".png", gfx_src))
base = os.path.splitext(os.path.basename(png_src))[0]
png_dst = os.path.join(assets_dir, "tilesets", base + ".png")
if png_src not in converted:
size = gfx.convert_png(png_src, png_dst)
converted[png_src] = size
size = converted[png_src]
blocks = read_blockset(os.path.join(pokered, blk_src))
coll_label = f"{name}_Coll"
if coll_label not in colls:
util.die(f"tileset {name}: no collision list {coll_label}")
out[const_name] = {
"id": const_name,
"source": f"data/tilesets/tileset_headers.asm:{lineno}",
"image": f"assets/generated/tilesets/{base}.png",
"imageWidth": size[0],
"imageHeight": size[1],
"tilesPerRow": size[0] // 8,
"blocks": blocks,
"walkable": sorted(colls[coll_label]),
"counterTiles": [c for c in counters if c >= 0],
"grassTile": grass if grass >= 0 else None,
"doorTiles": sorted(doors.get(const_name, [])),
"warpTiles": sorted(set(warps.get(name, []))),
"animation": anim,
}
util.write_lua(os.path.join(out_dir, "tilesets.lua"), out,
header="Sources: data/tilesets/*.asm, gfx/blocksets/*.bst, gfx/tilesets/*.png")
return out
+133
View File
@@ -0,0 +1,133 @@
"""Extract trainer classes and parties.
Sources:
constants/trainer_constants.asm -> trainer class ids (OPP_*)
data/trainers/names.asm -> class display names
data/trainers/parties.asm -> parties per class:
db level, mon, mon, ..., 0 (all same level)
db $FF, lvl, mon, lvl, mon, ..., 0 (mixed levels)
gfx/trainers/*.png -> class battle pics (via gfx/pics.asm)
Output:
data/generated/trainers.lua
assets/generated/battle/trainers/*.png
"""
import os
import re
from . import gfx, util
from .util import parse_number, read_asm, split_args, warn
def parse_trainer_consts(pokered):
path = os.path.join(pokered, "constants/trainer_constants.asm")
names = []
for lineno, line in read_asm(path):
m = re.match(r"trainer_const\s+(\w+)|const\s+(OPP_\w+)", line.strip())
if m:
names.append(m.group(1) or m.group(2))
return names
def parse_parties(pokered):
"""parties.asm: XData: labels with db rows until next label."""
path = os.path.join(pokered, "data/trainers/parties.asm")
order, parties = [], {}
current = None
for lineno, line in read_asm(path):
s = line.strip()
m = re.match(r"(\w+)Data:{1,2}\s*$", s)
if m:
current = m.group(1)
order.append(current)
parties[current] = []
continue
m = re.match(r"db\s+(.*)$", s)
if m and current:
a = split_args(m.group(1))
if a and a[-1] == "0":
a = a[:-1]
if not a:
continue
party = []
if a[0] in ("$FF", "-1"):
it = iter(a[1:])
for lvl, mon in zip(it, it):
party.append({"level": parse_number(lvl), "species": mon})
else:
lvl = parse_number(a[0])
for mon in a[1:]:
party.append({"level": lvl, "species": mon})
parties[current].append(party)
return order, parties
def parse_move_choices(pokered):
"""move_choices.asm: AI modification layers (1/2/3) per class."""
mods = []
for lineno, line in read_asm(os.path.join(pokered, "data/trainers/move_choices.asm")):
m = re.match(r"move_choices(?:\s+(.*))?$", line.strip())
if m is not None and not line.strip().startswith("MACRO"):
args = [int(a) for a in split_args(m.group(1) or "") if a.strip().isdigit()]
mods.append(args)
return mods
def extract(pokered, out_dir, assets_dir):
consts = parse_trainer_consts(pokered)
consts = [c for c in consts if c and c != "NOBODY"]
move_choices = parse_move_choices(pokered)
names = []
for lineno, line in read_asm(os.path.join(pokered, "data/trainers/names.asm")):
m = re.match(r'li\s+"([^"]*)"', line.strip())
if m:
names.append(m.group(1).replace("#", "POKé"))
order, parties = parse_parties(pokered)
# pic labels via gfx/pics.asm: YoungsterPic:: INCBIN "gfx/trainers/youngster.pic"
pics = {}
for lineno, line in read_asm(os.path.join(pokered, "gfx/pics.asm")):
m = re.match(r'(\w+)Pic::?\s+INCBIN\s+"(gfx/trainers/[^"]+)"', line.strip())
if m:
pics[m.group(1)] = re.sub(r"\.pic$", ".png", m.group(2))
# base prize money: reward = baseMoney * level of last enemy mon
money = []
for lineno, line in read_asm(os.path.join(pokered, "data/trainers/pic_pointers_money.asm")):
m = re.match(r"pic_money\s+\w+,\s*(\d+)", line.strip())
if m:
money.append(int(m.group(1)) // 100)
out = {}
for i, label in enumerate(order):
const = "OPP_" + consts[i] if i < len(consts) else None
if const is None:
warn(f"parties.asm: no trainer constant for {label}Data")
continue
pic_src = pics.get(label)
pic_dst = None
if pic_src:
base = os.path.splitext(os.path.basename(pic_src))[0]
pic_dst = f"assets/generated/battle/trainers/{base}.png"
gfx.convert_png(os.path.join(pokered, pic_src),
os.path.join(assets_dir, "battle/trainers", base + ".png"),
transparent_matte=True)
out[const] = {
"id": const,
"index": i + 1,
"name": names[i] if i < len(names) else label,
"source": "data/trainers/parties.asm",
"pic": pic_dst,
"baseMoney": money[i] if i < len(money) else 0,
"aiMods": move_choices[i] if i < len(move_choices) else [],
"parties": parties[label],
}
if "OPP_YOUNGSTER" not in out or not out["OPP_YOUNGSTER"]["parties"]:
util.die("trainer extraction sanity check failed")
util.write_lua(os.path.join(out_dir, "trainers.lua"), out,
header="Sources: data/trainers/parties.asm, names.asm; parties indexed 1-based\n"
"as used by object_event trainer args.")
return out
+46
View File
@@ -0,0 +1,46 @@
"""Extract the Gen 1 type effectiveness chart.
Source: data/types/type_matchups.asm
db attacker, defender, SUPER_EFFECTIVE|NOT_VERY_EFFECTIVE|NO_EFFECT
Also extracts type names from data/types/names.asm.
Output: data/generated/type_chart.lua
Multipliers are stored x10 (20 = 2x, 5 = 0.5x, 0 = immune) like the game.
"""
import os
import re
from . import util
from .util import read_asm, split_args
EFFECT = {"SUPER_EFFECTIVE": 20, "NOT_VERY_EFFECTIVE": 5, "NO_EFFECT": 0}
def extract(pokered, out_dir):
matchups = []
for lineno, line in read_asm(os.path.join(pokered, "data/types/type_matchups.asm")):
m = re.match(r"db\s+(.*)$", line.strip())
if not m:
continue
a = split_args(m.group(1))
if len(a) != 3 or a[0] == "-1":
continue
if a[2] not in EFFECT:
util.warn(f"type_matchups.asm:{lineno}: unknown effectiveness {a[2]}")
continue
matchups.append({"attacker": a[0], "defender": a[1], "multiplier": EFFECT[a[2]]})
if len(matchups) < 50:
util.die(f"type chart parsed only {len(matchups)} rows")
names = []
for lineno, line in read_asm(os.path.join(pokered, "data/types/names.asm")):
m = re.match(r'(?:\.\w+:\s*)?db\s+"([^"@]*)@?"', line.strip())
if m:
names.append(m.group(1))
util.write_lua(os.path.join(out_dir, "type_chart.lua"),
{"source": "data/types/type_matchups.asm",
"matchups": matchups, "names": names},
header="Multipliers are x10: 20 = super effective, 5 = not very, 0 = immune.")
return matchups
+242
View File
@@ -0,0 +1,242 @@
"""Shared helpers for parsing RGBDS assembly from pret/pokered.
Every extractor in this package reads specific known files, parses known
macros/tables, warns on unsupported syntax, and fails loudly on malformed
output. See docs/extraction-notes.md.
"""
import os
import re
import sys
WARNINGS = []
def warn(msg):
WARNINGS.append(msg)
print(f"warning: {msg}", file=sys.stderr)
def die(msg):
print(f"error: {msg}", file=sys.stderr)
raise SystemExit(1)
# We build the Red version: rgbasm-style conditionals resolve with
# _RED defined, so IF DEF(_BLUE) blocks are dropped (the wild data,
# title mons, prizes etc. are version-gated in pokered).
ASM_DEFINES = {"_RED"}
_IF_RE = None
def read_asm(path):
"""Read an asm file as a list of (lineno, text) with comments stripped.
Semicolon comments are removed, but semicolons inside double-quoted
strings are preserved. IF DEF(x)/ELSE/ENDC blocks are resolved
against ASM_DEFINES; unrecognized IF conditions keep their body
(safe for the non-version conditionals we do not model).
"""
import re as _re
lines = []
# stack of (taking, condition_known) for nested IFs
stack = []
with open(path, encoding="utf-8") as f:
for lineno, raw in enumerate(f, 1):
line = strip_comment(raw.rstrip("\n"))
s = line.strip()
m = _re.match(
r"IF\s+DEF\((\w+)\)\s*\|\|\s*DEF\((\w+)\)\s*$",
s, _re.IGNORECASE)
if m:
taking = any(name in ASM_DEFINES for name in m.groups())
stack.append([taking, True])
continue
m = _re.match(r"IF\s+(!)?DEF\((\w+)\)\s*$", s, _re.IGNORECASE)
if m:
defined = m.group(2) in ASM_DEFINES
taking = (not defined) if m.group(1) else defined
stack.append([taking, True])
continue
if _re.match(r"IF\b", s):
stack.append([True, False]) # unmodeled condition: keep body
continue
if _re.match(r"ELSE\s*$", s, _re.IGNORECASE) and stack:
if stack[-1][1]:
stack[-1][0] = not stack[-1][0]
continue
if _re.match(r"ENDC\s*$", s, _re.IGNORECASE) and stack:
stack.pop()
continue
if any(not fr[0] for fr in stack):
continue
lines.append((lineno, line))
return lines
def strip_comment(line):
out = []
in_str = False
for ch in line:
if ch == '"':
in_str = not in_str
elif ch == ";" and not in_str:
break
out.append(ch)
return "".join(out).rstrip()
def parse_number(tok):
"""Parse an RGBDS numeric literal: $hex, %binary, decimal, or -N."""
tok = tok.strip()
neg = tok.startswith("-")
if neg:
tok = tok[1:].strip()
if tok.startswith("$"):
val = int(tok[1:], 16)
elif tok.startswith("%"):
val = int(tok[1:], 2)
elif tok.isdigit():
val = int(tok)
else:
raise ValueError(f"not a number: {tok!r}")
return -val if neg else val
def split_args(argstr):
"""Split macro arguments on commas, respecting double quotes."""
args, cur, in_str = [], [], False
for ch in argstr:
if ch == '"':
in_str = not in_str
cur.append(ch)
elif ch == "," and not in_str:
args.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
tail = "".join(cur).strip()
if tail:
args.append(tail)
return args
def parse_const_block(path, stop_at=None):
"""Parse a file of `const_def` / `const NAME` style constant lists.
Returns an ordered list of names (index = const value, starting at the
most recent const_def base). Only handles the simple linear form used by
e.g. sprite_constants.asm and pokemon_constants.asm.
"""
names = []
value = None
for lineno, line in read_asm(path):
s = line.strip()
if not s:
continue
if stop_at and re.match(rf"DEF\s+{stop_at}\b", s):
break
m = re.match(r"const_def(?:\s+(\d+))?$", s)
if m:
value = int(m.group(1)) if m.group(1) else 0
continue
m = re.match(r"const\s+(\w+)", s)
if m and value is not None:
while len(names) < value:
names.append(None)
names.append(m.group(1))
value += 1
continue
m = re.match(r"const_skip(?:\s+(\d+))?$", s)
if m and value is not None:
n = int(m.group(1)) if m.group(1) else 1
for _ in range(n):
names.append(None)
value += n
return names
# ---------------------------------------------------------------------------
# Lua serialization
# ---------------------------------------------------------------------------
LUA_KEYWORDS = {
"and", "break", "do", "else", "elseif", "end", "false", "for", "function",
"goto", "if", "in", "local", "nil", "not", "or", "repeat", "return",
"then", "true", "until", "while",
}
_IDENT_RE = re.compile(r"^[A-Za-z_]\w*$")
def _lua_key(k):
if isinstance(k, int):
return f"[{k}]"
if _IDENT_RE.match(k) and k not in LUA_KEYWORDS:
return k
return "[" + _lua_str(k) + "]"
def _lua_str(s):
out = s.replace("\\", "\\\\").replace('"', '\\"')
out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
out = out.replace("\f", "\\f").replace("\v", "\\v")
return '"' + out + '"'
def lua_value(v, indent=0, compact_lists=True):
pad = " " * indent
if v is None:
return "nil"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
return repr(v)
if isinstance(v, str):
return _lua_str(v)
if isinstance(v, (list, tuple)):
if not v:
return "{}"
items = [lua_value(x, indent + 1, compact_lists) for x in v]
if compact_lists and all(isinstance(x, (int, float)) for x in v):
# wrap long numeric arrays
lines, cur = [], []
for it in items:
cur.append(it)
if sum(len(c) + 2 for c in cur) > 90:
lines.append(", ".join(cur) + ",")
cur = []
if cur:
lines.append(", ".join(cur) + ",")
if len(lines) == 1 and len(lines[0]) <= 92:
return "{ " + lines[0].rstrip(",") + " }"
body = ("\n" + pad + " ").join(lines)
return "{\n" + pad + " " + body + "\n" + pad + "}"
body = (",\n" + pad + " ").join(items)
return "{\n" + pad + " " + body + ",\n" + pad + "}"
if isinstance(v, dict):
if not v:
return "{}"
keys = sorted(v.keys(), key=lambda k: (isinstance(k, str), k))
parts = []
for k in keys:
parts.append(f"{_lua_key(k)} = {lua_value(v[k], indent + 1, compact_lists)}")
body = (",\n" + pad + " ").join(parts)
return "{\n" + pad + " " + body + ",\n" + pad + "}"
raise TypeError(f"cannot serialize {type(v)}")
def write_lua(path, value, header=None):
"""Write `return <value>` as a Lua module. Deterministic output."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write("-- Generated by tools/build_data.py. DO NOT EDIT.\n")
if header:
for line in header.splitlines():
f.write(f"-- {line}\n")
f.write("return ")
f.write(lua_value(value))
f.write("\n")
print(f"wrote {path}")