254 lines
8.7 KiB
Python
254 lines
8.7 KiB
Python
|
|
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()
|