PSX Decompilation
This commit is contained in:
parent
56f6099820
commit
bbd29b1f10
25 changed files with 1921 additions and 701 deletions
586
tools/psx_export_map_type_probe.py
Normal file
586
tools/psx_export_map_type_probe.py
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_INPUT = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\post_audio_region_01_00007448_paired_u16x6.json")
|
||||
DEFAULT_OUTPUT_ROOT = Path(r"k:\ghidra\crusader_map_viewer\map_renderer\site\data")
|
||||
DEFAULT_MAP_ID = 0
|
||||
DEBUG_SCENE_VERSION = "psx-region01-type-placement-probe-v1"
|
||||
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 PlaceholderSprite:
|
||||
type_id: int
|
||||
variant: int
|
||||
lane: int
|
||||
atlas_id: str
|
||||
file_name: str
|
||||
shape_code: int
|
||||
width: int
|
||||
height: int
|
||||
origin_x: int
|
||||
origin_y: int
|
||||
display_name: str
|
||||
description: str
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export a PSX LSET region-01 type-placement probe into the current map viewer static data."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT, help="Path to paired_u16x6 JSON export")
|
||||
parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT, help="Map viewer 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 Type Placement Probe",
|
||||
help="Human-readable map label for the catalog entry",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screen-scale",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Multiplier applied to the raw region-01 coordinate delta when placing probe sprites",
|
||||
)
|
||||
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.u5, record.u4, 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 chunk(tag: bytes, payload: bytes) -> bytes:
|
||||
crc = zlib.crc32(tag)
|
||||
crc = zlib.crc32(payload, crc) & 0xFFFFFFFF
|
||||
return len(payload).to_bytes(4, "big") + tag + payload + crc.to_bytes(4, "big")
|
||||
|
||||
|
||||
def write_png_rgba(path: Path, rgba: bytes, width: int, height: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stride = width * 4
|
||||
scanlines = bytearray()
|
||||
for row in range(height):
|
||||
start = row * stride
|
||||
scanlines.append(0)
|
||||
scanlines.extend(rgba[start : start + stride])
|
||||
ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + bytes((8, 6, 0, 0, 0))
|
||||
idat = zlib.compress(bytes(scanlines), level=9)
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||
path.write_bytes(png)
|
||||
|
||||
|
||||
def clamp_channel(value: int) -> int:
|
||||
return max(0, min(255, value))
|
||||
|
||||
|
||||
def color_from_key(type_id: int, variant: int, lane: int) -> tuple[int, int, int]:
|
||||
seed = (type_id * 1103515245 + variant * 12345 + lane * 2654435761) & 0xFFFFFFFF
|
||||
red = 64 + (seed & 0x7F)
|
||||
green = 72 + ((seed >> 7) & 0x7F)
|
||||
blue = 80 + ((seed >> 14) & 0x7F)
|
||||
if lane == 0x22:
|
||||
green += 24
|
||||
elif lane == 0x30:
|
||||
blue += 28
|
||||
return clamp_channel(red), clamp_channel(green), clamp_channel(blue)
|
||||
|
||||
|
||||
def placeholder_geometry(lane: int) -> tuple[int, int, int, int]:
|
||||
if lane == 0x30:
|
||||
return 64, 64, 32, 52
|
||||
if lane == 0x22:
|
||||
return 64, 40, 32, 28
|
||||
return 64, 32, 32, 20
|
||||
|
||||
|
||||
def set_pixel(rgba: bytearray, width: int, x: int, y: int, color: tuple[int, int, int, int]) -> None:
|
||||
if x < 0 or y < 0 or x >= width:
|
||||
return
|
||||
index = (y * width + x) * 4
|
||||
if index < 0 or index + 3 >= len(rgba):
|
||||
return
|
||||
rgba[index : index + 4] = bytes(color)
|
||||
|
||||
|
||||
def build_placeholder_rgba(type_id: int, variant: int, lane: int) -> tuple[bytes, int, int, int, int]:
|
||||
width, height, origin_x, origin_y = placeholder_geometry(lane)
|
||||
rgba = bytearray(width * height * 4)
|
||||
fill_rgb = color_from_key(type_id, variant, lane)
|
||||
border_rgb = tuple(clamp_channel(channel - 36) for channel in fill_rgb)
|
||||
top = 4 if lane != 0x30 else 16
|
||||
mid_y = top + 8
|
||||
bottom = height - 4
|
||||
center_x = width // 2
|
||||
half_span = width // 2 - 4
|
||||
for y in range(top, bottom):
|
||||
if lane == 0x30 and y < mid_y:
|
||||
continue
|
||||
rel = (y - mid_y) / max(1, bottom - mid_y)
|
||||
span = max(4, int(half_span * (1.0 - abs(rel))))
|
||||
left = center_x - span
|
||||
right = center_x + span
|
||||
for x in range(left, right + 1):
|
||||
alpha = 220
|
||||
if x in (left, right) or y in (top, bottom, mid_y):
|
||||
color = (*border_rgb, 255)
|
||||
else:
|
||||
color = (*fill_rgb, alpha)
|
||||
set_pixel(rgba, width, x, y, color)
|
||||
if lane == 0x30:
|
||||
for y in range(8, mid_y):
|
||||
left = center_x - 12
|
||||
right = center_x + 12
|
||||
for x in range(left, right + 1):
|
||||
color = (*fill_rgb, 208) if x not in (left, right) else (*border_rgb, 255)
|
||||
set_pixel(rgba, width, x, y, color)
|
||||
stripe_count = min(variant, 3)
|
||||
for stripe in range(stripe_count):
|
||||
stripe_y = bottom - 6 - stripe * 4
|
||||
for x in range(center_x - 10, center_x + 11):
|
||||
set_pixel(rgba, width, x, stripe_y, (255, 255, 255, 220))
|
||||
return bytes(rgba), width, height, origin_x, origin_y
|
||||
|
||||
|
||||
def build_placeholder_sprites(output_root: Path, records: list[PlacementRecord]) -> dict[tuple[int, int, int], PlaceholderSprite]:
|
||||
maps_root = output_root / "maps" / "psx-remorse" / "map-0"
|
||||
sprites: dict[tuple[int, int, int], PlaceholderSprite] = {}
|
||||
keys = sorted({(record.u0, record.u4, record.u5) for record in records})
|
||||
for index, (type_id, variant, lane) in enumerate(keys):
|
||||
rgba, width, height, origin_x, origin_y = build_placeholder_rgba(type_id, variant, lane)
|
||||
atlas_id = f"atlas-type-{type_id:04x}-variant-{variant}-lane-{lane:04x}"
|
||||
file_name = f"type_{type_id:04X}_variant_{variant}_lane_{lane:04X}.png"
|
||||
write_png_rgba(maps_root / file_name, rgba, width, height)
|
||||
shape_code = 0xA000 + index
|
||||
sprites[(type_id, variant, lane)] = PlaceholderSprite(
|
||||
type_id=type_id,
|
||||
variant=variant,
|
||||
lane=lane,
|
||||
atlas_id=atlas_id,
|
||||
file_name=file_name,
|
||||
shape_code=shape_code,
|
||||
width=width,
|
||||
height=height,
|
||||
origin_x=origin_x,
|
||||
origin_y=origin_y,
|
||||
display_name=f"PSX type {type_id:04X} variant {variant} lane {lane:04X}",
|
||||
description=(
|
||||
"Region-01 placement probe using PSX type/lane placeholders. "
|
||||
"This scene is intended to validate coordinate coherence before final art binding."
|
||||
),
|
||||
)
|
||||
return sprites
|
||||
|
||||
|
||||
def build_scene(
|
||||
records: list[PlacementRecord],
|
||||
placeholder_sprites: dict[tuple[int, int, int], PlaceholderSprite],
|
||||
game_id: str,
|
||||
game_label: str,
|
||||
map_id: int,
|
||||
screen_scale: int,
|
||||
) -> dict[str, object]:
|
||||
if not records:
|
||||
raise ValueError("No structured PSX placement records survived the 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)
|
||||
|
||||
atlases: list[dict[str, object]] = []
|
||||
sprites: list[dict[str, object]] = []
|
||||
shape_definitions: list[dict[str, object]] = []
|
||||
for sprite in placeholder_sprites.values():
|
||||
atlases.append({
|
||||
"id": sprite.atlas_id,
|
||||
"fileName": sprite.file_name,
|
||||
"width": sprite.width,
|
||||
"height": sprite.height,
|
||||
})
|
||||
sprites.append({
|
||||
"id": f"sprite:{sprite.shape_code}:0",
|
||||
"atlasId": sprite.atlas_id,
|
||||
"shape": sprite.shape_code,
|
||||
"frame": 0,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": sprite.width,
|
||||
"height": sprite.height,
|
||||
"xoff": sprite.origin_x,
|
||||
"yoff": sprite.origin_y,
|
||||
})
|
||||
shape_definitions.append({
|
||||
"id": f"shape:{sprite.shape_code}",
|
||||
"shape": sprite.shape_code,
|
||||
"shapeHex": f"0x{sprite.shape_code:04x}",
|
||||
"family": None,
|
||||
"label": "Terrain",
|
||||
"kind": "terrain",
|
||||
"displayName": sprite.display_name,
|
||||
"description": sprite.description,
|
||||
"dimensions": {"x": sprite.width, "y": sprite.height, "z": 1},
|
||||
"visibilityTags": ["psx", "type-probe"],
|
||||
"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": sprite.display_name,
|
||||
"description": sprite.description,
|
||||
"roof": None,
|
||||
"semitransparency": None,
|
||||
"oob": None,
|
||||
},
|
||||
"catalogOverrides": {"roof": None, "semitransparency": None, "oob": None},
|
||||
"tableFallback": None,
|
||||
})
|
||||
|
||||
items: list[dict[str, object]] = []
|
||||
map_source_items: list[dict[str, object]] = []
|
||||
min_screen_left = None
|
||||
min_screen_top = None
|
||||
final_right = 0
|
||||
final_bottom = 0
|
||||
|
||||
for draw_order, record in enumerate(records):
|
||||
sprite = placeholder_sprites[(record.u0, record.u4, record.u5)]
|
||||
anchor_x = (record.u1 - min_x) * screen_scale
|
||||
anchor_y = (max_y - record.u2) * screen_scale
|
||||
screen_left = anchor_x - sprite.origin_x
|
||||
screen_top = anchor_y - sprite.origin_y
|
||||
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)
|
||||
final_right = max(final_right, screen_left + sprite.width)
|
||||
final_bottom = max(final_bottom, screen_top + sprite.height)
|
||||
item = {
|
||||
"id": f"item:{draw_order}:psx-region01-type:{record.side}:{record.row_index}",
|
||||
"mapSourceIndex": draw_order,
|
||||
"drawOrder": draw_order,
|
||||
"kind": "terrain",
|
||||
"label": "Terrain",
|
||||
"source": "psx-region01-type-probe",
|
||||
"world": {"x": record.u1, "y": record.u2, "z": record.u3},
|
||||
"mapNum": record.u5,
|
||||
"npcNum": record.u4,
|
||||
"nextItem": 0,
|
||||
"quality": record.u0,
|
||||
"frame": 0,
|
||||
"screen": {
|
||||
"left": screen_left,
|
||||
"top": screen_top,
|
||||
"right": screen_left + sprite.width,
|
||||
"bottom": screen_top + sprite.height,
|
||||
"width": sprite.width,
|
||||
"height": sprite.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 type-placement 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"placeholder mapping: type={record.u0:04X} variant={record.u4} lane={record.u5:04X}",
|
||||
],
|
||||
"frameSize": {
|
||||
"width": sprite.width,
|
||||
"height": sprite.height,
|
||||
"xoff": sprite.origin_x,
|
||||
"yoff": sprite.origin_y,
|
||||
},
|
||||
"egg": None,
|
||||
"npcPreview": None,
|
||||
"itemPreview": None,
|
||||
"shapeDefId": f"shape:{sprite.shape_code}",
|
||||
"spriteId": f"sprite:{sprite.shape_code}:0",
|
||||
}
|
||||
items.append(item)
|
||||
map_source_items.append(
|
||||
{
|
||||
"x": record.u1,
|
||||
"y": record.u2,
|
||||
"z": record.u3,
|
||||
"shape": sprite.shape_code,
|
||||
"frame": 0,
|
||||
"flags": record.u3,
|
||||
"quality": record.u0,
|
||||
"npcNum": record.u4,
|
||||
"mapNum": record.u5,
|
||||
"nextItem": 0,
|
||||
"source": "psx-region01-type-probe",
|
||||
"rawWords": [record.u0, record.u1, record.u2, record.u3, record.u4, record.u5],
|
||||
"recordSide": record.side,
|
||||
"rowIndex": record.row_index,
|
||||
"typeId": record.u0,
|
||||
"lane": record.u5,
|
||||
"variant": record.u4,
|
||||
"screenLeft": screen_left,
|
||||
"screenTop": screen_top,
|
||||
}
|
||||
)
|
||||
|
||||
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"])
|
||||
|
||||
lane_counts: dict[str, int] = {}
|
||||
for record in records:
|
||||
lane_key = f"0x{record.u5:04X}"
|
||||
lane_counts[lane_key] = lane_counts.get(lane_key, 0) + 1
|
||||
|
||||
return {
|
||||
"build": {
|
||||
"version": DEBUG_SCENE_VERSION,
|
||||
"fingerprint": "psx-lset1-l0-region01-type-placement",
|
||||
"generatedAt": "2026-04-06T00: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-type-probe": len(records)},
|
||||
"topFamilies": [{"family": None, "count": len(records)}],
|
||||
},
|
||||
"usage": {
|
||||
"status": "research",
|
||||
"confidence": "medium",
|
||||
"knownHints": [
|
||||
"Uses region-01 coordinate/type records with deterministic placeholder sprites instead of the invalid raw-bundle art mapping.",
|
||||
"This scene is for validating PSX map coherence and placement clustering before final art binding.",
|
||||
"Palette and final sprite lookup are intentionally deferred in this probe.",
|
||||
],
|
||||
"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": "Type-placement probe using region-01 records. This is the current best map-coherence debug scene, not final art output.",
|
||||
"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_type_probe",
|
||||
"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-type-placement",
|
||||
"generatedAt": "2026-04-06T00:00:00.000Z",
|
||||
"probeStats": {
|
||||
"screenScale": screen_scale,
|
||||
"typeCount": len({record.u0 for record in records}),
|
||||
"spriteKeyCount": len(placeholder_sprites),
|
||||
"laneCounts": lane_counts,
|
||||
"u1Range": [min_x, max_x],
|
||||
"u2Range": [min_y, max_y],
|
||||
},
|
||||
},
|
||||
"atlases": sorted(atlases, key=lambda entry: entry["id"]),
|
||||
"sprites": sorted(sprites, key=lambda entry: entry["id"]),
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
maps_root = args.output_root / "maps" / args.game_id / f"map-{args.map_id}"
|
||||
maps_root.mkdir(parents=True, exist_ok=True)
|
||||
placeholder_sprites = build_placeholder_sprites(args.output_root, records)
|
||||
scene = build_scene(
|
||||
records,
|
||||
placeholder_sprites,
|
||||
args.game_id,
|
||||
args.game_label,
|
||||
args.map_id,
|
||||
max(1, args.screen_scale),
|
||||
)
|
||||
(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 type-placement probe: 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())
|
||||
|
|
@ -771,7 +771,7 @@ def annotate_region_tim_counts(
|
|||
|
||||
|
||||
def parse_lset_wdl(data: bytes) -> dict[str, object] | None:
|
||||
if len(data) < 0x34:
|
||||
if len(data) < 0x38:
|
||||
return None
|
||||
|
||||
header_size = u32(data, 0)
|
||||
|
|
@ -781,6 +781,22 @@ def parse_lset_wdl(data: bytes) -> dict[str, object] | None:
|
|||
header_words = [u32(data, offset) for offset in range(0, header_size, 4)]
|
||||
audio_size = header_words[1]
|
||||
post_audio_start = header_size + audio_size
|
||||
section_sizes = [u32(data, offset) for offset in range(0x08, 0x38, 4)]
|
||||
sections: list[dict[str, int | str]] = []
|
||||
cursor = post_audio_start
|
||||
for index, size in enumerate(section_sizes):
|
||||
if size <= 0:
|
||||
continue
|
||||
if cursor + size > len(data):
|
||||
break
|
||||
sections.append(
|
||||
{
|
||||
"name": f"post_audio_section_{index:02d}",
|
||||
"offset": cursor,
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
cursor += size
|
||||
high_boundaries = sorted(
|
||||
{
|
||||
value
|
||||
|
|
@ -815,6 +831,7 @@ def parse_lset_wdl(data: bytes) -> dict[str, object] | None:
|
|||
|
||||
tim_hits = scan_tims(data)
|
||||
annotate_region_tim_counts(regions, tim_hits)
|
||||
annotate_region_tim_counts(sections, tim_hits)
|
||||
|
||||
return {
|
||||
"kind": "lset",
|
||||
|
|
@ -822,6 +839,8 @@ def parse_lset_wdl(data: bytes) -> dict[str, object] | None:
|
|||
"header_words": header_words,
|
||||
"audio_size": audio_size,
|
||||
"post_audio_start": post_audio_start,
|
||||
"section_sizes": section_sizes,
|
||||
"sections": sections,
|
||||
"high_offset_boundaries": high_boundaries,
|
||||
"regions": regions,
|
||||
"tim_hits": tim_hits,
|
||||
|
|
@ -880,6 +899,10 @@ def summarize(path: Path, summary: dict[str, object]) -> str:
|
|||
lines.append(f"header_size: 0x{summary['header_size']:X}")
|
||||
lines.append(f"audio_size: 0x{summary['audio_size']:X}")
|
||||
lines.append(f"post_audio_start: 0x{summary['post_audio_start']:X}")
|
||||
lines.append(
|
||||
"section_sizes: "
|
||||
+ ", ".join(f"0x{value:X}" for value in summary["section_sizes"])
|
||||
)
|
||||
lines.append(
|
||||
"high_offset_boundaries: "
|
||||
+ ", ".join(f"0x{value:X}" for value in summary["high_offset_boundaries"])
|
||||
|
|
@ -896,6 +919,15 @@ def summarize(path: Path, summary: dict[str, object]) -> str:
|
|||
+ f"{region['name']}: offset=0x{region['offset']:X} size=0x{region['size']:X} tims={tim_count}"
|
||||
)
|
||||
|
||||
if summary["kind"] == "lset":
|
||||
lines.append("sections:")
|
||||
for section in summary["sections"]:
|
||||
tim_count = section.get("tim_count", 0)
|
||||
lines.append(
|
||||
" "
|
||||
+ f"{section['name']}: offset=0x{section['offset']:X} size=0x{section['size']:X} tims={tim_count}"
|
||||
)
|
||||
|
||||
lines.append("tim_hits:")
|
||||
for hit in summary["tim_hits"]:
|
||||
lines.append(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue