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