PSX Decompilation

This commit is contained in:
MaddoScientisto 2026-04-07 00:15:44 +02:00
commit bbd29b1f10
25 changed files with 1921 additions and 701 deletions

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

View file

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