#!/usr/bin/env python3 from math import floor from sys import argv, path from pathlib import Path from typing import List, Tuple import xml.etree.ElementTree as ET import png # type: ignore path.append(str(Path(__file__).parent.parent)) path.append(str(Path(__file__).parent.parent.parent)) path.append(str(Path(__file__).parent.parent.parent / "splat")) path.append(str(Path(__file__).parent.parent.parent / "splat_ext")) from common import get_asset_path, iter_in_groups from splat_ext.pm_sprites import ( MAX_COMPONENTS_XML, PALETTE_GROUPS_XML, NpcRaster, NpcSprite, ) from splat_ext.sprite_common import AnimComponent def pack_color(r, g, b, a): r = floor(31 * (r / 255)) g = floor(31 * (g / 255)) b = floor(31 * (b / 255)) s = round(a / 0xFF) s |= (r & 0x1F) << 11 s |= (g & 0x1F) << 6 s |= (b & 0x1F) << 1 return s def resolve_image_path( sprite_dir: Path, sub_dir: str, img_name: str, asset_stack: Tuple[Path, ...], ) -> str: try: img_path = get_asset_path(sprite_dir / sub_dir / img_name, asset_stack) except FileNotFoundError: # Allow missing subdirectory for backwards compatibility with Star Rod img_path = get_asset_path(sprite_dir / img_name, asset_stack) print(f"warning: please move {sprite_dir}/{img_path.name} to '{sub_dir}' subdirectory") return str(img_path) def from_dir( sprite_name: str, asset_stack: Tuple[Path, ...], load_images: bool = True, ) -> NpcSprite: sprite_dir = Path(f"sprite/npc/{sprite_name}") sprite_sheet_xml_path = get_asset_path(sprite_dir / "SpriteSheet.xml", asset_stack) xml = ET.parse(sprite_sheet_xml_path) SpriteSheet = xml.getroot() true_max_components = 0 if "a" in SpriteSheet.attrib: max_components = int(SpriteSheet.attrib["a"]) else: max_components = int(SpriteSheet.attrib[MAX_COMPONENTS_XML]) if "b" in SpriteSheet.attrib: num_variations = int(SpriteSheet.attrib["b"]) else: num_variations = int(SpriteSheet.attrib[PALETTE_GROUPS_XML]) variation_names = SpriteSheet.get("variations", default="").split(",") palettes = [] palette_names: List[str] = [] for Palette in SpriteSheet.findall("./PaletteList/Palette"): if asset_stack is not None and load_images: img_name = Palette.attrib["src"] img_path = resolve_image_path(sprite_dir, "palettes", img_name, asset_stack) img = png.Reader(img_path) img.preamble(True) palette = img.palette(alpha="force") palette = palette[0:16] assert len(palette) == 16 palettes.append(palette) palette_names.append(Palette.get("name", Palette.attrib["src"].split(".png")[0])) images = [] image_names: List[str] = [] for Raster in SpriteSheet.findall("./RasterList/Raster"): if asset_stack is not None and load_images: img_name = Raster.attrib["src"] img_path = resolve_image_path(sprite_dir, "rasters", img_name, asset_stack) width, height, raster, info = png.Reader(img_path).read_flat() palette_index = int(Raster.attrib["palette"], base=16) image = NpcRaster(width, height, palette_index, raster) assert (image.width % 8) == 0, f"{img_path} width is not a multiple of 8" assert (image.height % 8) == 0, f"{img_path} height is not a multiple of 8" images.append(image) image_names.append(Raster.attrib["src"].split(".png")[0]) animations = [] animation_names: List[str] = [] for Animation in SpriteSheet.findall("./AnimationList/Animation"): comps: List[AnimComponent] = [] for comp_xml in Animation: comp: AnimComponent = AnimComponent.from_xml(comp_xml) comps.append(comp) animation_names.append(Animation.attrib["name"]) animations.append(comps) if len(comps) > true_max_components: true_max_components = len(comps) max_components = true_max_components # assert self.max_components == true_max_components, f"{true_max_components} component(s) used, but SpriteSheet.a = {self.max_components}" return NpcSprite( max_components, num_variations, animations, palettes, images, image_names, palette_names, animation_names, variation_names, ) if __name__ == "__main__": if len(argv) != 4: print("usage: sprite.py [OUTBIN] [SPRITE_NAME] [ASSET_STACK]") exit(1) _, outfile, sprite_name, asset_stack_raw = argv asset_stack = tuple(Path(d) for d in asset_stack_raw.split(",")) try: sprite = from_dir(sprite_name, asset_stack) except AssertionError as e: print("error:", e) exit(1) with open(outfile, "wb") as f: f.seek(0x10) # leave space for header # leave space for animation offset list f.seek((len(sprite.animations) + 1) * 4, 1) animation_offsets = [] # write animations for i, components in enumerate(sprite.animations): animation_offsets.append(f.tell()) # leave space for component offset list f.seek((len(components) + 1) * 4, 1) component_offsets = [] for comp in components: offset = f.tell() for command in comp.commands: f.write(command.to_bytes(2, byteorder="big")) f.seek(f.tell() % 4, 1) component_offsets.append(f.tell()) f.write(offset.to_bytes(4, byteorder="big")) f.write((len(comp.commands) * 2).to_bytes(2, byteorder="big")) f.write(comp.x.to_bytes(2, byteorder="big", signed=True)) f.write(comp.y.to_bytes(2, byteorder="big", signed=True)) f.write(comp.z.to_bytes(2, byteorder="big", signed=True)) next_anim = f.tell() # write component offset list f.seek(animation_offsets[i]) component_offsets.append(-1) for offset in component_offsets: f.write(offset.to_bytes(4, byteorder="big", signed=True)) f.seek(next_anim) # palettes start 8-byte aligned if (f.tell() & 7) == 4: f.seek(4, 1) # write palettes palette_offsets: List[int] = [] for i, palette in enumerate(sprite.palettes): palette_offsets.append(f.tell()) for rgba in palette: if rgba[3] not in (0, 0xFF): print("error: translucent pixels not allowed in palette {sprite.palette_names[i]}") exit(1) color = pack_color(*rgba) f.write(color.to_bytes(2, byteorder="big")) # write images/rasters image_offsets = [] for image in sprite.images: offset = f.tell() for a, b in iter_in_groups(image.raster, 2): byte = (a << 4) | b f.write(byte.to_bytes(1, byteorder="big")) image_offsets.append(f.tell()) f.write(offset.to_bytes(4, byteorder="big")) f.write(bytes([image.width, image.height, image.palette_index, 0xFF])) # write image offset list image_offset_list_offset = f.tell() image_offsets.append(-1) for offset in image_offsets: f.write(offset.to_bytes(4, byteorder="big", signed=True)) # write palette offset list palette_offset_list_offset = f.tell() palette_offsets.append(-1) for offset in palette_offsets: f.write(offset.to_bytes(4, byteorder="big", signed=True)) # write header f.seek(0) f.write(image_offset_list_offset.to_bytes(4, byteorder="big")) f.write(palette_offset_list_offset.to_bytes(4, byteorder="big")) f.write(sprite.max_components.to_bytes(4, byteorder="big")) f.write(sprite.num_variations.to_bytes(4, byteorder="big")) # write animation offset list animation_offsets.append(-1) for offset in animation_offsets: f.write(offset.to_bytes(4, byteorder="big", signed=True))