PSX Research

This commit is contained in:
Marco 2026-04-07 17:16:44 +02:00
commit 94c49ac5bd
16 changed files with 2052 additions and 16 deletions

622
docs/psx/map-rendering.md Normal file
View file

@ -0,0 +1,622 @@
# 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.
## 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_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.