559 lines
22 KiB
Python
559 lines
22 KiB
Python
|
|
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())
|