Decomp updates
This commit is contained in:
parent
f6a5155675
commit
c4fa8a6b05
62 changed files with 9413 additions and 20 deletions
458
tools/dump_dtable_names.py
Normal file
458
tools/dump_dtable_names.py
Normal 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()
|
||||
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()
|
||||
202
tools/psx_dump_mode1_live_clut_categories.py
Normal file
202
tools/psx_dump_mode1_live_clut_categories.py
Normal 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()
|
||||
559
tools/psx_export_map_debug_scene.py
Normal file
559
tools/psx_export_map_debug_scene.py
Normal 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
1087
tools/psx_extract_wdl.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue