Crusader_Decomp/tools/psx_export_map_debug_scene.py

559 lines
22 KiB
Python
Raw Permalink Normal View History

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