from __future__ import annotations import argparse import struct import sys from dataclasses import dataclass from pathlib import Path ROOT = Path(r"k:/ghidra/Crusader_Decomp") DEFAULT_DISC_ROOT = Path(r"e:/emu/psx/Crusader - No Remorse") DEFAULT_GPU_DUMP = ROOT / "binary/Crusader - No Remorse (USA) GPU RAM.bin" DEFAULT_OUTPUT = ROOT / "out/psx_wdl/all_lset_sprite_categories" ROW_BYTES = 2048 LIVE_CLUT_Y = 0xF0 LIVE_CLUT_X = 0 ATLAS_MAX_WIDTH = 1024 ATLAS_MAX_HEIGHT = 1024 ATLAS_PADDING = 4 sys.path.insert(0, str(ROOT / "tools")) from psx_extract_wdl import ( choose_palette, colorize_indexed_pixels, extract_palette_sets, parse_lset_wdl, scan_sprite_bundles, write_png_rgba, ) @dataclass class SpriteFrame: source_tag: str source_name: str bundle_offset: int absolute_offset: int frame_index: int width: int height: int mode: int frame_count: int rgba: bytes opaque_pixels: int @property def area(self) -> int: return self.width * self.height @property def opaque_ratio(self) -> float: if self.area == 0: return 0.0 return self.opaque_pixels / self.area @property def aspect_ratio(self) -> float: if self.height == 0: return 0.0 return self.width / self.height @property def stem(self) -> str: return ( f"{self.source_tag}_off_{self.absolute_offset:08X}_bundle_{self.bundle_offset:08X}" f"_frame_{self.frame_index:03d}_m{self.mode}" ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Dump all LSET sprite bundles from the PSX game files into category folders and packed atlases." ) parser.add_argument("--disc-root", type=Path, default=DEFAULT_DISC_ROOT) parser.add_argument("--gpu-dump", type=Path, default=DEFAULT_GPU_DUMP) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument("--max-candidates", type=int, default=0) return parser.parse_args() def load_live_palette(gpu_dump_path: Path) -> list[int]: gpu = gpu_dump_path.read_bytes() row = gpu[LIVE_CLUT_Y * ROW_BYTES : (LIVE_CLUT_Y + 1) * ROW_BYTES] row_words = struct.unpack("<1024H", row) return list(row_words[LIVE_CLUT_X : LIVE_CLUT_X + 256]) def count_opaque_pixels(rgba: bytes) -> int: opaque = 0 for offset in range(3, len(rgba), 4): if rgba[offset] != 0: opaque += 1 return opaque def classify_frame(frame: SpriteFrame) -> str: if frame.opaque_pixels <= 96 or (frame.area <= 512 and frame.opaque_ratio < 0.22): return "effects_and_particles" if frame.width >= 72 or frame.height >= 72 or frame.area >= 3200: return "large_props" if frame.aspect_ratio >= 1.85 and frame.height <= 32: return "panels_and_strips" if frame.aspect_ratio <= 0.55 and frame.height >= 28: return "tall_fixtures" if frame.height <= 18 and frame.width <= 48: return "pickups_and_weapons" if frame.height <= 28 and frame.width <= 40 and frame.area <= 900: return "small_items" if frame.frame_count >= 4 and frame.area <= 1600: return "animated_small_props" return "medium_props" def pack_pages(frames: list[SpriteFrame]) -> list[list[tuple[SpriteFrame, int, int]]]: sorted_frames = sorted(frames, key=lambda item: (item.height, item.width, item.area), reverse=True) pages: list[list[tuple[SpriteFrame, int, int]]] = [] current_page: list[tuple[SpriteFrame, int, int]] = [] cursor_x = 0 cursor_y = 0 shelf_height = 0 for frame in sorted_frames: needed_width = frame.width + (ATLAS_PADDING if cursor_x else 0) if cursor_x and cursor_x + needed_width > ATLAS_MAX_WIDTH: cursor_x = 0 cursor_y += shelf_height + ATLAS_PADDING shelf_height = 0 if cursor_y and cursor_y + frame.height > ATLAS_MAX_HEIGHT: pages.append(current_page) current_page = [] cursor_x = 0 cursor_y = 0 shelf_height = 0 place_x = cursor_x + (ATLAS_PADDING if cursor_x else 0) current_page.append((frame, place_x, cursor_y)) cursor_x = place_x + frame.width shelf_height = max(shelf_height, frame.height) if current_page: pages.append(current_page) return pages def write_packed_atlas(path: Path, placements: list[tuple[SpriteFrame, int, int]]) -> None: atlas_width = max(x + frame.width for frame, x, _ in placements) atlas_height = max(y + frame.height for frame, _, y in placements) atlas = bytearray(atlas_width * atlas_height * 4) for frame, origin_x, origin_y in placements: for y in range(frame.height): src_start = y * frame.width * 4 dst_start = ((origin_y + y) * atlas_width + origin_x) * 4 atlas[dst_start : dst_start + frame.width * 4] = frame.rgba[src_start : src_start + frame.width * 4] write_png_rgba(path, bytes(atlas), atlas_width, atlas_height) def export_category(output_dir: Path, category: str, frames: list[SpriteFrame]) -> None: category_dir = output_dir / category category_dir.mkdir(parents=True, exist_ok=True) for frame in frames: write_png_rgba(category_dir / f"{frame.stem}.png", frame.rgba, frame.width, frame.height) for page_index, placements in enumerate(pack_pages(frames)): write_packed_atlas(category_dir / f"atlas_{page_index:02d}.png", placements) def source_tag_from_path(disc_root: Path, path: Path) -> str: relative = path.relative_to(disc_root) parts = [part.lower().replace(".wdl", "") for part in relative.parts] return "_".join(parts) def collect_frames_from_lset( disc_root: Path, path: Path, live_palette: list[int], max_candidates: int, ) -> list[SpriteFrame]: data = path.read_bytes() summary = parse_lset_wdl(data) if summary is None: return [] graphics_region = next( (region for region in summary["regions"] if region["name"] == "post_audio_region_04"), None, ) if graphics_region is None: return [] region_data = data[graphics_region["offset"] : graphics_region["offset"] + graphics_region["size"]] palettes_16 = extract_palette_sets(data, summary) source_tag = source_tag_from_path(disc_root, path) limit = None if max_candidates <= 0 else max_candidates frames: list[SpriteFrame] = [] for bundle in scan_sprite_bundles(region_data, max_candidates=limit): if bundle["mode"] == 1: palette = live_palette elif bundle["mode"] == 2: palette_index = bundle.get("palette_index") if palette_index is None or palette_index >= len(palettes_16): palette_index = choose_palette(palettes_16, bundle["frames"], bundle["mode"]) if palette_index is None or palette_index >= len(palettes_16): continue palette = palettes_16[palette_index] else: continue absolute_bundle_offset = graphics_region["offset"] + bundle["offset"] for frame in bundle["frames"]: rgba = colorize_indexed_pixels(frame["pixels"], frame["width"], frame["height"], bundle["mode"], palette) frames.append( SpriteFrame( source_tag=source_tag, source_name=str(path.relative_to(disc_root)), bundle_offset=bundle["offset"], absolute_offset=absolute_bundle_offset, frame_index=frame["index"], width=frame["width"], height=frame["height"], mode=bundle["mode"], frame_count=bundle["frame_count"], rgba=rgba, opaque_pixels=count_opaque_pixels(rgba), ) ) return frames def main() -> None: args = parse_args() args.output.mkdir(parents=True, exist_ok=True) live_palette = load_live_palette(args.gpu_dump) wdl_paths = sorted(args.disc_root.glob("LSET*/L*.WDL")) all_frames: list[SpriteFrame] = [] for path in wdl_paths: all_frames.extend(collect_frames_from_lset(args.disc_root, path, live_palette, args.max_candidates)) categories: dict[str, list[SpriteFrame]] = {} for frame in all_frames: categories.setdefault(classify_frame(frame), []).append(frame) for category, frames in sorted(categories.items()): export_category(args.output, category, frames) print(f"source_files={len(wdl_paths)}") print(f"frames={len(all_frames)}") for category, frames in sorted(categories.items()): print(f"{category}={len(frames)}") print(f"folder={args.output / category}") if __name__ == "__main__": main()