Decomp updates
This commit is contained in:
parent
f6a5155675
commit
c4fa8a6b05
62 changed files with 9413 additions and 20 deletions
254
tools/psx_dump_all_lset_sprites.py
Normal file
254
tools/psx_dump_all_lset_sprites.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue