mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
473 lines
19 KiB
Python
473 lines
19 KiB
Python
"""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
|