17 KiB
PSX Map Exporter Spec
Goal
psx-map-exporter is a standalone Node.js probe for Crusader PSX map extraction.
It exists to prove a fresh end-to-end path from raw LSET*.WDL input to:
- extracted intermediate sprite assets under
.cache - a rendered map PNG under
.output
This project does not reuse Crusader-Map-Viewer code, scene caches, donor mappings, or sidecar summaries as binding inputs. It only consumes raw PSX assets plus the documented executable-backed findings from docs/psx and the live Ghidra session.
Scope
Version v0 is intentionally narrow.
It will:
- read one PSX
LSET*.WDLfile - parse the documented
0x38-byte top-level header - carve the post-audio map/art regions from header-derived boundaries
- parse the loader-sized post-audio sections as a second, higher-value view of the file layout
- extract the dense constructor-placement family from
post_audio_section_00 - keep the smaller root-dispatch family available as a comparison probe
- render a layered authored probe that can combine constructor placements with the smaller root-dispatch lane
- scan
post_audio_region_04for type-4/type-5 sprite bundles - decode bundle frames directly from the raw WDL
- write extracted frame PNGs to
.cache - compose a probe map PNG to
.output
It will not claim full runtime parity yet.
Known non-goals for v0:
- exact CLUT reproduction
- full stage-1 dependency-graph ordering
- full
post_audio_region_01/post_audio_region_02semantic decode
Landed in the current pass (was previously a non-goal):
- loader-faithful
DAT_800758d8active-header bank binding via explicit parses of theartInstallandoverrideblocks in bothSPEC_A.WDLand the map-localLSET*.WDL. SeeLoader LayoutandArt Binding Rulebelow.
Evidence Constraints
The implementation is grounded in these current facts from the docs and Ghidra:
LSET*.WDLbegins with 14 little-endianu32size fields (56 bytes total) describing the sequence of post-header blocks. The loader (wdl_resource_bundle_load_by_index @ 0x80039444) reads each size and carves the blocks in order:packPreamble,dispatchRootsSize,ctorPlacementsSize,packTailRewindSize,ctorPlacementSection,sectionPackBaseSize,policyTableSize,table8006754cSize,opcodeStreamsSize,detachedBlobSize,artInstallSize,stateBankSize,overrideSize,stateBank2Size.SPEC_A.WDL(global bundle A) begins with a fixed0x3520-byte VRAM preload, followed by the same 14-field size header. Bundle A skips thesectionPackanddetachedBlobblocks entirely; the remaining blocks are still present in the same order.- The old "audio blob at
word[1]" model was incorrect for this stream; the first 14 u32 words are block-size descriptors, not a single audio size. - The post-audio four-region carve is kept as a fallback diagnostic view but is no longer the primary input for record or art extraction.
- The small count-prefixed section-0 root-dispatch rows are real, but they are not the whole map object set.
- The dense constructor-placement records recovered from loader-sized
post_audio_section_00are the live-object seed source for rendering. post_audio_region_04is retained only as a fallback bundle source; real art now flows through theartInstallandoverrideblocks parsed out of the 14-u32 layout.- Type-4/type-5 drawable bundles expose width, height, palette mode/index, frame count, frame table offset, and data offset in the raw
0x58-byte bundle header. - Bundle frame entries use a
20-byte row with size, relative data offset, width, height, origin x/y, and flags. sprite_rle_decode_rowsuses row-local control bytes:- positive: repeat next byte N times
- negative: copy next
abs(N)literal bytes - zero: end row
- The executable projection basis (per
psx_project_object_main_visible @ 0x80040d44) is, in pixel units, with no extra scale factor:
screen_x = y - x
screen_y = 2z - \frac{x + y}{2}
- Record X/Y values are already in screen-pixel units. The live view-cull box is
camera +/- 0x140=+/- 320pixels, matching PSX screen width. The exporter therefore usesPSX_SCREEN_SCALE = 1; earlier builds multiplied by 2, producing over-spaced maps.
Loader Layout
Both SPEC_A.WDL and LSET*.WDL are fed to the same loader body, once per WDL pass. Each pass runs two art installs, two state-bank installs, and one override install. The loader reads the 14-u32 size header starting at offset 0 (LSET) or 0x3520 (SPEC_A) and lays out blocks sequentially.
artInstallblock (at0x800396a0for bundle A,0x80039988for bundle B): directory and payloads live atblock + 0x2718. The first0x2710bytes of the block are a scratch header cache used while resources are built. The directory format is{ u32 count; u32 directoryOffset; }at the start of the block, thencountentries of{ u32 size; u32 typeId; }atblock + 0x2718 + directoryOffset. For each non-zero entry the loader installs a built-resource pair{ u16 kind; u16 _; u32 resource_ptr }intoDAT_800758d8[typeId](0x18-byte stride).overrideblock (at0x80039730for bundle A,0x80039a18for bundle B): same directory format, but the payload cursor starts atblock + 8(directly after the 8-byte prefix). Each non-zero entry payload is a raw0x58-byte drawable header whose pointer is written straight intoDAT_800758d8[typeId]at0x8003977c/0x80039a64, overwriting whatever the earlierartInstallpass installed. Zero-size entries clear the bank slot.- Apply order per loader call: SPEC_A
artInstall→ SPEC_Aoverride→ LSETartInstall→ LSEToverride. Later writes win, so the finalDAT_800758d8state is a mix of built-resource pointers and raw override headers.
Evidence retained for reference
- The direct
typeWord -> bundle slotscan-order binding is disproven as a final art rule and is retained only as a diagnostic bundle-family probe.
Input Model
The exporter accepts either:
- a direct
--wdlpath - or a
--sourcepath relative to a PSX disc root
Default disc root for local workspace runs:
d:/Ghidra/Crusader-Map-Viewer/map_renderer/STATIC_PSX
Expected source examples:
LSET1/L0.WDLLSET4/L37.WDL
Output Layout
.cache
Per-run cache path:
.cache/<map-stem>/
Contents:
wdl-summary.jsonrecords.jsonbundles.jsonframe-manifest.jsonactive-header-overrides.jsonsprites/<bundle-offset>/frame_<n>.png
The cache is disposable. It exists to preserve intermediate evidence and make re-runs inspectable.
records.json now also records constructor-stream detection metadata when available: stream header offset, record start offset, reported count, and the initial structured-prefix run.
The cache also records candidate late DAT_800758d8 header-only override blobs as a standalone diagnostic. Those candidates are not used as final art binding yet.
wdl-summary.json now also emits sceneInterpretation, which is an explicit warning-bearing classification of what the current export most likely represents. For constructor-placement exports this should currently read as a constructor-fed live-object seed lane rather than a final visible-world reconstruction.
.output
Per-run final outputs:
.output/<map-stem>.png.output/<map-stem>.json.output/<map-stem>_<layer>.pngfor each rendered authored layer when layered mode is active
The JSON stores the final probe scene manifest used to draw the PNG.
The .output folder is reset at the start of each export so evaluation only sees artifacts from the current run.
The .output/<map-stem>.json manifest inherits sceneInterpretation from wdl-summary.json so consumers do not need to infer that warning from prose docs alone.
Record Extraction Rules
v0 pulls scene records from two loader-faithful lanes inside the section pack, matching the executable's two dispatch iterators. Both lanes are indexed through packSubranges from the 14-u32 loader layout.
Constructor placements (12-byte stride)
- Source:
ctorPlacementspack subrange (word 2). - Dispatcher:
psx_dispatch_section0_constructor_placements @ 0x800258cc. - Layout:
[u32 count][count * { u16 typeWord; u16 X; u16 Y; u16 Z; u16 selector; u16 flags }]. - The dispatcher passes each record directly to
descriptor_table[typeWord].slot0(record, 0)and downstream spawners (e.g.psx_object_create_compound_record) read exactly the six u16 fields. - Older heuristic region-01 / section-0 scans are retained as compatibility fallbacks when the loader block is absent or empty.
Dispatch roots (24-byte stride)
- Source:
dispatchRootspack subrange (word 1). - Dispatcher:
psx_dispatch_section0_dispatch_roots @ 0x800256b0. - Layout per record:
[u32 count]followed by 24-byte entries whose dispatcher-visible fields are:+0x04 u16 typeIdindexespsx_type_descriptor_table+0x08 u16 screenXused directly by the+/- 0x140view-cull+0x0A u16 screenYsame+0x10 u16 flagsbit 3 skips the record
- Remaining fields are forwarded to descriptor slot 0. The exporter empirically projects
+0x06as z,+0x0Cas selector,+0x0Eas lane, with relaxed plausibility because the live dispatcher only requires the fields above.
Selection modes
auto/combined/layeredmerges both lanes into one layered probe.constructors/region01returns only the 12-byte constructor placement records (preferring the loader block; falling back to the region-01 heuristic stream).roots/region00returns only the 24-byte dispatch-root records (preferring the loader block; falling back to the region-00 paired-record scan).
Renderable-record counts for the current validation set (auto mode):
LSET1/L0.WDL: 2334 total (1182 constructor placements + 1152 dispatch roots).LSET4/L37.WDL: 1463 total.
This is now a loader-faithful schema for the two main visible-object lanes. The older count-prefixed region heuristics are kept only as compatibility fallbacks.
Art Binding Rule
v0 now binds art via a loader-faithful DAT_800758d8 parse. For each scene record with typeWord = T:
- First preference: the bundle installed at
DAT_800758d8[T]by the LSEToverridepass (bundleSource = override-bank-lset). - Then: SPEC_A
overridepass (bundleSource = override-bank-spec-a). - Then: LSET
artInstallpass (bundleSource = art-install-lset). - Then: SPEC_A
artInstallpass (bundleSource = art-install-spec-a). - Fallback only when no loader block covers the type: raw
post_audio_region_04scan slot (bundleSource = raw-scan).
Mapping sources are recorded per item so failures stay auditable. For the current L0 / L9 / L37 validation runs there are no raw-scan fallbacks; every rendered type resolves through artInstall or override.
The opt-in runtime-map0-masked-proxy mode is retained as a secondary override for research against the runtime map-0 RAM snapshot. It no longer supplies the primary binding.
The older typeWord -> bundle slot scan-order rule is retained only as a named binding mode (raw) for negative-evidence experiments. It is not claimed as executable truth.
When debug labels are enabled for a map render, labels identify unique rendered resources rather than per-instance placements. The stable label key is bundle offset + clamped frame + resolved palette.
Rendering Rule
For each record:
- compute
screenXandscreenYfrom the documented projection basis - select frame index from
selectorWord, clamped to available frames - place sprite top-left at:
screenX - originXscreenY - originY
Projection sign convention
psx_project_object_main_visible @ 0x80040d44 writes proj_x = Y - X and
proj_y = 2*Z - (X + Y) / 2 to obj+0x78/0x7c. The engine's draw step at
0x80040e3c/0x80040e5c then computes screen = (cam - proj) - origin, which
flips the sign of the projection relative to canvas Y-down space. For a
camera-less full-map export the exporter bakes that flip into
projectCtorPlacement, so higher world-Z and smaller (X+Y) sit visibly higher
on the output PNG. The same projection is applied to both constructor
placements and dispatch-root records; dispatch-root X/Y fields are in world
coordinates, not pre-projected screen coordinates, despite the runtime
camera +/- 0x140 cull comparing against them directly.
Authored layer semantics
The two authored lanes carry different responsibilities:
constructors: static level geometry — walls, floors, architecture placed bypsx_dispatch_section0_constructor_placements.roots: interactive / dynamic objects — crates, terminals, doors, pickups placed bypsx_dispatch_section0_dispatch_roots.
Despite the dispatcher name, the roots lane is not the map background; it is
the live-object seed list. For the exporter, "constructors" is the geometry
layer and "roots" is the object layer.
Painter's order
The exporter sorts items before blitting using the following keys, in order:
stageascending.stage = 1whentypeWord === 4orlaneWord & 0x0400is set; those overlays draw last.- Authored-layer priority:
constructors(0) beforeroots(1). Static geometry draws first so interactive props in the same room do not get hidden behind the floor or wall pieces that occupy the same cell. - Isometric depth ascending: back-to-front by world
X + Y(isometric ground-depth axis). Falls back to projectedscreenYwhen world coordinates are unavailable. - World
Zascending within the same ground cell so lower elevations draw before taller objects sharing the same footprint. screenXascending as a stable tie-breaker.
This is still an approximation of the engine's stage-1 graph order but is closer to what an isometric painter's algorithm would produce than the earlier screenY-only sort.
The rendered PNG uses a neutral opaque background by default so probe silhouettes are legible without relying on transparency.
Color Rule
The exporter resolves palettes entirely from the WDL contents. It does not require any RAM or VRAM dump; those paths are now optional research overrides.
The map-local palette blob lives at headerWords[2] .. headerWords[2] + 0x1000
(4096 bytes = 2048 colors = 128 × 16-entry CLUTs). The blob is what the engine
uploads to VRAM rows 0xf0 .. 0xf7 on map load; each VRAM row is 16 CLUTs
wide so the 128 CLUTs tile exactly 8 rows of 16 CLUTs.
Resolution rules by bundle mode:
- Mode 2 (4bpp): the bundle header's
paletteIndexat+0x14is the 16-entry CLUT index into the WDLpalettes16bank. When that index points at a sparse/empty CLUT the exporter falls back to a per-bundle palette sweep that picks a CLUT covering the pixel-index set used by the bundle frames. - Mode 1 (8bpp): the 256-color CLUT is the concatenation of 16 consecutive
16-entry CLUTs from the WDL bank. The bundle's
paletteIndexis treated as the starting CLUT index. For the current L0 dataset every mode-1 bundle storespaletteIndex = 0, which is the top-left 256-color bank. Mode-1 color fidelity is therefore approximate until the level-specific 256-CLUT source (suspected to live in thestateBankblock) is decoded — tracked as a follow-up.
Transparent pixel index 0 stays transparent during blit regardless of the
color value stored at CLUT index 0.
CLI
Primary command:
node src/cli.js --source LSET1/L0.WDL
Supported options:
--source <relative-path>--wdl <absolute-or-relative-file>--disc-root <path>--binding-mode <raw|runtime-map0-masked-proxy>--map-source <auto|combined|layered|constructors|roots|region01|region00>--out-name <stem>
Success Criteria
v0 is successful if it can:
- parse a raw
LSET*.WDL - recover the loader-sized section view alongside the region carve
- scan bundles directly from
post_audio_region_04 - decode at least one frame from raw data
- extract a stable constructor-placement record set from
post_audio_section_00 - write extracted sprite PNGs into
.cache - write a readable diagnostic probe PNG into
.output
Planned Follow-Ups
- decode the
stateBankandstateBank2blocks to recover the level-specific 256-color CLUT used by mode-1 sprites. Current mode-1 palettes default to CLUT-bank start 0, which produces plausible colors for some sprites but renders many indoor floor tiles as solid green plates. - extend
sceneInterpretationso it reflects the landed loader-faithful binding instead of the older repeated-wrong-art warning. - recover the engine's stage-1 graph ordering instead of approximating with
isometric
(X + Y, Z, screenX)sort keys. - compare the probe scene against fixed live samples such as
map 104without reintroducing viewer-side donor assumptions.