Crusader_Decomp/tools/psx_dump_all_lset_sprites.py

254 lines
8.7 KiB
Python
Raw Normal View History

2026-03-30 00:19:01 +02:00
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()