25 KiB
PSX Map Rendering Architecture
Scope
This document explains the current evidence-backed model for how the PlayStation build stores map data, how the executable turns that data into live objects and visible primitives, and how a viewer can reassemble the same data into a coherent scene.
It is not a replacement for the running note in docs/psx/psx.md. Instead, it consolidates the map-rendering findings from that note and the related Ghidra work into one detailed technical reference.
Primary target:
- retail PlayStation
SLUS_002.68
Primary use:
- guide viewer/exporter work in
Crusader-Map-Viewer - preserve the executable-backed reasoning behind the current PSX scene format
- make the remaining unresolved gap explicit instead of leaving it spread across many small notes
Executive Summary
The current strongest model is:
- A PSX level is not stored as one flat placement table.
LSET*.WDLloads a multi-section bundle with at least three map-relevant classes of data:- authored root/dispatch records
- authored constructor-placement records
- per-type runtime banks for art, state scripts, variants, and simple-record payloads
- Constructors turn those authored rows into live objects with fixed-point world coordinates, a cached drawable resource pointer, a state-script bank pointer, a variant bank pointer, and a preserved pointer back to the original authored record.
- The runtime does not draw directly from the authored selector byte. It advances and sometimes reseats the live state script after spawn, then uses the current script word to drive frame choice and variant lookup.
- There are two separate world-facing render lanes:
- stage 1: main visible-object list
- stage 2: queued special-visible list
- The viewer can already reconstruct placement, projection, most resource loading, and much of the draw path from executable evidence.
- The main remaining blocker is the last live state-to-art rule for unresolved families such as
0x0042and0x0049. The map is still unreadable in practical terms because those families still fall back to placeholders.
So the problem is no longer "how do PSX coordinates work" or "where do draw rectangles come from". The problem is now much narrower: the viewer still does not fully reproduce the executable's final runtime art-state resolution.
Evidence Base
This model is grounded by a combination of static and runtime-adjacent evidence:
- Ghidra decompilation of retail
SLUS_002.68 - the renderer-local
.cachescene/export pipeline - WDL section parsing and bank extraction work
- live comparison between exported scene fields and executable object layouts
- repeated correction of earlier bad hypotheses, especially the disproven "small top-level record stream == whole map" assumption
Most important named functions and data anchors:
wdl_resource_bundle_load_by_indexpsx_object_create_simple_recordpsx_object_create_compound_recordpsx_object_select_state_scriptpsx_object_advance_state_scriptpsx_object_lookup_variant_entrypsx_object_integrate_motion_and_route_visiblepsx_project_object_main_visiblepsx_project_object_special_visible_queuepsx_draw_world_visible_passespsx_draw_main_visible_objectpsx_draw_special_visible_queuepsx_level_root_record_stream(DAT_800678f4)psx_section0_dispatch_root_records(DAT_80067720)psx_section0_constructor_placement_records(DAT_800678f0)psx_type_art_template_bank(DAT_800758d8)psx_type_simple_component_bank(DAT_800758d0)psx_type_state_script_bank(DAT_800758cc)psx_type_companion_extents_bank(DAT_800758d4)psx_level_detached_blob(DAT_8006767c)psx_level_decompressed_state_buffer(DAT_8006769c)psx_level_runtime_header_block(DAT_80067794)
How Map Data Is Stored
1. LSET*.WDL is a multi-section bundle
The executable-backed loader model is no longer speculative:
wdl_resource_bundle_load_by_indexopensSPEC_A.WDLand the selectedLSET*.WDL- it reads a
0x38header whose first nine dwords act as section sizes - it then lays out multiple runtime pointers rather than one monolithic map blob
Current map-relevant runtime destinations:
psx_level_root_record_stream(DAT_800678f4): top-level root record streampsx_section0_dispatch_root_records(DAT_80067720): secondary0x18-stride authored record familypsx_section0_constructor_placement_records(DAT_800678f0):0x0c-stride constructor-placement familypsx_type_art_template_bank(DAT_800758d8): per-type art/template descriptor bankpsx_type_simple_component_bank(DAT_800758d0): per-type simple-record payload bankpsx_type_state_script_bank(DAT_800758cc): per-type state-script bankpsx_type_companion_extents_bank(DAT_800758d4): per-type variant/companion bankDAT_800675f8: per-type flags tablepsx_level_detached_blob(DAT_8006767c): additional detached level blobDAT_8006b5d8 -> psx_level_decompressed_state_buffer(DAT_8006769c): optional decompressed0x3e00runtime/state substratepsx_level_runtime_header_block(DAT_80067794): separate0x50level runtime-header block
2. The map is split across authored families, not one row type
The viewer's old region00/region01 labels were a useful stepping stone, but the current executable-backed names are better:
section0_dispatch_roots: closest to theDAT_80067720/ root-dispatch familysection0_constructor_placements: closest to theDAT_800678f0constructor-input family
These runtime anchors are now named the same way in the live Ghidra database:
section0_dispatch_rootsaligns withpsx_section0_dispatch_root_recordssection0_constructor_placementsaligns withpsx_section0_constructor_placement_records
These families do different jobs.
section0_dispatch_roots:
- generic runtime-object descriptors
- fed into per-type dispatch handlers
- not yet directly final render primitives
- include families that still need downstream runtime state/variant logic before visible art is known
section0_constructor_placements:
- tighter constructor-facing rows
- much closer to direct object spawn inputs
- already usable for a large part of the viewer export
3. The late template bank matters
The per-type art bank in DAT_800758d8 is not taken from the earlier small-section heuristic.
Current best read:
- the useful late
DAT_800758d8candidate sits in a late large section - it decodes only when that section is treated as a bank with an embedded
+0x38parse start - on retail map
9, that correction lifts resolved bundle-mapped items from0to111
This is one of the strongest pieces of evidence that the viewer must respect executable loader structure rather than broad file-wide scans or first-match heuristics.
Constructor Record Layouts
Two constructor families are now strong enough to describe directly.
Compound-record constructor
psx_object_create_compound_record reads:
typefromrecord + 0x00xfromu16at+0x02yfromu16at+0x04zfrom byte+0x06- initial state selector from byte
+0x08 - flags from
+0x0a
Simple-record constructor
psx_object_create_simple_record reads:
typefromrecord + 0x04xfromu16at+0x08yfromu16at+0x0azfrom byte+0x0c- initial state selector from byte
+0x0e
Common constructor outputs
Both constructors:
- write authored coordinates into object fields
+0x3c/+0x40/+0x44as16.16fixed-point - preserve the original authored source-record pointer at
obj + 0xa0 - resolve the per-type art bank and seed a drawable resource pointer at
obj + 0x10 - store the per-type variant bank at
obj + 0x84 - store the per-type state-script bank at
obj + 0x88 - call
psx_object_select_state_scriptto seed the initial live state
That preserved source-record pointer at obj + 0xa0 is especially important because it closes the palette-override provenance: later draw code really is reading authored bytes directly.
Runtime Banks And Object Fields
The current best object-centric map/render model revolves around a small cluster of object fields.
Per-type banks
psx_type_art_template_bank(DAT_800758d8): art/template descriptor bankpsx_type_simple_component_bank(DAT_800758d0): simple-record local payload bankpsx_type_state_script_bank(DAT_800758cc): state-script bankpsx_type_companion_extents_bank(DAT_800758d4): variant/companion bank
Important object fields
obj + 0x10: current drawable resource pointerobj + 0x20..0x2e: projected on-screen rectangleobj + 0x3c/+0x40/+0x44: fixed-point worldx/y/zobj + 0x54/+0x58/+0x5c: next/target world position cluster used by motion/integration helpersobj + 0x60/+0x64/+0x68: motion vector used by heading/state reselection helpersobj + 0x78/+0x7c: intermediate projected screen anchor in fixed-pointobj + 0x84: current variant bank pointerobj + 0x88: current state-script table pointerobj + 0x8c/+0x90: active script base and current script cursorobj + 0x94: current script word, which is already the live frame/state index used by later draw helpersobj + 0x9e: original authored selector stored bypsx_object_select_state_scriptobj + 0xa0: original authored source-record pointer
The crucial distinction is:
obj + 0x9eis the authored input selectorobj + 0x94is the current live script word after advancement/reselection
The executable's later art-facing logic follows obj + 0x94, not obj + 0x9e.
State Scripts, Variants, And Why The Map Is Still Unreadable
1. Initial state selection is not final state selection
psx_object_select_state_script:
- stores the authored selector at
obj + 0x9e - chooses an initial script base from
DAT_800758cc - seeds
obj + 0x8c/+0x90
But that is only the starting point.
2. The runtime advances and sometimes reseats the live script
psx_object_advance_state_script:
- interprets sentinel-driven script records
- updates
obj + 0x94from the current script word - reruns
psx_object_lookup_variant_entry
Verified sentinel/control behavior now includes:
0xfffe: non-visible audio/effect helper dispatch0xfffd: direct in-family jump0xfffc: immediate switch to subsidiary script-table entry0xfffb: scan-forward variant that consumes the next in-band0xfffdselector before switching
3. Variant lookup is indexed by live state, not by raw placement selector
psx_object_lookup_variant_entry:
- reads
DAT_800758d4 - indexes it by
obj + 0x94 - does not index directly by
obj + 0x9e
That is the key split between authored placement metadata and runtime-visible state.
4. The current DAT_800758d4 evidence points to companion extents, not final art
The newly traced consumer path is narrower and more concrete than the earlier placeholder-art theory.
psx_object_advance_state_script:
- reruns
psx_object_lookup_variant_entry - sign-extends the returned three bytes into
obj + 0x30/+0x34/+0x38 - does not update
obj + 0x10 - does not replace the live frame index stored at
obj + 0x94
Downstream consumers of obj + 0x30/+0x34/+0x38 are now verified in the interaction lane:
psx_object_test_overlap_3duses those fields as the object's overlap extents againstobj + 0x54/+0x58/+0x5cpsx_object_update_contact_block_flagsuses the same extents while setting directional block/contact bitspsx_object_reselect_state_from_target_vectorandpsx_type4_reselect_motion_stateuse target-object+0x30/+0x34/+0x38as target bounds while reseating heading-based state
By contrast, the visible projectors and draw helpers still take visible art only from:
- the drawable resource pointer at
obj + 0x10 - the live frame/state word at
obj + 0x94
So DAT_800758d4 is currently better described as a per-state companion-extents bank than as the last missing direct art table.
5. Interaction and heading state can rewrite the live script
The runtime does not only advance scripts linearly.
Verified reselection path:
psx_object_reselect_state_from_target_vectorpsx_object_quantize_motion_heading16psx_quantize_vector_heading16
Verified interaction/reselection cluster:
psx_type4_update_delayed_interactionpsx_type4_reselect_motion_statepsx_object_update_nearby_interactionspsx_object_test_overlap_3dpsx_object_update_contact_block_flagspsx_object_register_contact_pair
These helpers prove that post-spawn interaction and motion state can reseat the active script from runtime heading, not only from the authored row.
That is why exported 0x0042 selectors 3 and 4 do not contradict an earlier three-script file read: some of those live selectors are runtime outcomes.
6. This is the current blocker
The map is still unreadable because the viewer still does not fully reproduce that last runtime bridge:
- authored row
- initial state bank entry
- post-spawn script advancement/reselection
- live companion-extents lookup
- final visible resource/frame choice
Projection, placement decoding, list routing, and primitive submission are no longer the main unknowns.
Per-Frame World And Render Pipeline
1. Outer lifecycle
psx_level_session_loop: outer level-session loopwdl_resource_bundle_load_by_index: level load and runtime-bank setuppsx_world_frame_tick: normal per-frame world looppsx_draw_world_visible_passes: top-level draw submission
2. Authored record dispatch before live-object draw
The executable still operates on authored families each frame before or alongside live objects:
psx_dispatch_section0_dispatch_roots: dispatches theDAT_80067720family plus nearby fixed-size entriespsx_dispatch_section0_constructor_placements: dispatches theDAT_800678f0constructor-placement family
These are the closest executable matches for the viewer's exported authored record families.
3. Live-object update lane
psx_run_live_object_type_updates: per-type live-object update callback passpsx_run_live_object_behavior_callbacks: later per-object behavior callback passpsx_object_integrate_motion_and_route_visible: integrates motion, updates visibility flags, advances script state, and routes the object to the appropriate render lane
4. Stage 1 versus stage 2 is a real runtime split
Stage 1:
- projector:
psx_project_object_main_visible - draw helper:
psx_draw_main_visible_object - object membership stored in
psx_main_visible_list(DAT_8006ad5c)
Stage 2:
- projector:
psx_project_object_special_visible_queue - draw helper:
psx_draw_special_visible_queue - queue stored in
psx_special_visible_queue(DAT_80078b70) with countpsx_special_visible_queue_count(DAT_80067472) - reset each frame by
psx_reset_special_visible_queue
This is not a HUD-only split. Stage 2 is a real world-facing lane.
5. Small wrapper helpers prove the split is intentional
Recovered wrappers:
psx_spawn_compound_record_advance_state_oncepsx_spawn_simple_record_set_active_flagpsx_object_refresh_main_visible_and_cleanuppsx_object_advance_state_and_queue_special_visible
These wrappers show that the executable contains dedicated short-form helpers for:
- spawn then immediately advance state
- spawn then mark active
- refresh and project into stage 1
- advance state and immediately queue into stage 2
That is strong evidence that route selection is a first-class runtime decision, not an incidental byproduct of the draw code.
Projection Model
The PSX executable-backed coordinate model is now stable enough for viewer use.
World coordinates
Authored world values are stored in object fields:
obj + 0x3c:xas16.16obj + 0x40:yas16.16obj + 0x44:zas16.16
Projection
psx_project_object_main_visible and psx_project_object_special_visible_queue use:
screen_anchor_x = y - xscreen_anchor_y = 2*z - (x + y)/2
They write the fixed-point intermediate anchor to:
obj + 0x78obj + 0x7c
Then they subtract camera origin and per-frame origin metrics to produce the final on-screen rectangle at:
obj + 0x20..0x2e
This matters for the viewer because PSX sprites should be positioned from those authored screen rectangles, not from a DOS-style reconstructed wireframe.
Render Submission Path
1. Top-level draw pass
psx_draw_world_visible_passes:
- asks
psx_main_visible_list_get_sorted_slicefor the current sorted stage-1 slice - iterates it through
psx_draw_main_visible_object - then draws stage 2 through
psx_draw_special_visible_queue - then executes the HUD/overlay pass
2. Stage-1 visible-list management
Named helpers:
psx_main_visible_list_addpsx_main_visible_list_removepsx_main_visible_list_rebucket_objectpsx_main_visible_list_refresh_from_live_chainpsx_main_visible_list_sort_rangepsx_main_visible_list_get_sorted_slice
The sorter is important because it shows that draw order is not just screen-y or type-only ordering. It uses dependency and tie-break logic tied to the DAT_800915xx ordering data plus depth/position rules.
3. Frame metrics and final resource-specific submitters
Frame metric accessors:
psx_resource_frame_origin_xpsx_resource_frame_origin_ypsx_resource_frame_widthpsx_resource_frame_height
Resource-specific submitters:
psx_sprite_resource_submit_framepsx_image_table_submit_frame
The critical point is that both paths take the live frame index from obj + 0x94.
4. Resource creation path
psx_create_image_resource_from_descriptor:
- type-4 descriptors bind a single image resource through
image_resource_bind_vram_slot - type-5 descriptors allocate and upload multi-frame bundles through
image_bundle_load_to_vram
This is why the constructors can seed obj + 0x10 early and then let later code only vary frame index and state.
Palette Selection
Palette handling is partly closed and partly still open.
Closed:
psx_draw_main_visible_objectreads palette overrides from the original source-record pointer atobj + 0xa0- for types
0x003e..0x00ab, it uses the high byte of source word+0x06 - for types
>= 0x00ac, it uses the high byte of source word+0x0c
Open:
- the full rule for all resource classes and all placement families is not yet completely closed
- the viewer still needs broader CLUT-selection recovery so common cases render with runtime-correct colors without heuristics
So palette work is real, but it is no longer confused with the deeper unresolved art-state bridge.
How To Reassemble A PSX Map In A Viewer
This is the current best practical recipe.
Step 1: parse LSET*.WDL as a multi-section bundle
Do not treat the first small record stream as the whole map.
Required outputs from the loader stage:
- root/dispatch family payloads
- constructor-placement family payloads
- per-type banks for art/state/variant/simple payloads
- detached extra blob(s)
- optional decoded
DAT_8006769csubstrate/state buffer - runtime-header block metadata
Step 2: preserve authored rows as authored rows
Keep the exported record families as reversible scene metadata.
Useful naming already in use:
section0_dispatch_rootssection0_constructor_placements
Do not flatten them into one inferred placement family too early.
Step 3: reconstruct constructor inputs faithfully
For each candidate placement row, reconstruct at least:
type- authored selector byte
- authored
x/y/z - original source bytes needed for later palette and state analysis
Store the original row pointer or offset in exported metadata where practical.
Step 4: resolve per-type runtime banks
For each type, export or reconstruct:
- art/template descriptor
- state-script bank
- companion-extents/variant bank
- simple-record payload bank when present
This is already partly supported by the current cache/export path through stateLayers and related scene metadata.
Step 5: seed a viewer-side live object model
Minimum object fields for a faithful viewer simulation:
- resource pointer or resource descriptor reference
- world
x/y/z - current script word
- original selector
- current companion extents from
obj + 0x30/+0x34/+0x38 - type flags
- source-record pointer or reconstructed source bytes
- visible lane classification if known
Step 6: project with the executable's isometric transform
Use:
screen_x = y - xscreen_y = 2*z - (x + y)/2
Then apply per-frame origin/size offsets and camera subtraction, just as the executable projectors do.
Step 7: separate stage 1 and stage 2
Do not assume one visible-object list.
Viewer-side representation should support:
- stage-1 main visible list behavior
- stage-2 queued special-visible path
Even if the first viewer implementation collapses them visually, the underlying scene model should keep them distinct.
Step 8: honor draw-order rules explicitly
The main visible list is sorted, not appended blindly.
A viewer that only sorts by simple y or z will eventually diverge. The current best approximation should be based on the executable-backed visible-list sort behavior, and exported metadata should preserve enough object/dependency information to improve that later.
Step 9: use authored palette bytes where proven
The viewer/exporter should keep both:
- default resource palette assumptions
- authored placement override bytes from the preserved source record
That keeps palette work reversible and lets the viewer use stronger executable-backed overrides without hard-wiring them into flattened output.
Step 10: keep the unresolved state-to-art rule explicit
For unresolved families, do not pretend a flat type -> frame table is solved.
Current best practice:
- use verified executable-backed frame maps only where they are genuinely closed
- mark unresolved families as provisional
- preserve the exported state metadata and the
DAT_800758d4companion-extents metadata so later passes can replace placeholders without redoing the whole loader - do not treat
DAT_800758d4as a direct fallback art selector unless a family-specific consumer proves otherwise
What A Viewer Can Already Do Reliably
Already defensible from evidence:
- load PSX maps from the correct
LSET*.WDLfamilies - separate authored record families instead of flattening them
- reconstruct multi-level
zvalues for the constructor-placement lane - use executable-backed projection math
- separate stage-1 and stage-2 world lanes in the scene model
- resolve a first subset of per-type real art from the corrected late
DAT_800758d8bank - preserve state banks, companion-extents banks, and decoded runtime blobs as research metadata
What Still Prevents A Fully Readable Map
The remaining blocker is now narrow and concrete:
- the exact last rule that turns live script/variant state into final visible art for unresolved families
More specifically:
- the constructors, routing, projection, draw passes, and resource submission path are now substantially understood
DAT_800758d4now looks like per-state companion extents used by overlap/contact logic, not the missing final art table- but for unresolved families the viewer still does not fully reproduce how
DAT_800758cc, runtime reselection, and family-specific drawable resource/frame presentation interact after the live script changes
That is why the current viewer output is still unreadable as a practical map even though so much of the storage and render machinery is now mapped.
Recommended Viewer Strategy From Here
Short-term:
- keep the current executable-backed export model
- preserve all state metadata in exported scene JSON
- export the
DAT_800758d4-backed signed companion extents as explicit runtime-bounds metadata instead of treating them as likely art selectors - avoid broad fallback art heuristics that overwrite evidence
- use narrow verified family-specific rules only where backed by executable behavior
Current status of that first export step:
- the PSX cache builder now decodes
DAT_800758d4as a packed per-state signed extents table - exported
stateLayerspreserve those decoded extents for each type - each exported scene item and
mapSourcerow now carries the resolvedcompanionExtentstuple for its chosen live state when available
Medium-term:
- use the recovered companion extents to improve viewer-side inspection and future occlusion/contact overlays
- recover the remaining family-specific state-to-art bridge for unresolved root-dispatch families without assuming
DAT_800758d4is the art source - close palette selection more broadly once the art-state path is stable enough
Long-term:
- replace placeholder-heavy families with executable-backed final art selection
- keep scene export reversible so future corrections do not require a fresh reverse-engineering pass over the raw files
Current Best One-Line Model
The PSX map is stored as a multi-section level bundle plus per-type runtime banks; the executable turns authored rows into live objects, advances and sometimes reseats their state scripts, updates companion extents from DAT_800758d4, projects them into one of two world-facing render lanes, and finally draws resource/frame pairs driven by the live script word. The viewer can now reconstruct most of that chain, and the remaining unreadable output is concentrated in the last unresolved live state-to-art bridge for a few still-placeholder-heavy families.