Decomp updates

This commit is contained in:
MaddoScientisto 2026-03-30 00:19:01 +02:00
commit c4fa8a6b05
62 changed files with 9413 additions and 20 deletions

458
tools/dump_dtable_names.py Normal file
View file

@ -0,0 +1,458 @@
from __future__ import annotations
import argparse
import csv
import json
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CategorySpec:
name: str
table_offset: int
entry_size: int
entry_count: int
display_frame_offset: int
display_frame_size: int
name_base_global_index: int
helper_name: str
@dataclass(frozen=True)
class TargetProfile:
target: str
exe_name: str
xml_name: str | None
data_segment: str
helper_function_address: str
data_segment_file_offset: int
pointer_table_offset: int
global_slot_count: int
string_pool_offset: int
sequential_string_count: int | None
invalid_alias_indices: frozenset[int]
output_prefix: str
categories: tuple[CategorySpec, ...]
notes: tuple[str, ...]
REMORSE_CATEGORIES: tuple[CategorySpec, ...] = (
CategorySpec(
name="weapon",
table_offset=0x20C6,
entry_size=0x12,
entry_count=14,
display_frame_offset=0x0D,
display_frame_size=1,
name_base_global_index=0,
helper_name="Weapon_GetNameForShapeNo",
),
CategorySpec(
name="misc_item",
table_offset=0x2244,
entry_size=0x04,
entry_count=12,
display_frame_offset=0x02,
display_frame_size=2,
name_base_global_index=14,
helper_name="MiscTable_GetNameForItemShapeNo",
),
CategorySpec(
name="bomb",
table_offset=0x2274,
entry_size=0x08,
entry_count=9,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=32,
helper_name="MiscTable_GetNameForBombShapeNo",
),
CategorySpec(
name="ammo",
table_offset=0x2042,
entry_size=0x08,
entry_count=6,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=26,
helper_name="Ammo_GetNameForShapeNo",
),
)
REGRET_CATEGORIES: tuple[CategorySpec, ...] = (
CategorySpec(
name="weapon",
table_offset=0x2614,
entry_size=0x12,
entry_count=17,
display_frame_offset=0x0D,
display_frame_size=1,
name_base_global_index=0,
helper_name="Weapon_GetNameForShapeNo",
),
CategorySpec(
name="misc_item",
table_offset=0x2792,
entry_size=0x04,
entry_count=19,
display_frame_offset=0x02,
display_frame_size=2,
name_base_global_index=17,
helper_name="MiscTable_GetNameForItemShapeNo",
),
CategorySpec(
name="bomb",
table_offset=0x27DE,
entry_size=0x08,
entry_count=8,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=44,
helper_name="MiscTable_GetNameForBombShapeNo",
),
CategorySpec(
name="ammo",
table_offset=0x2590,
entry_size=0x08,
entry_count=8,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=36,
helper_name="Ammo_GetNameForShapeNo",
),
)
PROFILES: dict[str, TargetProfile] = {
"remorse": TargetProfile(
target="remorse",
exe_name="CRUSADER.EXE",
xml_name="CRUSADER.EXE.xml",
data_segment="1478",
helper_function_address="1118:056a",
data_segment_file_offset=0xE3C00,
pointer_table_offset=0x22BC,
global_slot_count=41,
string_pool_offset=0x238C,
sequential_string_count=None,
invalid_alias_indices=frozenset({0, 14, 26, 32}),
output_prefix="",
categories=REMORSE_CATEGORIES,
notes=(
"Each category uses local index 0 as a reserved INVALID slot.",
"DTable_GetNameForShapeNo tests categories in order: weapon, misc_item, bomb, ammo, then falls back to INVALID.",
"The dump is derived from live retail CRUSADER.EXE bytes plus the named helper structure recovered in segment 1118.",
),
),
"regret": TargetProfile(
target="regret",
exe_name="REGRET.EXE",
xml_name=None,
data_segment="1480",
helper_function_address="1130:056a",
data_segment_file_offset=0xE2400,
pointer_table_offset=0x2856,
global_slot_count=52,
string_pool_offset=0x2926,
sequential_string_count=49,
invalid_alias_indices=frozenset({0, 17, 36, 44}),
output_prefix="regret_",
categories=REGRET_CATEGORIES,
notes=(
"Each category uses local index 0 as a reserved INVALID slot, but Regret stores only one physical INVALID string and aliases it into later category starts.",
"DTable_GetNameForShapeNo is recovered in the live REGRET.EXE helper cluster at 1130:056a, alongside the same 1130:0145..0474 name-helper family seen in Remorse.",
"This dump is derived from live retail REGRET.EXE bytes. No local REGRET.EXE XML export was required once the 1480 data-segment base, pointer table, and string pool were recovered from the binary.",
),
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Dump Crusader DTable_GetNameForShapeNo mappings.")
parser.add_argument("target", nargs="?", default="remorse", choices=sorted(PROFILES.keys()))
return parser.parse_args()
def read_u16(data: bytes, start: int) -> int:
return int.from_bytes(data[start : start + 2], "little")
def read_u8(data: bytes, start: int) -> int:
return data[start]
def read_cstring(data: bytes, start: int) -> str:
end = data.index(0, start)
return data[start:end].decode("ascii")
def segment_abs(profile: TargetProfile, offset: int) -> str:
return f"{profile.data_segment}:{offset:04x}"
def load_global_names_from_xml(profile: TargetProfile, segment_data: bytes, xml_text: str) -> list[dict[str, object]]:
relocations: dict[int, int] = {}
pattern = re.compile(
rf'<RELOCATION ADDRESS="{re.escape(profile.data_segment)}:([0-9a-f]{{4}})" TYPE="0x3" VALUE="0x[0-9a-f]+,0x0,0x[0-9a-f]{{4}},0x[0-9a-f]+,0x([0-9a-f]{{4}})"',
re.IGNORECASE,
)
for match in pattern.finditer(xml_text):
slot_offset = int(match.group(1), 16)
target_offset = int(match.group(2), 16)
relocations[slot_offset] = target_offset
names: list[dict[str, object]] = []
for global_index in range(profile.global_slot_count):
slot_offset = profile.pointer_table_offset + (global_index * 4)
target_offset = relocations[slot_offset]
text = read_cstring(segment_data, target_offset)
names.append(
{
"global_index": global_index,
"slot_address": segment_abs(profile, slot_offset),
"address": segment_abs(profile, target_offset),
"name": text,
}
)
return names
def load_global_names_from_string_pool(profile: TargetProfile, segment_data: bytes) -> list[dict[str, object]]:
if profile.sequential_string_count is None:
raise ValueError(f"Profile {profile.target} does not define a sequential string count")
unique_strings: list[tuple[int, str]] = []
cursor = profile.string_pool_offset
for _ in range(profile.sequential_string_count):
text = read_cstring(segment_data, cursor)
unique_strings.append((cursor, text))
cursor += len(text) + 1
invalid_offset, invalid_name = unique_strings[0]
next_unique_index = 1
names: list[dict[str, object]] = []
for global_index in range(profile.global_slot_count):
slot_offset = profile.pointer_table_offset + (global_index * 4)
if global_index in profile.invalid_alias_indices:
target_offset = invalid_offset
text = invalid_name
else:
target_offset, text = unique_strings[next_unique_index]
next_unique_index += 1
names.append(
{
"global_index": global_index,
"slot_address": segment_abs(profile, slot_offset),
"address": segment_abs(profile, target_offset),
"name": text,
}
)
if next_unique_index != len(unique_strings):
raise ValueError(
f"Profile {profile.target} consumed {next_unique_index} strings but extracted {len(unique_strings)} unique strings"
)
return names
def load_global_names(profile: TargetProfile, repo_root: Path, segment_data: bytes) -> list[dict[str, object]]:
if profile.xml_name:
xml_path = repo_root / "exports" / profile.xml_name
xml_text = xml_path.read_text(encoding="utf-8")
return load_global_names_from_xml(profile, segment_data, xml_text)
return load_global_names_from_string_pool(profile, segment_data)
def load_category_entries(
profile: TargetProfile,
segment_data: bytes,
global_names: list[dict[str, object]],
) -> dict[str, list[dict[str, object]]]:
category_entries: dict[str, list[dict[str, object]]] = {}
for spec in profile.categories:
entries: list[dict[str, object]] = []
for local_index in range(spec.entry_count):
entry_offset = spec.table_offset + (local_index * spec.entry_size)
shape = read_u16(segment_data, entry_offset)
if spec.display_frame_size == 1:
display_frame = read_u8(segment_data, entry_offset + spec.display_frame_offset)
else:
display_frame = read_u16(segment_data, entry_offset + spec.display_frame_offset)
global_name_index = spec.name_base_global_index + local_index
name_entry = global_names[global_name_index]
entries.append(
{
"category": spec.name,
"helper_name": spec.helper_name,
"local_index": local_index,
"reserved_zero_slot": local_index == 0,
"table_address": segment_abs(profile, entry_offset),
"shape": shape,
"shape_hex": f"0x{shape:04X}",
"display_frame": display_frame,
"display_frame_hex": f"0x{display_frame:04X}",
"global_name_index": global_name_index,
"global_name_slot_address": name_entry["slot_address"],
"global_name_address": name_entry["address"],
"name": name_entry["name"],
}
)
category_entries[spec.name] = entries
return category_entries
def resolve_shapes(
profile: TargetProfile,
category_entries: dict[str, list[dict[str, object]]],
fallback_name: str,
) -> list[dict[str, object]]:
shape_membership: dict[int, list[dict[str, object]]] = {}
for spec in profile.categories:
for entry in category_entries[spec.name]:
shape_membership.setdefault(int(entry["shape"]), []).append(entry)
resolved: list[dict[str, object]] = []
for shape in sorted(shape_membership):
if shape == 0:
continue
memberships = shape_membership[shape]
chosen: dict[str, object] | None = None
for spec in profile.categories:
for entry in memberships:
if entry["category"] == spec.name and not entry["reserved_zero_slot"] and int(entry["shape"]) != 0:
chosen = entry
break
if chosen is not None:
break
resolved.append(
{
"shape": shape,
"shape_hex": f"0x{shape:04X}",
"resolved_name": chosen["name"] if chosen is not None else fallback_name,
"resolved_category": chosen["category"] if chosen is not None else "fallback_invalid",
"resolved_helper_name": chosen["helper_name"] if chosen is not None else "DTable_GetNameForShapeNo fallback",
"resolved_local_index": chosen["local_index"] if chosen is not None else None,
"resolved_global_name_index": chosen["global_name_index"] if chosen is not None else 0,
"resolved_global_name_slot_address": chosen["global_name_slot_address"] if chosen is not None else segment_abs(profile, profile.pointer_table_offset),
"resolved_global_name_address": chosen["global_name_address"] if chosen is not None else segment_abs(profile, profile.string_pool_offset),
"display_frame": chosen["display_frame"] if chosen is not None else None,
"display_frame_hex": chosen["display_frame_hex"] if chosen is not None else None,
"memberships": memberships,
}
)
return resolved
def write_slot_csv(path: Path, global_names: list[dict[str, object]]) -> None:
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["global_index", "slot_address", "address", "name"])
writer.writeheader()
for row in global_names:
writer.writerow(row)
def write_table_csv(path: Path, categories: tuple[CategorySpec, ...], category_entries: dict[str, list[dict[str, object]]]) -> None:
fieldnames = [
"category",
"helper_name",
"local_index",
"reserved_zero_slot",
"table_address",
"shape",
"shape_hex",
"display_frame",
"display_frame_hex",
"global_name_index",
"global_name_slot_address",
"global_name_address",
"name",
]
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for category in (spec.name for spec in categories):
for row in category_entries[category]:
writer.writerow({key: row[key] for key in fieldnames})
def write_resolved_csv(path: Path, resolved_rows: list[dict[str, object]]) -> None:
fieldnames = [
"shape",
"shape_hex",
"resolved_name",
"resolved_category",
"resolved_helper_name",
"resolved_local_index",
"resolved_global_name_index",
"resolved_global_name_slot_address",
"resolved_global_name_address",
"display_frame",
"display_frame_hex",
]
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in resolved_rows:
writer.writerow({key: row[key] for key in fieldnames})
def output_basename(profile: TargetProfile) -> str:
return f"{profile.output_prefix}dtable"
def main() -> None:
args = parse_args()
profile = PROFILES[args.target]
repo_root = Path(__file__).resolve().parent.parent
exe_path = repo_root / profile.exe_name
out_dir = repo_root / "out"
out_dir.mkdir(parents=True, exist_ok=True)
exe_bytes = exe_path.read_bytes()
segment_data = exe_bytes[profile.data_segment_file_offset :]
global_names = load_global_names(profile, repo_root, segment_data)
category_entries = load_category_entries(profile, segment_data, global_names)
resolved_rows = resolve_shapes(profile, category_entries, fallback_name=str(global_names[0]["name"]))
json_path = out_dir / f"{output_basename(profile)}_get_name_dump.json"
payload = {
"target": profile.target,
"function": "DTable_GetNameForShapeNo",
"function_address": profile.helper_function_address,
"segment": profile.data_segment,
"segment_file_offset_hex": f"0x{profile.data_segment_file_offset:05X}",
"pointer_table_address": segment_abs(profile, profile.pointer_table_offset),
"string_pool_address": segment_abs(profile, profile.string_pool_offset),
"lookup_order": [spec.name for spec in profile.categories],
"global_name_slots": global_names,
"category_entries": category_entries,
"resolved_shapes": resolved_rows,
"fallback_name": global_names[0]["name"],
"fallback_global_index": global_names[0]["global_index"],
"notes": list(profile.notes),
}
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
slots_csv_path = out_dir / f"{output_basename(profile)}_global_name_slots.csv"
tables_csv_path = out_dir / f"{output_basename(profile)}_category_entries.csv"
resolved_csv_path = out_dir / f"{output_basename(profile)}_resolved_shapes.csv"
write_slot_csv(slots_csv_path, global_names)
write_table_csv(tables_csv_path, profile.categories, category_entries)
write_resolved_csv(resolved_csv_path, resolved_rows)
print(f"Wrote {json_path}")
print(f"Wrote {slots_csv_path}")
print(f"Wrote {tables_csv_path}")
print(f"Wrote {resolved_csv_path}")
if __name__ == "__main__":
main()

View 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()

View file

@ -0,0 +1,202 @@
from __future__ import annotations
import argparse
import struct
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(r"k:/ghidra/Crusader_Decomp")
DEFAULT_L0_WDL = Path(r"e:/emu/psx/Crusader - No Remorse/LSET1/L0.WDL")
DEFAULT_GPU_DUMP = ROOT / "binary/Crusader - No Remorse (USA) GPU RAM.bin"
DEFAULT_OUTPUT = ROOT / "out/psx_wdl/L0/mode1_live_clut_categories"
ROW_BYTES = 2048
LIVE_CLUT_Y = 0xF0
LIVE_CLUT_X = 0
ATLAS_MAX_WIDTH = 1024
ATLAS_MAX_HEIGHT = 1024
ATLAS_PADDING = 4
import sys
sys.path.insert(0, str(ROOT / "tools"))
from psx_extract_wdl import colorize_indexed_pixels, parse_lset_wdl, scan_sprite_bundles, write_png_rgba
@dataclass
class SpriteFrame:
bundle_offset: int
frame_index: int
width: int
height: 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"bundle_{self.bundle_offset:08X}_frame_{self.frame_index:03d}"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Export mode-1 PSX L0 sprites into category folders using the verified live CLUT row formula."
)
parser.add_argument("--wdl", type=Path, default=DEFAULT_L0_WDL)
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=160)
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 collect_mode1_frames(wdl_path: Path, gpu_dump_path: Path, max_candidates: int) -> list[SpriteFrame]:
l0_data = wdl_path.read_bytes()
palette = load_live_palette(gpu_dump_path)
summary = parse_lset_wdl(l0_data)
if summary is None:
raise SystemExit(f"failed to parse {wdl_path}")
region = next(region for region in summary["regions"] if region["name"] == "post_audio_region_04")
region_data = l0_data[region["offset"] : region["offset"] + region["size"]]
frames: list[SpriteFrame] = []
for bundle in scan_sprite_bundles(region_data, max_candidates=max_candidates):
if bundle["mode"] != 1:
continue
for frame in bundle["frames"]:
rgba = colorize_indexed_pixels(frame["pixels"], frame["width"], frame["height"], 1, palette)
frames.append(
SpriteFrame(
bundle_offset=bundle["offset"],
frame_index=frame["index"],
width=frame["width"],
height=frame["height"],
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)
frames = collect_mode1_frames(args.wdl, args.gpu_dump, args.max_candidates)
categories: dict[str, list[SpriteFrame]] = {}
for frame in frames:
categories.setdefault(classify_frame(frame), []).append(frame)
for category, category_frames in sorted(categories.items()):
export_category(args.output, category, category_frames)
print(f"frames={len(frames)}")
for category, category_frames in sorted(categories.items()):
print(f"{category}={len(category_frames)}")
print(f"folder={args.output / category}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,559 @@
from __future__ import annotations
import argparse
import json
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
DEFAULT_INPUT = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\post_audio_region_01_00007448_paired_u16x6.json")
DEFAULT_SUMMARY = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\summary.json")
DEFAULT_SPRITE_ROOT = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\sprite_bundles")
DEFAULT_OUTPUT_ROOT = Path(r"k:\ghidra\Crusader_Decomp_Public\map_renderer\site\data")
DEFAULT_MAP_ID = 0
DEBUG_SCENE_VERSION = "psx-region01-unverified-bundle-probe-v2"
SCREEN_SCALE = 2
ALLOWED_U5 = {0x20, 0x22, 0x30}
@dataclass(frozen=True)
class PlacementRecord:
side: str
row_index: int
record_index: int
u0: int
u1: int
u2: int
u3: int
u4: int
u5: int
@dataclass(frozen=True)
class BundleFrame:
bundle_index: int
bundle_offset: int
frame_index: int
frame_count: int
width: int
height: int
origin_x: int
origin_y: int
palette_index: int
source_png: Path
def sanitize_origin(origin_x: int, origin_y: int, width: int, height: int) -> tuple[int, int]:
clean_x = origin_x
clean_y = origin_y
if clean_x < 0 or clean_x > width * 4:
clean_x = width // 2
if clean_y < 0 or clean_y > height * 4:
clean_y = max(0, height - 1)
return clean_x, clean_y
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Export a PSX LSET region-01 debug scene for the public map renderer.")
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT, help="Path to paired_u16x6 JSON export")
parser.add_argument("--summary", type=Path, default=DEFAULT_SUMMARY, help="Path to LSET summary.json with sprite bundle metadata")
parser.add_argument("--sprite-root", type=Path, default=DEFAULT_SPRITE_ROOT, help="Path to extracted sprite_bundles directory")
parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT, help="Public renderer site/data root")
parser.add_argument("--game-id", default="psx-remorse", help="Catalog game id to write")
parser.add_argument("--game-label", default="No Remorse (PSX)", help="Catalog game label")
parser.add_argument("--map-id", type=int, default=DEFAULT_MAP_ID, help="Numeric map id for the static scene")
parser.add_argument(
"--map-label",
default="PSX LSET1/L0 Unverified Art Probe",
help="Human-readable map label for the catalog entry",
)
return parser.parse_args()
def load_records(path: Path) -> list[PlacementRecord]:
payload = json.loads(path.read_text(encoding="utf-8"))
records: list[PlacementRecord] = []
for row in payload.get("rows", []):
row_index = int(row["index"])
for side_index, side in enumerate(("left", "right")):
values = row.get(side)
if not isinstance(values, dict):
continue
record = PlacementRecord(
side=side,
row_index=row_index,
record_index=row_index * 2 + side_index,
u0=int(values.get("u0", 0)),
u1=int(values.get("u1", 0)),
u2=int(values.get("u2", 0)),
u3=int(values.get("u3", 0)),
u4=int(values.get("u4", 0)),
u5=int(values.get("u5", 0)),
)
if is_structured_candidate(record):
records.append(record)
records.sort(key=lambda record: (record.u2, record.u1, record.u0, record.record_index))
return records
def is_structured_candidate(record: PlacementRecord) -> bool:
if record.u0 >= 0x200:
return False
if record.u1 == 0 and record.u2 == 0:
return False
if record.u1 >= 0x4000 or record.u2 >= 0x4000:
return False
if record.u3 > 0x20 or record.u4 > 0x04:
return False
if record.u5 not in ALLOWED_U5:
return False
return True
def load_bundle_frames(summary_path: Path, sprite_root: Path) -> list[BundleFrame]:
bundle_frames: list[BundleFrame] = []
bundle_dirs = sorted(path for path in sprite_root.iterdir() if path.is_dir() and path.name.startswith("bundle_"))
for bundle_index, bundle_dir in enumerate(bundle_dirs):
bundle = json.loads((bundle_dir / "bundle.json").read_text(encoding="utf-8"))
bundle_offset = int(bundle.get("offset", 0))
palette_index = int(bundle.get("palette_index", 0))
frame_count = int(bundle.get("frame_count", 0))
for frame in bundle.get("exported_frames", []):
frame_index = int(frame.get("index", 0))
width = int(frame.get("width", 1))
height = int(frame.get("height", 1))
origin_x, origin_y = sanitize_origin(
int(frame.get("origin_x", 0)),
int(frame.get("origin_y", 0)),
width,
height,
)
source_png = bundle_dir / f"frame_{frame_index:03d}_color.png"
if not source_png.exists():
source_png = bundle_dir / f"frame_{frame_index:03d}.png"
if not source_png.exists():
continue
bundle_frames.append(
BundleFrame(
bundle_index=bundle_index,
bundle_offset=bundle_offset,
frame_index=frame_index,
frame_count=frame_count,
width=width,
height=height,
origin_x=origin_x,
origin_y=origin_y,
palette_index=palette_index,
source_png=source_png,
)
)
if not bundle_frames:
raise ValueError("No extracted sprite bundle PNGs were found under sprite_bundles.")
return bundle_frames
def build_bundle_frame_lookup(bundle_frames: list[BundleFrame]) -> dict[int, list[BundleFrame]]:
lookup: dict[int, list[BundleFrame]] = {}
for bundle_frame in bundle_frames:
lookup.setdefault(bundle_frame.bundle_index, []).append(bundle_frame)
for frames in lookup.values():
frames.sort(key=lambda frame: frame.frame_index)
return lookup
def build_scene(records: list[PlacementRecord], bundle_frames: list[BundleFrame], game_id: str, game_label: str, map_id: int) -> tuple[dict[str, object], list[tuple[Path, str]]]:
if not records:
raise ValueError("No structured PSX placement records survived the debug filter.")
min_x = min(record.u1 for record in records)
max_x = max(record.u1 for record in records)
min_y = min(record.u2 for record in records)
max_y = max(record.u2 for record in records)
bundle_lookup = build_bundle_frame_lookup(bundle_frames)
requested_bundles = sorted({record.u0 for record in records})
missing_bundle_indexes = [bundle_index for bundle_index in requested_bundles if bundle_index not in bundle_lookup]
if missing_bundle_indexes:
raise ValueError(f"Missing extracted PNGs for bundle indexes: {missing_bundle_indexes[:10]}")
shape_by_bundle = {bundle_index: 0x9000 + index for index, bundle_index in enumerate(requested_bundles)}
sprite_frames: dict[tuple[int, int], BundleFrame] = {}
sprite_copies: list[tuple[Path, str]] = []
atlases: list[dict[str, object]] = []
sprites: list[dict[str, object]] = []
for bundle_index in requested_bundles:
frames = bundle_lookup[bundle_index]
used_frame_indexes = sorted({min(record.u4, len(frames) - 1) for record in records if record.u0 == bundle_index})
shape = shape_by_bundle[bundle_index]
for used_frame_index in used_frame_indexes:
bundle_frame = frames[used_frame_index]
sprite_frames[(bundle_index, used_frame_index)] = bundle_frame
atlas_id = f"atlas-{bundle_index:03d}-{used_frame_index:03d}"
file_name = f"bundle_{bundle_frame.bundle_offset:08X}_frame_{bundle_frame.frame_index:03d}.png"
sprite_copies.append((bundle_frame.source_png, file_name))
atlases.append(
{
"id": atlas_id,
"fileName": file_name,
"width": bundle_frame.width,
"height": bundle_frame.height,
}
)
sprites.append(
{
"id": f"sprite:{shape}:{used_frame_index}",
"atlasId": atlas_id,
"shape": shape,
"frame": used_frame_index,
"x": 0,
"y": 0,
"width": bundle_frame.width,
"height": bundle_frame.height,
"xoff": bundle_frame.origin_x,
"yoff": bundle_frame.origin_y,
}
)
shape_definitions: list[dict[str, object]] = []
for bundle_index in requested_bundles:
frames = bundle_lookup[bundle_index]
bundle_frame = frames[0]
shape = shape_by_bundle[bundle_index]
shape_definitions.append(
{
"id": f"shape:{shape}",
"shape": shape,
"shapeHex": f"0x{shape:04x}",
"family": None,
"label": "Terrain",
"kind": "terrain",
"displayName": f"PSX unverified bundle probe u0={bundle_index:04X} -> bundle {bundle_index}",
"description": f"Unverified PSX art probe using region-01 u0 as direct sprite bundle index (bundle offset 0x{bundle_frame.bundle_offset:08X}).",
"dimensions": {"x": bundle_frame.width, "y": bundle_frame.height, "z": 1},
"visibilityTags": [],
"traits": {
"editor": False,
"roof": False,
"oob": False,
"occluding": False,
"translucent": False,
"solid": False,
"fixed": False,
"land": True,
"draw": True,
"invitem": False,
"animType": 0,
},
"catalogEntry": {
"humanReadableId": "",
"description": "",
"roof": None,
"semitransparency": None,
"oob": None,
},
"catalogOverrides": {
"roof": None,
"semitransparency": None,
"oob": None,
},
"tableFallback": None,
}
)
items: list[dict[str, object]] = []
min_screen_left = None
min_screen_top = None
screen_right = None
screen_bottom = None
fallback_frame_count = 0
for draw_order, record in enumerate(records):
anchor_x = (record.u1 - min_x) * SCREEN_SCALE
anchor_y = (max_y - record.u2) * SCREEN_SCALE
frames = bundle_lookup[record.u0]
chosen_frame_index = min(record.u4, len(frames) - 1)
if chosen_frame_index != record.u4:
fallback_frame_count += 1
bundle_frame = sprite_frames[(record.u0, chosen_frame_index)]
screen_left = anchor_x - bundle_frame.origin_x
screen_top = anchor_y - bundle_frame.origin_y
screen_right = screen_left + bundle_frame.width if screen_right is None else max(screen_right, screen_left + bundle_frame.width)
screen_bottom = screen_top + bundle_frame.height if screen_bottom is None else max(screen_bottom, screen_top + bundle_frame.height)
min_screen_left = screen_left if min_screen_left is None else min(min_screen_left, screen_left)
min_screen_top = screen_top if min_screen_top is None else min(min_screen_top, screen_top)
shape = shape_by_bundle[record.u0]
items.append(
{
"id": f"item:{draw_order}:psx-region01:{record.side}:{record.row_index}",
"mapSourceIndex": draw_order,
"drawOrder": draw_order,
"kind": "terrain",
"label": "Terrain",
"source": "psx-region01",
"world": {
"x": record.u1,
"y": record.u2,
"z": record.u0,
},
"mapNum": record.u5,
"npcNum": record.u4,
"nextItem": 0,
"quality": record.u0,
"frame": chosen_frame_index,
"screen": {
"left": screen_left,
"top": screen_top,
"right": screen_left + bundle_frame.width,
"bottom": screen_top + bundle_frame.height,
"width": bundle_frame.width,
"height": bundle_frame.height,
"anchorX": anchor_x,
"anchorY": anchor_y,
},
"flags": {
"raw": record.u3,
"hex": f"0x{record.u3:04X}",
"invisible": False,
"flipped": False,
},
"presentation": {
"opacity": 1,
"visibilityDefault": True,
},
"notes": [
f"PSX region-01 art probe record {record.side} row {record.row_index}",
f"raw words: {record.u0:04X} {record.u1:04X} {record.u2:04X} {record.u3:04X} {record.u4:04X} {record.u5:04X}",
f"provisional art mapping: bundle_index={record.u0} bundle_offset=0x{bundle_frame.bundle_offset:08X} requested_frame={record.u4} chosen_frame={chosen_frame_index} palette_index={bundle_frame.palette_index}",
],
"frameSize": {
"width": bundle_frame.width,
"height": bundle_frame.height,
"xoff": bundle_frame.origin_x,
"yoff": bundle_frame.origin_y,
},
"egg": None,
"npcPreview": None,
"itemPreview": None,
"shapeDefId": f"shape:{shape}",
"spriteId": f"sprite:{shape}:{chosen_frame_index}",
}
)
x_shift = -min(0, min_screen_left or 0)
y_shift = -min(0, min_screen_top or 0)
final_right = 0
final_bottom = 0
for item in items:
screen = item["screen"]
screen["left"] += x_shift
screen["right"] += x_shift
screen["top"] += y_shift
screen["bottom"] += y_shift
screen["anchorX"] += x_shift
screen["anchorY"] += y_shift
final_right = max(final_right, screen["right"])
final_bottom = max(final_bottom, screen["bottom"])
map_source_items = []
for record, item in zip(records, items):
chosen_frame_index = item["frame"]
bundle_frame = sprite_frames[(record.u0, chosen_frame_index)]
map_source_items.append(
{
"x": record.u1,
"y": record.u2,
"z": record.u0,
"shape": shape_by_bundle[record.u0],
"frame": chosen_frame_index,
"flags": record.u3,
"quality": record.u0,
"npcNum": record.u4,
"mapNum": record.u5,
"nextItem": 0,
"source": "psx-region01",
"rawWords": [record.u0, record.u1, record.u2, record.u3, record.u4, record.u5],
"recordSide": record.side,
"rowIndex": record.row_index,
"bundleOffset": bundle_frame.bundle_offset,
"paletteIndex": bundle_frame.palette_index,
"screenLeft": item["screen"]["left"],
"screenTop": item["screen"]["top"],
}
)
scene = {
"build": {
"version": DEBUG_SCENE_VERSION,
"fingerprint": "psx-lset1-l0-region01-debug",
"generatedAt": "2026-03-29T00:00:00.000Z",
"cacheMode": "single-scene",
},
"metadata": {
"game": game_id,
"gameLabel": game_label,
"map": map_id,
"rawItemCount": len(records),
"itemCount": len(records),
"paintedItemCount": len(records),
"occludedItemCount": 0,
"invalidItemCount": 0,
"invalidItems": [],
"sceneSummary": {
"atlasCount": len(atlases),
"spriteCount": len(sprites),
"helperCount": 0,
"kindCounts": {"terrain": len(records)},
"sourceCounts": {"psx-region01": len(records)},
"topFamilies": [{"family": None, "count": len(records)}],
},
"usage": {
"status": "research",
"confidence": "low",
"knownHints": [
"Uses real extracted LSET sprite bundle PNGs as an explicitly unverified art probe.",
"Current hypothesis is direct region-01 u0 -> sprite bundle index and u4 -> frame index.",
"This mapping is known to be incoherent and should not be treated as final art placement."
],
"itemMapNums": sorted({record.u5 for record in records}),
"nonzeroItemMapNums": sorted({record.u5 for record in records if record.u5 != 0}),
"npcLinkedItemCount": sum(1 for record in records if record.u4 != 0),
"note": "This is an unverified real-art PSX probe scene from filtered region-01 placement candidates, not a final decoded map format.",
"hasRenderableContent": True,
"game": game_id,
"map": map_id,
},
"baseItemSummary": {
"roofItems": 0,
"editorItems": 0,
"eggFamilyItems": 0,
"invisibleFlaggedItems": 0,
"npcLinkedItems": sum(1 for record in records if record.u4 != 0),
},
"sorter": "psx_region01_debug",
"isEmpty": False,
"emptyReason": None,
"bounds": {
"screenLeft": 0,
"screenTop": 0,
"screenRight": final_right,
"screenBottom": final_bottom,
"width": final_right,
"height": final_bottom,
},
"zoom": {
"min": 0.01,
"max": 8,
"step": 0.1,
"initial": 1,
},
"buildFingerprint": "psx-lset1-l0-region01-unverified-art-probe",
"generatedAt": "2026-03-29T00:00:00.000Z",
"probeStats": {
"fallbackFrameCount": fallback_frame_count,
"bundleIndexMin": requested_bundles[0],
"bundleIndexMax": requested_bundles[-1],
"bundleCountUsed": len(requested_bundles),
},
},
"atlases": atlases,
"sprites": sprites,
"shapeDefinitions": sorted(shape_definitions, key=lambda entry: entry["shape"]),
"items": items,
"mapSource": {
"formatVersion": DEBUG_SCENE_VERSION,
"game": game_id,
"mapId": map_id,
"itemRecordSize": 12,
"itemCount": len(map_source_items),
"originalByteLength": len(map_source_items) * 12,
"exportFileName": None,
"defaultTeleportEggShape": None,
"defaultTeleportEggShapeHex": None,
"defaultTeleportEggFrame": None,
"defaultTeleporterEggFrame": None,
"defaultTeleportDestinationEggFrame": None,
"binaryExportSupported": False,
"items": map_source_items,
},
}
return scene, sprite_copies
def write_catalog_entry(output_root: Path, game_id: str, game_label: str, map_id: int, map_label: str, raw_item_count: int, shape_definitions: list[dict[str, object]]) -> None:
catalog_path = output_root / "catalog.json"
catalog = json.loads(catalog_path.read_text(encoding="utf-8")) if catalog_path.exists() else {"games": []}
games = [game for game in catalog.get("games", []) if game.get("id") != game_id]
games.append(
{
"id": game_id,
"label": game_label,
"mapCount": 1,
"maps": [
{
"id": map_id,
"label": map_label,
"rawItemCount": raw_item_count,
}
],
}
)
games.sort(key=lambda game: game["label"])
catalog["games"] = games
catalog_path.write_text(json.dumps(catalog, indent=2) + "\n", encoding="utf-8")
catalogs_dir = output_root / "catalogs"
catalogs_dir.mkdir(parents=True, exist_ok=True)
csv_lines = [
"shape_code,human_readable_id,description,roof,semitransparency,OOB,categorization,qualities"
]
for definition in shape_definitions:
csv_lines.append(
",".join(
[
definition["shapeHex"],
definition["displayName"],
definition["description"],
"",
"",
"",
definition["kind"],
"",
]
)
)
(catalogs_dir / f"{game_id}.csv").write_text("\n".join(csv_lines) + "\n", encoding="utf-8")
def main() -> int:
args = parse_args()
records = load_records(args.input)
bundle_frames = load_bundle_frames(args.summary, args.sprite_root)
scene, sprite_copies = build_scene(records, bundle_frames, args.game_id, args.game_label, args.map_id)
maps_root = args.output_root / "maps" / args.game_id / f"map-{args.map_id}"
maps_root.mkdir(parents=True, exist_ok=True)
for source_png, file_name in sprite_copies:
shutil.copyfile(source_png, maps_root / file_name)
(maps_root / "scene.json").write_text(json.dumps(scene, indent=2) + "\n", encoding="utf-8")
write_catalog_entry(
args.output_root,
args.game_id,
args.game_label,
args.map_id,
args.map_label,
len(records),
scene["shapeDefinitions"],
)
print(
f"wrote PSX art probe scene: game={args.game_id} map={args.map_id} items={len(records)} unique_shapes={len(scene['shapeDefinitions'])} atlases={len(scene['atlases'])}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

1087
tools/psx_extract_wdl.py Normal file

File diff suppressed because it is too large Load diff