mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user