from dataclasses import dataclass from itertools import zip_longest import struct from typing import Dict, List import xml.etree.ElementTree as ET from enum import IntEnum class CMD(IntEnum): WAIT = 0 SET_IMG = 1 GOTO = 2 SET_POS = 3 SET_ROT = 4 SET_SCALE = 5 SET_PAL = 6 LOOP = 7 SET_META = 8 XML_ATTR_NAME = "name" XML_ATTR_INDEX = "index" XML_ATTR_VALUE = "value" XML_ATTR_DURATION = "duration" XML_ATTR_DEST = "dest" XML_ATTR_POS = "pos" XML_ATTR_COUNT = "count" XML_ATTR_MODE = "mode" XML_ATTR_PCT = "percent" XML_ATTR_XYZ = "xyz" XML_ATTR_FLAG = "flag" def iter_in_groups(iterable, n, fillvalue=None): args = [iter(iterable)] * n return zip_longest(*args, fillvalue=fillvalue) def read_offset_list(data: bytes): l = [] for offset in struct.iter_unpack(">i", data): if offset[0] == -1: break l.append(offset[0]) return l class Animation: @property def name(self) -> str: return self.__class__.__name__ def get_attributes(self) -> Dict[str, str]: raise NotImplementedError() @dataclass class Label(Animation): lbl_name: str def get_attributes(self): return { XML_ATTR_NAME: str(self.lbl_name), } @dataclass class Wait(Animation): duration: int def get_attributes(self): return { XML_ATTR_DURATION: str(self.duration), } @dataclass class SetRaster(Animation): raster: int def get_attributes(self): return { XML_ATTR_INDEX: f"{self.raster:X}", } @dataclass class SetPalette(Animation): palette: int def get_attributes(self): return { XML_ATTR_INDEX: f"{self.palette:X}", } @dataclass class Goto(Animation): dest: str pos: int def get_attributes(self): if self.pos != 0: return { XML_ATTR_POS: str(self.pos), } else: return { XML_ATTR_DEST: str(self.dest), } @dataclass class Loop(Animation): count: int dest: str pos: int def get_attributes(self): if self.pos != 0: return { XML_ATTR_COUNT: str(self.count), XML_ATTR_POS: str(self.pos), } else: return { XML_ATTR_COUNT: str(self.count), XML_ATTR_DEST: str(self.dest), } @dataclass class SetPos(Animation): flag: int x: int y: int z: int def get_attributes(self): return { XML_ATTR_FLAG: str(self.flag), XML_ATTR_XYZ: f"{self.x},{self.y},{self.z}", } @dataclass class SetRot(Animation): x: int y: int z: int def get_attributes(self): return { XML_ATTR_XYZ: f"{self.x},{self.y},{self.z}", } SCALE_MODE_INT_TO_STR = { 0: "uniform", 1: "x", 2: "y", 3: "z", } SCALE_MODE_STR_TO_INT = {v: k for k, v in SCALE_MODE_INT_TO_STR.items()} @dataclass class SetScale(Animation): mode: int percent: int def get_mode_str(self): if self.mode in SCALE_MODE_INT_TO_STR: return SCALE_MODE_INT_TO_STR[self.mode] else: raise ValueError(f"invalid scale mode {self.mode}") def get_attributes(self): return { XML_ATTR_MODE: self.get_mode_str(), XML_ATTR_PCT: str(self.percent), } @dataclass class Unknown(Animation): v: int def get_attributes(self): return { XML_ATTR_VALUE: str(self.v), } @dataclass class SetParent(Animation): index: int def get_attributes(self): return { XML_ATTR_INDEX: str(self.index), } @dataclass class SetNotify(Animation): v: int def get_attributes(self): return { XML_ATTR_VALUE: str(self.v), } @dataclass class Keyframe(Animation): pass @dataclass class AnimComponent: x: int y: int z: int commands: List[int] @property def size(self): return len(self.commands) @staticmethod def parse_commands(command_list: List[int]) -> List[Animation]: ret: List[Animation] = [] labels = {} boundaries = [] labels[0] = "Start" i = 0 while i < len(command_list): boundaries.append(i) cmd_start = command_list[i] cmd_op = cmd_start >> 12 cmd_arg = cmd_start & 0xFFF if cmd_op == CMD.GOTO: dest = cmd_arg if dest in boundaries and dest not in labels: labels[dest] = f"Pos_{dest}" elif cmd_op == CMD.SET_POS: i += 3 elif cmd_op == CMD.SET_ROT: i += 2 elif cmd_op == CMD.SET_SCALE: i += 1 elif cmd_op == CMD.LOOP: dest = command_list[i + 1] if dest in boundaries and dest not in labels: labels[dest] = f"Pos_{dest}" i += 1 i += 1 def to_signed(value): return -(value & 0x8000) | (value & 0x7FFF) i = 0 while i < len(command_list): cmd_start = command_list[i] cmd_op = cmd_start >> 12 cmd_arg = cmd_start & 0xFFF if i in labels: ret.append(Label(labels[i])) if cmd_op == CMD.WAIT: ret.append(Wait(cmd_start)) elif cmd_op == CMD.SET_IMG: raster = cmd_arg if raster == 0xFFF: raster = -1 ret.append(SetRaster(raster)) elif cmd_op == CMD.GOTO: dest = cmd_arg if dest in labels: lbl_name = labels[dest] ret.append(Goto(lbl_name, 0)) else: ret.append(Goto(None, dest)) elif cmd_op == CMD.SET_POS: flag = cmd_arg x, y, z = command_list[i + 1 : i + 4] x = to_signed(x) y = to_signed(y) z = to_signed(z) i += 3 ret.append(SetPos(flag, x, y, z)) elif cmd_op == CMD.SET_ROT: x, y, z = command_list[i : i + 3] x = (cmd_arg << 20) >> 20 y = to_signed(y) z = to_signed(z) i += 2 ret.append(SetRot(x, y, z)) elif cmd_op == CMD.SET_SCALE: mode = cmd_arg percent = command_list[i + 1] i += 1 ret.append(SetScale(mode, percent)) elif cmd_op == CMD.SET_PAL: palette = cmd_arg if palette == 0xFFF: palette = -1 ret.append(SetPalette(palette)) elif cmd_op == CMD.LOOP: count = cmd_arg dest = command_list[i + 1] if dest in labels: lbl_name = labels[dest] ret.append(Loop(count, lbl_name, 0)) else: ret.append(Loop(count, None, dest)) i += 1 elif cmd_op == CMD.SET_META: if cmd_start <= 0x80FF: ret.append(Unknown(cmd_arg & 0xFF)) elif cmd_start <= 0x81FF: ret.append(SetParent(cmd_arg & 0xFF)) elif cmd_start <= 0x82FF: ret.append(SetNotify(cmd_arg & 0xFF)) else: raise Exception("Unknown command") i += 1 return ret @staticmethod def from_bytes(data: bytes, sprite_data: bytes): commands_offset = int.from_bytes(data[0:4], byteorder="big") commands_size = int.from_bytes(data[4:6], byteorder="big") # size in bytes commands_data = sprite_data[commands_offset : commands_offset + commands_size] x, y, z = struct.unpack(">hhh", data[6:12]) commands = [int.from_bytes(d[0:2], byteorder="big", signed=False) for d in iter_in_groups(commands_data, 2)] return AnimComponent(x, y, z, commands) @property def animations(self) -> List[Animation]: return AnimComponent.parse_commands(self.commands) @staticmethod def from_xml(xml: ET.Element): commands: List[int] = [] labels = {} for cmd in xml: if cmd.tag == "Label": idx = len(commands) labels[cmd.attrib[XML_ATTR_NAME]] = idx elif cmd.tag == "Wait": duration = int(cmd.attrib[XML_ATTR_DURATION]) commands.append(duration & 0xFFF) elif cmd.tag == "SetRaster": raster = int(cmd.attrib[XML_ATTR_INDEX], 0x10) if raster == -1: raster = 0xFFF commands.append(0x1000 + (raster & 0xFFF)) elif cmd.tag == "Goto": if XML_ATTR_POS in cmd.attrib: # support hardcoded positions for glitched animations pos = int(cmd.attrib[XML_ATTR_POS]) else: # properly formatted animations will have labels lbl_name = cmd.attrib[XML_ATTR_DEST] if not lbl_name in labels: raise Exception("Label missing for Goto dest: " + lbl_name) pos = labels[lbl_name] commands.append(0x2000 + (pos & 0xFFF)) elif cmd.tag == "SetPos": flag = int(cmd.attrib[XML_ATTR_FLAG], 0x10) x, y, z = cmd.attrib[XML_ATTR_XYZ].split(",") commands.append(0x3000 + (flag & 0xFFF)) commands.append(int(x) & 0xFFFF) commands.append(int(y) & 0xFFFF) commands.append(int(z) & 0xFFFF) elif cmd.tag == "SetRot": x, y, z = cmd.attrib[XML_ATTR_XYZ].split(",") commands.append(0x4000 + (int(x) & 0xFFF)) commands.append(int(y) & 0xFFFF) commands.append(int(z) & 0xFFFF) elif cmd.tag == "SetScale": mode = SCALE_MODE_STR_TO_INT[cmd.attrib[XML_ATTR_MODE]] percent = int(cmd.attrib[XML_ATTR_PCT]) commands.append(0x5000 + mode) commands.append(percent) elif cmd.tag == "SetPalette": palette = int(cmd.attrib[XML_ATTR_INDEX], 0x10) if palette == -1: palette = 0xFFF commands.append(0x6000 + (palette & 0xFFF)) elif cmd.tag == "Loop": count = int(cmd.attrib[XML_ATTR_COUNT]) if XML_ATTR_POS in cmd.attrib: # support hardcoded positions for glitched animations pos = int(cmd.attrib[XML_ATTR_POS]) else: # properly formatted animations will have labels lbl_name = cmd.attrib[XML_ATTR_DEST] if not lbl_name in labels: raise Exception("Label missing for Loop dest: " + lbl_name) pos = labels[lbl_name] commands.append(0x7000 + (count & 0xFFF)) commands.append(pos) elif cmd.tag == "Unknown": commands.append(0x8000 + (int(cmd.attrib[XML_ATTR_VALUE]) & 0xFF)) elif cmd.tag == "SetParent": commands.append(0x8100 + (int(cmd.attrib[XML_ATTR_INDEX]) & 0xFF)) elif cmd.tag == "SetNotify": commands.append(0x8200 + (int(cmd.attrib[XML_ATTR_VALUE]) & 0xFF)) elif cmd.tag == "Command": # old Star Rod compatibility commands.append(int(cmd.attrib["val"], 16)) else: raise ValueError(f"unknown command {cmd.tag}") x, y, z = xml.attrib[XML_ATTR_XYZ].split(",") return AnimComponent(int(x), int(y), int(z), commands)