mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user