Crusader_Decomp/docs/psx/map-rendering.md
2026-04-07 17:16:44 +02:00

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:

  1. A PSX level is not stored as one flat placement table.
  2. LSET*.WDL loads 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
  3. 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.
  4. 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.
  5. There are two separate world-facing render lanes:
    • stage 1: main visible-object list
    • stage 2: queued special-visible list
  6. The viewer can already reconstruct placement, projection, most resource loading, and much of the draw path from executable evidence.
  7. The main remaining blocker is the last live state-to-art rule for unresolved families such as 0x0042 and 0x0049. 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 .cache scene/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_index
  • psx_object_create_simple_record
  • psx_object_create_compound_record
  • psx_object_select_state_script
  • psx_object_advance_state_script
  • psx_object_lookup_variant_entry
  • psx_object_integrate_motion_and_route_visible
  • psx_project_object_main_visible
  • psx_project_object_special_visible_queue
  • psx_draw_world_visible_passes
  • psx_draw_main_visible_object
  • psx_draw_special_visible_queue
  • psx_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_index opens SPEC_A.WDL and the selected LSET*.WDL
  • it reads a 0x38 header 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 stream
  • psx_section0_dispatch_root_records (DAT_80067720): secondary 0x18-stride authored record family
  • psx_section0_constructor_placement_records (DAT_800678f0): 0x0c-stride constructor-placement family
  • psx_type_art_template_bank (DAT_800758d8): per-type art/template descriptor bank
  • psx_type_simple_component_bank (DAT_800758d0): per-type simple-record payload bank
  • psx_type_state_script_bank (DAT_800758cc): per-type state-script bank
  • psx_type_companion_extents_bank (DAT_800758d4): per-type variant/companion bank
  • DAT_800675f8: per-type flags table
  • psx_level_detached_blob (DAT_8006767c): additional detached level blob
  • DAT_8006b5d8 -> psx_level_decompressed_state_buffer (DAT_8006769c): optional decompressed 0x3e00 runtime/state substrate
  • psx_level_runtime_header_block (DAT_80067794): separate 0x50 level 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 the DAT_80067720 / root-dispatch family
  • section0_constructor_placements: closest to the DAT_800678f0 constructor-input family

These runtime anchors are now named the same way in the live Ghidra database:

  • section0_dispatch_roots aligns with psx_section0_dispatch_root_records
  • section0_constructor_placements aligns with psx_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_800758d8 candidate sits in a late large section
  • it decodes only when that section is treated as a bank with an embedded +0x38 parse start
  • on retail map 9, that correction lifts resolved bundle-mapped items from 0 to 111

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:

  • type from record + 0x00
  • x from u16 at +0x02
  • y from u16 at +0x04
  • z from byte +0x06
  • initial state selector from byte +0x08
  • flags from +0x0a

Simple-record constructor

psx_object_create_simple_record reads:

  • type from record + 0x04
  • x from u16 at +0x08
  • y from u16 at +0x0a
  • z from byte +0x0c
  • initial state selector from byte +0x0e

Common constructor outputs

Both constructors:

  • write authored coordinates into object fields +0x3c/+0x40/+0x44 as 16.16 fixed-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_script to 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 bank
  • psx_type_simple_component_bank (DAT_800758d0): simple-record local payload bank
  • psx_type_state_script_bank (DAT_800758cc): state-script bank
  • psx_type_companion_extents_bank (DAT_800758d4): variant/companion bank

Important object fields

  • obj + 0x10: current drawable resource pointer
  • obj + 0x20..0x2e: projected on-screen rectangle
  • obj + 0x3c/+0x40/+0x44: fixed-point world x/y/z
  • obj + 0x54/+0x58/+0x5c: next/target world position cluster used by motion/integration helpers
  • obj + 0x60/+0x64/+0x68: motion vector used by heading/state reselection helpers
  • obj + 0x78/+0x7c: intermediate projected screen anchor in fixed-point
  • obj + 0x84: current variant bank pointer
  • obj + 0x88: current state-script table pointer
  • obj + 0x8c/+0x90: active script base and current script cursor
  • obj + 0x94: current script word, which is already the live frame/state index used by later draw helpers
  • obj + 0x9e: original authored selector stored by psx_object_select_state_script
  • obj + 0xa0: original authored source-record pointer

The crucial distinction is:

  • obj + 0x9e is the authored input selector
  • obj + 0x94 is 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 + 0x94 from the current script word
  • reruns psx_object_lookup_variant_entry

Verified sentinel/control behavior now includes:

  • 0xfffe: non-visible audio/effect helper dispatch
  • 0xfffd: direct in-family jump
  • 0xfffc: immediate switch to subsidiary script-table entry
  • 0xfffb: scan-forward variant that consumes the next in-band 0xfffd selector 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_3d uses those fields as the object's overlap extents against obj + 0x54/+0x58/+0x5c
  • psx_object_update_contact_block_flags uses the same extents while setting directional block/contact bits
  • psx_object_reselect_state_from_target_vector and psx_type4_reselect_motion_state use target-object +0x30/+0x34/+0x38 as 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_vector
  • psx_object_quantize_motion_heading16
  • psx_quantize_vector_heading16

Verified interaction/reselection cluster:

  • psx_type4_update_delayed_interaction
  • psx_type4_reselect_motion_state
  • psx_object_update_nearby_interactions
  • psx_object_test_overlap_3d
  • psx_object_update_contact_block_flags
  • psx_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 loop
  • wdl_resource_bundle_load_by_index: level load and runtime-bank setup
  • psx_world_frame_tick: normal per-frame world loop
  • psx_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 the DAT_80067720 family plus nearby fixed-size entries
  • psx_dispatch_section0_constructor_placements: dispatches the DAT_800678f0 constructor-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 pass
  • psx_run_live_object_behavior_callbacks: later per-object behavior callback pass
  • psx_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 count psx_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_once
  • psx_spawn_simple_record_set_active_flag
  • psx_object_refresh_main_visible_and_cleanup
  • psx_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: x as 16.16
  • obj + 0x40: y as 16.16
  • obj + 0x44: z as 16.16

Projection

psx_project_object_main_visible and psx_project_object_special_visible_queue use:

  • screen_anchor_x = y - x
  • screen_anchor_y = 2*z - (x + y)/2

They write the fixed-point intermediate anchor to:

  • obj + 0x78
  • obj + 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:

  1. asks psx_main_visible_list_get_sorted_slice for the current sorted stage-1 slice
  2. iterates it through psx_draw_main_visible_object
  3. then draws stage 2 through psx_draw_special_visible_queue
  4. then executes the HUD/overlay pass

2. Stage-1 visible-list management

Named helpers:

  • psx_main_visible_list_add
  • psx_main_visible_list_remove
  • psx_main_visible_list_rebucket_object
  • psx_main_visible_list_refresh_from_live_chain
  • psx_main_visible_list_sort_range
  • psx_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_x
  • psx_resource_frame_origin_y
  • psx_resource_frame_width
  • psx_resource_frame_height

Resource-specific submitters:

  • psx_sprite_resource_submit_frame
  • psx_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_object reads palette overrides from the original source-record pointer at obj + 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_8006769c substrate/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_roots
  • section0_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 - x
  • screen_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_800758d4 companion-extents metadata so later passes can replace placeholders without redoing the whole loader
  • do not treat DAT_800758d4 as 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*.WDL families
  • separate authored record families instead of flattening them
  • reconstruct multi-level z values 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_800758d8 bank
  • 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_800758d4 now 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.

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_800758d4 as a packed per-state signed extents table
  • exported stateLayers preserve those decoded extents for each type
  • each exported scene item and mapSource row now carries the resolved companionExtents tuple 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_800758d4 is 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.