Crusader_Decomp/docs/psx/psx.md
2026-04-13 15:59:50 +02:00

1208 lines
101 KiB
Markdown

# Crusader: No Remorse (PlayStation) Recon
## Scope
- Target disc tree: `E:\emu\psx\Crusader - No Remorse`
- Goal of this pass: identify the boot executable, separate likely code from content, and find the most practical first extraction routes for PS1 assets.
## Function Coverage Census
Current live census for the active Ghidra `SLUS_002.68` session:
- Raw function table: `1428` total, `1070` named, `358` still anonymous (`74.93%` named)
- Local executable code only (`0x800...` addresses): `1274` total, `917` named, `357` still anonymous (`71.98%` named, `28.02%` anonymous)
Method used:
- queried the live MCP `list_functions` endpoint against the active PSX program
- counted `FUN_` and `nullfn_` names as anonymous placeholders
- treated the `0x800...` address range as the practical coverage metric so imported/system helpers in other spaces do not distort the remaining-work count
Current hottest anonymous local windows by `0x1000` page are:
- `0x8002exxx`: `21`
- `0x80030xxx`: `21`
- `0x80049xxx`: `21`
- `0x80031xxx`: `17`
- `0x80040xxx`: `14`
- `0x8002bxxx`: `14`
- `0x8003axxx`: `13`
- `0x80020xxx`: `13`
- `0x80048xxx`: `13`
- `0x80046xxx`: `11`
Practical read:
- the PSX database is well past the early blind-mapping stage; roughly seven tenths of the local code already has non-placeholder names
- the remaining anonymous mass clusters in a few rendering, world-update, and resource-heavy windows rather than being evenly spread
- `0x80040xxx` remaining hot is still consistent with the current map-rendering blocker, because that window contains late projection and presentation helpers rather than only loader-side code
## Immediate Conclusions
- `SYSTEM.CNF` is the disc boot file and points directly at `cdrom:\SLUS_002.68;1`.
- `SLUS_002.68` is the main game executable. It begins with a valid `PS-X EXE` header.
- No other top-level file currently looks like a second normal PS1 executable.
- Disc content is dominated by standard PS1 media (`.STR`, `.XA`) plus a large number of game-specific `.WDL` blobs.
- `FMV.BIN` looks like movie-playback support data or a resource blob, not a bootable executable.
- `ZZZ.ZZZ` looks much more like media/container data than code, and its size exactly matches `MOVIES/FMV3.STR`.
## Disc Boot Evidence
`SYSTEM.CNF` contents:
```ini
BOOT = cdrom:\SLUS_002.68;1
TCB = 4
EVENT = 10
STACK = 801FFFFC
```
`SLUS_002.68` header evidence:
- Magic: `PS-X EXE`
- Initial PC: `0x8004BD90`
- Load address: `0x80010000`
- Image size: `0x000A4800` (`675,840` bytes)
This is the clearest Ghidra import candidate for code analysis.
## Top-Level File Classification
Top-level items:
- `SLUS_002.68`
- `SYSTEM.CNF`
- `FMV.BIN`
- `ZZZ.ZZZ`
- `SPEC_A.WDL`
- `LEGAL.SCR`
- `LICENSEA.DAT`
- `AUDIO/`
- `MOVIES/`
- `MENUS/`
- `LSET1/` through `LSET7/`
Recursive extension summary from the extracted disc tree:
| Extension | Count | Total bytes | Current classification |
|---|---:|---:|---|
| `.STR` | 33 | 415,027,744 | PS1 movie streams |
| `.XA` | 2 | 91,897,856 | PS1 XA audio |
| `.WDL` | 66 | 76,198,860 | custom game asset/level blobs |
| `.ZZZ` | 1 | 26,148,984 | likely media/container data |
| `.68` | 1 | 675,840 | main PS1 executable |
| `.SCR` | 1 | 153,600 | data asset |
| `.BIN` | 1 | 90,812 | support/resource blob |
| `.DAT` | 1 | 28,032 | data asset |
| `.CNF` | 1 | 70 | boot config |
## File Family Findings
### 1. `SLUS_002.68`
- This is the boot target from `SYSTEM.CNF`.
- It has a normal PS1 executable header.
- It should be the first import into Ghidra.
- Current working assumption: this is the only primary native code binary on the disc.
### 2. `AUDIO/*.XA`
- Files found:
- `AUDIO/MULTI8.XA`
- `AUDIO/TALK1.XA`
- These are standard PS1 XA audio files.
- `MULTI8.XA` is divisible by both `2352` and `2048`, which is consistent with sector-oriented media data.
- `TALK1.XA` is divisible by `2048` but not exactly by `2352`.
- Most practical extraction route: use standard PS1/XA tooling rather than custom RE first.
### 3. `MOVIES/*.STR`
- Files found: `FMV0.STR` through `FMV32.STR`.
- These are the strongest candidates for standard PS1 video streams.
- The raw headers look consistent with sectorized PS1 stream data rather than executable code.
- Most practical extraction route: treat them as PS1 STR video and run them through PS1 media tooling first.
### 4. `FMV.BIN`
This file does not look like a normal executable.
First bytes begin with:
```text
\MOVIES\FMV%d.STR
MDEC_rest:bad option(%d)
MDEC_in_sync
MDEC_out_sync
DMA=(%d,%d), ADDR=(0x%08x->0x%08x)
FIFO=(%d,%d),BUSY=%d,DREQ=(%d,%d),RGB24=%d,STP=%d
```
Current best read:
- `FMV.BIN` is movie-related support data, code tables, or debug/resource text for the MDEC/FMVs.
- It clearly references the external movie path pattern `\MOVIES\FMV%d.STR`.
- It is worth a secondary Ghidra import only if the goal is to understand the movie subsystem specifically.
- It is not the disc boot executable.
### 5. `ZZZ.ZZZ`
Key findings:
- Size: `26,148,984` bytes
- That size exactly matches `MOVIES/FMV3.STR`.
- The file begins with stream-like binary data rather than an executable header.
- It also yielded movie-adjacent string evidence such as `MDEC`.
Current best read:
- `ZZZ.ZZZ` is probably not code.
- It is a strong candidate for either:
- a renamed movie stream, or
- a duplicate/alternate copy of `FMV3.STR`
Most practical next check:
1. compare a few sectors or hashes against `MOVIES/FMV3.STR`
2. try opening `ZZZ.ZZZ` directly in PS1 STR-capable tooling
### 6. `LSET*/L*.WDL`
These are the most important unknown asset family for content extraction.
Representative level sample: `LSET1/L0.WDL`
- Size: `1,312,624` bytes
- Header starts with structured values, not raw pixels:
```text
0x00000034 0x00006FDC 0x0000376C 0x00001000
0x00000160 0x00000498 0x0000025C 0x00000FE0
0x00000070 0x00072EC4 0x00034B6C 0x00007448
0x0007407C 0x00010824 0x00000002 0x00000000
```
- This does not behave like a flat framebuffer dump.
- It looks more like a custom structured level/container blob with internal offsets, lengths, or section pointers.
- Additional offset targets inside the file, such as `0x376C`, `0x6FDC`, and `0x10824`, land on repeating structured records rather than code.
Strict TIM-style scan results for `L0.WDL` found plausible embedded PS1 image headers at:
- `0xE7A84`
- `0x117DEC`
- `0x12CECC`
- `0x135F18`
- `0x1369F4`
- `0x136B38`
- `0x136C40`
Current best read:
- `LSET*.WDL` likely holds mixed level resources.
- At least some of those resources may include standard embedded PS1 TIM-like image blocks.
- These files are the strongest current target for a custom extractor.
Executable-guided extraction status:
- `lset_level_bundle_load` in the imported PSX executable now confirms the executable builds `\LSETn\Lx.WDL` paths directly and treats those files as the live level-bundle format.
- The same loader reads a small level header blob first, then a large SPU/audio blob, then dispatches the remaining level resource stream through `psx_stream_install_type_runtime_banks`.
- `image_resource_bind_vram_slot` and `image_bundle_load_to_vram` show that resource types `4` and `5` are image/sprite-oriented resources: they resolve VRAM placement and upload image data through `LoadImage`.
- `sprite_rle_decode_rows` is now confirmed as the row-based decompressor used when a type-5 frame record has its compressed bit set.
- Current consequence: sprite extraction now has a real executable-backed path, while map extraction has a reliable raw-carving path even though the full tile/object semantics are not decoded yet.
### 7. `MENUS/*.WDL` and `SPEC_A.WDL`
Representative menu sample: `MENUS/M13.WDL`
- Size: `1,475,928` bytes
- Starts with dense repeating values like:
```text
0x9D499D29 0x9D299D29 0x9D4A9D4A 0xA14A9D4A
0x9D49A14A 0x9D299D29 0x9D4A9D4A 0xA14A9D4A
```
Representative special sample: `SPEC_A.WDL`
- Size: `545,424` bytes
- Starts with the same raw-looking pattern as `M13.WDL`.
Current best read:
- These do not begin with obvious pointer tables.
- They look more like raw or lightly wrapped image/screen asset data than like level containers.
- `M13.WDL` had no strict TIM hits in the quick scan.
- `SPEC_A.WDL` did have a few plausible stricter TIM-style hits at:
- `0x449A8`
- `0x80ED8`
So the `.WDL` family probably is not one single uniform format. Current evidence supports at least two subfamilies:
- structured level/resource blobs (`LSET*/*.WDL`)
- raw-looking menu/special screen blobs (`MENUS/*.WDL`, `SPEC_A.WDL`)
Executable-guided extraction status:
- `SPEC_A.WDL` does not behave like the `LSET*.WDL` container family.
- The executable-backed extractor work currently treats it as a raw blob with embedded image candidates rather than as a contiguous section table.
- A validated carve on `SPEC_A.WDL` currently finds strict TIM-style hits at `0x1B5CC` and `0x80ED8`.
## Executable-Backed Extraction Model
These findings are now grounded in both file inspection and the imported `SLUS_002.68` executable.
### Level bundles: `LSET*/*.WDL`
Validated current extraction model for `LSET1/L0.WDL`:
- top-level header size: `0x34`
- immediately following blob: `0x6FDC` bytes
- post-audio resource area starts at: `0x7010`
- high-confidence internal boundaries recovered from the header and validated with the extractor:
- `0x7448`
- `0x34B6C`
- `0x72EC4`
- `0x7407C`
Current carved regions from `L0.WDL`:
| Region | Offset | Size | Current interpretation |
|---|---:|---:|---|
| `audio_or_spu_blob` | `0x34` | `0x6FDC` | SPU/sequence data loaded by the audio init path |
| `post_audio_region_00` | `0x7010` | `0x438` | small table/directory block |
| `post_audio_region_01` | `0x7448` | `0x2D724` | strong map/placement candidate |
| `post_audio_region_02` | `0x34B6C` | `0x3E358` | strong map/placement candidate |
| `post_audio_region_03` | `0x72EC4` | `0x11B8` | small control/index block |
| `post_audio_region_04` | `0x7407C` | `0xCC6F4` | strongest current sprite/graphics bank candidate |
Important consequence:
- for map work, the best current extraction targets are `post_audio_region_01` and `post_audio_region_02`
- for sprite/graphics work, the best current extraction target is `post_audio_region_04`
Important correction from the next executable pass:
- a previously suspected text-like block in the broader PSX resource system is now confirmed separately in executable analysis as a menu/prompt text resource, not map data
- a heavily used level-side table is also now confirmed as a per-type flag/behavior table used by collision/order logic, not a raw map grid
- so the late-level extraction focus stays on `LSET*.WDL` post-audio regions, not on every large runtime table seen in the executable
The validated strict TIM carver currently finds one confirmed embedded TIM block in `L0.WDL` at:
- `0xBBA54`
That hit lands inside `post_audio_region_04`, which supports treating the late large region as the current best graphics bank candidate.
The same structure now reproduces on `LSET1/L1.WDL` too:
- header size: `0x34`
- audio blob: `0x3244`
- post-audio start: `0x3278`
- high-confidence boundaries: `0x6F48`, `0x334D8`, `0x602C4`, `0x732D4`
- late graphics candidate region: `0x732D4 .. EOF`
- current strict TIM hit: `0xB4DC8`
So the current working model is no longer based on just one level file: `LSET` bundles appear to share a stable pattern of:
1. fixed `0x34` header
2. SPU/audio blob
3. several map/meta candidate regions
4. one large late graphics-oriented region
What is still not stable yet:
- the internal semantics of `post_audio_region_01` and `post_audio_region_02` are still unresolved
- `L0.WDL` starts with rows that look structured when viewed as `u16x6`, but `L1.WDL` does not preserve the same obvious interpretation at the same region boundary
- current safest reading is that these are still raw candidate map/meta payloads, not yet a decoded placement format
### Menu / special blobs: `SPEC_A.WDL`, `MENUS/*.WDL`
These currently behave like raw image-oriented blobs, not like the structured `LSET` family.
Validated current extraction model for `SPEC_A.WDL`:
- whole-file raw blob fallback works cleanly
- strict TIM hits currently validate at:
- `0x1B5CC`
- `0x80ED8`
Representative secondary check on `MENUS/M13.WDL`:
- whole-file raw blob fallback also works cleanly there
- current strict TIM hit validates at:
- `0x493EC`
This keeps menus/special screens as a secondary image-carving problem instead of a map/container problem.
## Working Extractor
Current extractor script:
- `tools/psx_extract_wdl.py`
What it does right now:
- recognizes the validated `LSET*.WDL` top-level layout
- carves the audio blob and header-directed post-audio regions
- scans the whole file for strict TIM blocks and extracts them
- falls back to raw-blob carving for `SPEC_A.WDL` / menu-like files
- emits an exploratory `u16x6` CSV view for the first post-audio LSET candidate region so raw row patterns can be inspected without claiming final semantics
- scans the large late LSET graphics region for type-5 sprite bundle headers
- decodes row-RLE compressed sprite frames and writes raw frame payloads plus grayscale preview images
- writes carved output under `out/psx_wdl/<stem>/`
Current practical usage:
```powershell
c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/psx_extract_wdl.py "E:/emu/psx/Crusader - No Remorse/LSET1/L0.WDL"
c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/psx_extract_wdl.py "E:/emu/psx/Crusader - No Remorse/SPEC_A.WDL"
```
This is enough to start extracting:
- raw map-candidate blocks from level bundles
- strict TIM sprite/image blocks from both level and menu/special blobs
- exploratory raw row exports for the first LSET post-audio candidate region
- actual extracted sprite frames from at least some type-5 bundles inside the late LSET graphics region
Current caution:
- the `u16x6` export is only a raw inspection aid
- `L0.WDL` gives structured-looking rows such as `0041,177B,0F7F,0000,0002,0020`, but `L1.WDL` shows very different values at the same relative region
- so this export should be treated as evidence-gathering for map decoding, not as a solved object-placement parser yet
## Confirmed Sprite Extraction
The extractor now produces actual sprite-frame outputs from at least part of the late LSET graphics bank.
The late-graphics scan is now widened beyond the original first-32-bundle probe. Current `L0.WDL` extraction finds `159` candidate bundles in `post_audio_region_04`, and the larger-first overview now clearly includes not just floor/wall tiles but also several object/UI-like assets such as framed panels, cabinets, a portrait, a hand-shaped sprite, a bone, and other small pickup-like art.
Confirmed current example from `LSET1/L0.WDL`:
- graphics-region-relative bundle offset: `0xE5B8`
- whole-file bundle offset: `0x82634`
- mode: `2` (current best read: 4bpp indexed)
- frame count: `3`
- first extracted frame dimensions: `40 x 66`
- runtime default bundle palette index: `12`
Confirmed output files:
- raw frame bytes: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/frame_000.bin`
- preview image: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/frame_000.png`
- colored preview image: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/frame_000_color.png`
- colored sprite atlas: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/atlas_color.png`
- bundle metadata: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/bundle.json`
- palette metadata: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/palette.json`
Current result:
- this is no longer just a graphics-bank hypothesis
- the workflow can now extract actual sprite/image frame payloads and render preview images from at least some LSET graphics bundles
- the workflow can now also render colored previews and a proper per-bundle RGBA atlas using the bundle default palette index recovered from the PSX executable
- widened grayscale overviews now confirm that the late graphics bank contains recognizable object and UI art, not just texture/noise candidates
Current palette model:
- the executable-backed palette source for `LSET*.WDL` is the first `0x1000` bytes loaded into `DAT_800676d8` by `lset_level_bundle_load`
- `level_palette_upload_cluts` uploads that `0x1000` block as `8 x 16` raw 16-color CLUTs and then caches the raw CLUT handles in `DAT_800a9f48`
- `level_palette_expand_5bit_to_16color` separately builds a second `0x1000` grayscale-expanded table, but `DAT_800a9f48` is populated only from the first 8 raw rows
- the bundle header field at `+0x14` is the default palette-table index used when no override is supplied at draw time
- the remaining blocker is not locating the raw CLUT block anymore; it is recovering the per-placement palette override metadata that can replace the bundle default during map/tile rendering
Current color blocker:
- both main texture draw helpers (`FUN_80044bdc` and `FUN_80044e9c`) fall back to the bundle default palette index only when no override is present
- the important caller path at `FUN_80041458` ORs in a high-byte palette override from object/tile metadata pointed to by object field `+0xa0`
- that `+0xa0` pointer is now tighter too: both object constructors store the original authored source-record pointer there, so the override is not coming from a hidden runtime side table. For current solved families the draw helper reads the override straight from the authored record bytes:
- type `0x003e..0x00ab`: high byte of source word at record `+0x06`
- type `>= 0x00ac`: high byte of source word at record `+0x0c`
- that means standalone bundle previews can still be wrong even when the bundle parser and raw CLUT table are both correct
- the extractor now emits wider `u16x12` raw CSV views for `post_audio_region_01` and `post_audio_region_02` because the relevant override state appears to live beyond the first 6 words of those candidate placement records
- the current top-ranked portrait bundle (`bundle_00064478`, default palette index `106`) is a useful color-validation anchor because the grayscale frame is obviously correct while all raw-palette candidates remain visibly wrong
- another important unresolved issue is the exact on-disk location of the second-stage runtime header after the initial `0x3520` front image block. The loader assembly proves the runtime sequence is: `0x3520` front block -> `12`-byte header -> palette blob -> audio blob -> `4`-byte stream count, but the raw file bytes at offset `0x3520` do not yet reconcile cleanly with those expected sizes.
- `cd_file_read` itself does not transform or decompress bytes; it performs sector-based buffered CD reads. So the remaining palette-source problem is now narrowed to file-layout interpretation rather than hidden read-time decoding.
### Runtime Dump Grounding: cabinet console bundle
The new RAM/VRAM dump pair was used to ground the known-colored cabinet console bundle against live runtime state instead of continuing static palette guessing.
Verified cabinet anchor:
- bundle: `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04`
- mode: `1`
- frame `0`: `56 x 68`
- default bundle palette index: `0`
Verified live-texture result from `binary/Crusader - No Remorse (USA) GPU RAM.bin`:
- the frame payload from `bundle_000A1B04/frame_000.bin` exists in live VRAM as one exact `8bpp` texture match
- exact match location: texel `x=258`, `y=256`
- texture page: `(1,1)`
- in-page offset: `(2,0)`
- no flipped exact match was found
This is a strong confirmation that the current `mode 1` pixel decode is correct. The remaining problem is CLUT selection, not texture extraction.
Verified CLUT result from the same dump:
- the active CLUT band used by `level_palette_upload_cluts` still sits at rows `0xF0..0xF7`
- the important successful step was simpler than the later screen-match ranking pass: in `live_vram_clut_atlas.png`, the very first candidate at the top-left corner is the correct formula for this visible cabinet family
- that top-left candidate is the contiguous `256`-entry palette taken directly from live GPU row `0xF0` at `x=0`
- in other words, current best read for this `mode 1` family is: `byte value -> direct index into the 256-word slice [row 0xF0, x 0..255]`
- equivalently, this behaves like `16` adjacent live `16-color` CLUTs flattened into one `256`-entry lookup table for the sprite byte stream
- the later numeric ranking pass that preferred handle `64` / row `0xF4` was misleading for this case and should not be treated as the correct palette formula
Important consequence:
- the dump-grounded success case is not `bundle default row 0` from `L0.WDL` and not the later `row 0xF4` ranking result
- the working palette source is the live VRAM CLUT row `0xF0`, `x=0`, treated as one contiguous `256`-entry table
- this means the current extractor problem for `mode 1` bundles is better described as `recover the runtime CLUT-row formula` rather than `pick one cached CLUT handle index`
- for the visible wall-console bundle, that runtime formula now has a concrete verified answer even though the higher-level metadata path that selects it is still unresolved
Wider decode result using the corrected formula:
- a focused batch renderer was run over the detected `mode 1` bundles in `LSET1/L0.WDL` post-audio graphics region `04`
- using the same live palette source `row 0xF0 / x=0`, the pass rendered `92` `mode 1` bundles with plausible colored output instead of only the single cabinet proof case
- the strongest batch proof is the generated overview:
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/overview_live_row_f0_x0.png`
- per-bundle outputs and summary metadata now live under:
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/`
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/summary.json`
- that wider pass now shows many object-like assets decoding plausibly under the same rule: cabinets, panels, tanks, wall fixtures, floor markers, weapons, pickups, and small machinery props
Generated runtime-grounded artifacts:
- `binary/psx_framebuffer_left.png`
- `binary/psx_framebuffer_console_crop.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_atlas.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_top_matches.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_best.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_rank.txt`
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/overview_live_row_f0_x0.png`
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/summary.json`
One important caveat from the dump-grounded pass:
- none of the `8` raw `256-color` palette blocks carved from `LSET1/L0.WDL` matched the live CLUT rows byte-for-byte in this dump
- that means either the dump was captured from a different loaded level/resource set, or the active runtime palette source for this on-screen console is not the raw `L0.WDL` palette blob currently being tested
Palette follow-up note:
- most extracted PSX sprite data is now structurally correct, but palette selection is still only partially solved
- the current exporter should therefore be treated as `good enough to continue map work`, not as a final automatic color pipeline
- we need a later pass to recover the runtime palette-selection rule well enough to assign the correct palette automatically for every bundle family instead of relying on the currently verified `mode 1` rule plus heuristics
## PSX Map Decode Plan
Current objective:
- decode the PSX `LSET*.WDL` map/resource layout well enough to render PSX maps through the existing public map renderer pipeline instead of building a one-off viewer
Current working split:
- `post_audio_region_01` is now the first high-confidence map-placement candidate
- `post_audio_region_02` still looks more like compressed or mixed resource payload than directly renderable placement rows
- `post_audio_region_04` remains the late graphics bank and should be treated as the PSX art source for any eventual renderer integration
Immediate working hypothesis:
- `post_audio_region_01` is a fixed-row authored layout stream
- the raw `u16x12` view is already showing that each `24`-byte row behaves like two adjacent `6`-word records with similar field structure
- the strongest early evidence is that neighboring left/right halves carry similar value ranges and repeat the same small control words in the tail fields, which is what we would expect from paired cell/object placements rather than opaque compressed data
Practical renderer goal:
- adapt the PSX decode into the same broad source model the public renderer already uses for PC fixed maps: coordinates, shape/frame identity, and a few raw metadata bytes/words kept for inspection
- do not block on fully naming every field before producing a first renderer-fed PSX map source
Planned work order:
1. Lock down `post_audio_region_01` row structure across more `LSET` files and confirm whether `24` bytes is the true authored row size.
2. Separate the two half-rows into individual candidate placement records and track stable min/max ranges for each word position.
3. Identify which words are likely coordinates by checking for bounded map-like ranges and local spatial continuity between neighboring rows.
4. Identify which words are likely tile/object ids by checking whether the same values recur in ways that match repeated wall/floor/object motifs.
5. Correlate the placement stream against `post_audio_region_04` bundle offsets or bundle-local ids to recover the art linkage.
6. Determine whether `post_audio_region_02` is a secondary map layer, a lookup table for region `01`, or a different compressed resource class entirely.
7. Prototype a PSX map-source exporter that emits JSON in a renderer-friendly form even if some fields are still labeled as raw words.
8. Add a PSX-specific loader path to the existing map renderer instead of creating a separate PSX viewer.
9. Once the first map renders, iterate on field naming, layer semantics, and art binding rather than trying to solve the whole format up front.
Current evidence-backed next step:
- the extractor now needs to keep emitting a paired-record export for `post_audio_region_01` so the candidate row model can be checked quickly across multiple maps without reinterpreting the CSV by hand each time
Current renderer-compatibility result:
- the old Python/site real-art probe remains useful as discarded negative evidence, but it is no longer the active viewer workflow
- the active integration path now lives inside `k:\ghidra\crusader_map_viewer\map_renderer` and builds live data into `.cache` from `STATIC_PSX`
- active renderer-local scripts:
- `src/build-psx-cache.js`
- `src/lib/psx-cache.js`
- build entrypoint:
- `npm run build-psx-cache`
- current generated live-cache outputs:
- `k:\ghidra\crusader_map_viewer\map_renderer\.cache\psx\catalog.json`
- `k:\ghidra\crusader_map_viewer\map_renderer\.cache\reference-data\psx-remorse\reference-data.json`
- per-map scene files under `k:\ghidra\crusader_map_viewer\map_renderer\.cache\scene-cache\psx-remorse\map-*\<fingerprint>\scene.json`
- `k:\ghidra\crusader_map_viewer\map_renderer\Catalogs\psx_shape_catalog_remorse.csv`
- current processed-cache characteristics from the verified build:
- source: `k:\ghidra\crusader_map_viewer\map_renderer\STATIC_PSX`
- scene format version: `psx-region01-provisional-art-probe-v2`
- processed maps: `23`
- shared shape definitions: `313`
- shared atlases: `313`
- largest currently useful placement-heavy maps: `LSET1/L0` (`1050` items), `LSET4/L33` (`942` items), `LSET5/L48` (`851` items), `LSET6/L51` (`463` items), `LSET7/L63` (`315` items)
Current art-binding hypothesis used by this probe:
- region-01 `u0` is treated as a provisional direct bundle index into the extracted `sprite_bundles/` set
- region-01 `u4` was originally treated as a provisional frame index within that bundle, but that interpretation is now considered wrong; the constructor chain instead points to `u4` as a state/script selector candidate
- this is evidence-backed enough to render real PSX art in the existing map renderer, but not strong enough yet to call the binding solved
- the strongest negative check so far is that the region-01 `u5` values (`0x20`, `0x22`, `0x30`) do not match the bundle default palette indexes, so the palette-selection/control path is still missing
Current invalidation result:
- this direct `u0 -> bundle index` mapping is now considered invalid for real renderer output
- the produced scene repeats a small set of obviously wrong assets, including portrait/UI-like art, in places that do not make spatial sense for the map
- executable-side tracing shows that art selection is type-driven through `DAT_800758cc/d0/d4/d8` resource tables loaded by `psx_stream_install_type_runtime_banks`, not by directly indexing the raw `post_audio_region_04` bundle scan
New loader/data evidence from this pass:
- `post_audio_region_00` now has dedicated extractor diagnostics:
- `out/psx_wdl/L0/post_audio_region_00_00007010_u16x6.csv`
- `out/psx_wdl/L0/post_audio_region_00_00007010_u16x12.csv`
- `out/psx_wdl/L0/post_audio_region_00_00007010_u32x5.csv`
- `out/psx_wdl/L0/post_audio_region_00_00007010_stream_probe.json`
- the new raw probe confirms that `post_audio_region_00` begins with a little-endian count value `0x20`
- after an initial short header/preamble, the bytes from about `0x3c` onward look like tightly packed `12`-byte records in the same broad shape family as the old candidate placement rows:
- example bytes at `0x3c`: `4a 00 03 16 e7 0e 00 00 01 00 20 00`
- little-endian words: `0x004A, 0x1603, 0x0EE7, 0x0000, 0x0001, 0x0020`
- that record family is a better next target than the invalidated direct bundle probe because it already exposes a small type-like word (`0x004A`) plus coordinate-like words without forcing an arbitrary raw-bundle index
What this renderer pass means now:
- the live renderer can expose PSX as an optional game only after the processed cache exists; it is no longer tied to ad hoc `site` exports
- the current active output is now a provisional real-art probe rather than a placeholder-only type/lane scene
- the processed-cache path is now compatible with the existing shared reference-data pipeline and PC-style catalog grouping, which keeps PSX integration inside the normal viewer architecture instead of forking it
- the old real-art probe is still valuable as negative evidence because it proved that direct raw bundle ordering produces obviously wrong scene content
New renderer-grounded improvement from this pass:
- `src/lib/psx-cache.js` now scans `post_audio_region_04` directly from `STATIC_PSX`, parses bundle headers in JavaScript, colorizes the extracted frames with the currently available default/heuristic palette path, and writes per-map bundle atlases into `.cache/reference-data/psx-remorse`
- the live cache no longer uses only synthetic placeholder shapes for map `0`; the current `LSET1/L0.WDL` scene references `49` real atlases and `62` real sprite frames under the still-provisional direct `u0 -> bundle index` hypothesis
- extracted bundle origins are now sanitized on import so bad `0xFFFF` offsets do not blow out the scene bounds; `LSET1/L0.WDL` is back to a sane `3896 x 8431` footprint instead of the broken `67k`-pixel-wide intermediate result
- PSX shape definitions now use a `1x1x1` footprint and the scene items synthesize viewer-compatible `world.x/world.y/world.z` from the final screen anchors; this keeps bounding-box and preview overlays aligned with the PSX art probe instead of projecting nonsense from the raw `u1/u2/u3` words
Current app compatibility notes:
- the public renderer app was updated so non-`FIXED.DAT` map sources do not advertise a bogus binary export path
- for the PSX probe scene, `Download Map Binary` is intentionally disabled while `Download PNG`, `Download Map JSON`, and `Download Atlas PNG` remain available
- the static app successfully loads the `PSX LSET1/L0 Region 01 Art Probe` catalog entry and currently fits it at about `8%` zoom instead of the earlier collapsed `2%` fit
Immediate implications for the next decode pass:
- the public renderer integration path is now proven enough to use as a live debug target for PSX map-format work
- the next priority is to replace the invalidated `u0 -> bundle index` hypothesis with a real type/resource lookup recovered from `psx_stream_install_type_runtime_banks`
- `post_audio_region_00` is now a top-tier candidate for that work because its new diagnostics expose a count-prefixed preamble plus compact typed records that look more loader-compatible than the old region-01 art probe
- the palette override path is now partially landed in the viewer/exporter too: the cache builder applies the executable-backed authored override byte when the source record exposes the proven `+0x06` / `+0x0c` lane, so the remaining blocker is the cases where the runtime first picks a different object/variant/state than the current export model
- once the bundle key and palette control path are recovered, the same scene-export path can graduate from `real-art probe` to actual PSX map rendering
## PSX Provisional Real-Art Probe
The live renderer now prefers a smaller loader-backed record family when it can normalize that family into structured placement rows, while still preserving the older dense region-01 probe as a fallback/debugging strategy.
What changed in this pass:
- the temporary Python probe established the scene structure, but the active implementation is now renderer-local JavaScript rather than a standalone exporter
- `src/lib/psx-cache.js` now reads `STATIC_PSX`, parses `LSET*.WDL`, prefers normalized `post_audio_region_00` count-prefixed records when they pass the existing structured-candidate filter, falls back to `post_audio_region_01` otherwise, scans `post_audio_region_04` for sprite bundles, and emits per-map atlases built from the extracted PSX frame data
- `src/build-psx-cache.js` writes the resulting processed data into the live cache tree:
- `k:\ghidra\crusader_map_viewer\map_renderer\.cache\psx\catalog.json`
- `k:\ghidra\crusader_map_viewer\map_renderer\.cache\reference-data\psx-remorse\reference-data.json`
- per-map scenes under `k:\ghidra\crusader_map_viewer\map_renderer\.cache\scene-cache\psx-remorse\...`
- `k:\ghidra\crusader_map_viewer\map_renderer\Catalogs\psx_shape_catalog_remorse.csv`
- the viewer now detects `psx-remorse` from the processed manifest instead of from a fake PC-style source-file heuristic
- scene items now keep the candidate PSX `x/y` words directly in `world`, use the executable-backed projection basis `screen_x = y - x`, `screen_y = 2*z - (x + y)/2` with provisional `z = 0`, and keep `1x1x1` shape footprints so overlay boxes remain usable without pretending the old PC-style world export is solved
Current verified processed-cache result:
- scene format version: `psx-runtime-record-probe-v1`
- processed maps: `61`
- atlas-backed shapes: `1112`
- atlases: `1112`
- `LSET1/L0.WDL` preferred source family: `post_audio_region_00`
- `LSET1/L0.WDL` rendered items from the preferred family: `59`
- `LSET1/L0.WDL` still has `1050` dense fallback `post_audio_region_01` records preserved in scene metadata for comparison
- `LSET1/L0.WDL` resolved real-art atlases for the preferred family: `18`
- `LSET1/L0.WDL` resolved sprite frames for the preferred family: `26`
- `LSET1/L0.WDL` unique `u0` types in the preferred family: `18`
- lane split:
- `0x0020`: `26`
- `0x0022`: `21`
- `0x0030`: `12`
- `LSET1/L0.WDL` current scene bounds after the runtime-record pass: `1313 x 438`
- `LSET1/L0.WDL` currently resolves all `59` preferred-family records to real extracted bundles with `0` placeholder fallbacks, but still clamps `15` frame requests down to the highest available extracted frame index
- one visible viewer mismatch is now separated from the remaining map-format problem: PSX sprites already draw from authored `item.screen`, but the old highlight/bounding overlay path was still recomputing DOS-style wireframes from provisional `item.world`; `scene-presentation.js` now falls back to authored screen rectangles for PSX items instead of drawing those incorrect projected boxes
Why this matters:
- this is the first live viewer path that prefers a loader-compatible, count-prefixed record family instead of treating the huge dense region-01 stream as the only scene source
- it keeps the strongest current working assumption narrower and more explicit:
- normalized `post_audio_region_00` rows are now the preferred placement family when they satisfy the same structural checks as the older region-01 records
- `post_audio_region_01` remains a dense fallback evidence source instead of being silently discarded
- the art lookup is still unresolved and must be recovered from the real runtime resource tables rather than inferred from raw bundle ordering
- it also moves the viewer one step closer to the executable model by applying the recovered PSX projection basis directly in the cache builder instead of plotting raw `u1/u2` values on a pseudo-screen plane
Immediate next consequence:
- the next map-format batch should treat the processed `.cache` runtime-record probe as the baseline renderer target and focus on proving exactly how the normalized `post_audio_region_00` words line up with the constructor-fed `x/y/z` fields
- the old dense region-01 path should stay available as evidence, but it should no longer be the default scene family unless the loader-backed family fails to normalize on a given map
- that means the remaining visual corruption should now be treated primarily as a placement/schema problem again, not as a box-overlay problem; the next pass needs to recover the authoritative height lane and the exact constructor-fed field mapping instead of spending more time on DOS-style overlay math
## PSX Map-System Correction
The current live viewer export was built on the wrong premise. The `~45..59` records currently exported per PSX map are not enough to represent a whole Crusader level, and executable tracing now shows why.
What the loader actually does:
- `wdl_resource_bundle_load_by_index` reads the selected `LSET*.WDL` into multiple section pointers, not one flat placement stream.
- the per-level file header is now structurally tighter too: after the initial `0x38` read, the loader treats the first nine dwords as contiguous section sizes, allocates a separate `0x50` runtime-header block at `DAT_80067794`, and later reads one more independently allocated compressed source blob before the optional `FUN_8003b00c(..., 0x3e00, 0x3e00)` inflate.
- The first runtime section is a top-level table at `DAT_800678f4` whose record stride is `0x18` bytes.
- The second runtime section at `DAT_80067720` is also `0x18`-stride, but it is not just a duplicate alias of the first pointer. The loader assigns the contiguous level sections in this order:
- `DAT_800678f4 = section[0]` root dispatch table
- `DAT_80067720 = section[1]` secondary `0x18`-stride control/placement table
- `DAT_800678f0 = section[2]`
- `DAT_80067938 = section[3]`
- `DAT_80067838 = section[5]`
- `DAT_800675f8 = section[6]`
- `DAT_8006754c = section[7]`
- `DAT_80067840 = section[8]`
- `DAT_800676d8 = section[9]` palette block loaded just before `level_palette_header_apply`
- The skipped header slot is not dead space. The loader allocates it separately as `DAT_8006767c`, then feeds it to `FUN_80040768`; that lane stays distinct from the contiguous section pack and from the later `DAT_8006b5d8 -> DAT_8006769c` decompression path.
- The loader iterates that first section with:
- `for each 0x18-byte top-level record`
- `type = record[+0x08]`
- `dispatch through PTR_PTR_80063118[type]`
- Those dispatch handlers do not behave like a terrain-tile walker. They construct one runtime object or a tiny object cluster at a time through `FUN_800249f4`, `FUN_80024eec`, `FUN_8003c314`, `FUN_8003c714`, and `FUN_8003cc08`.
- The current `0x0042` result now fits that same generic-object model more tightly than before: `psx_type_descriptor_table[0x0042]` at `0x80063220` points to the shared row `0x800626f8`, so both constructor-placement and root-dispatch `0x0042` still enter the same generic create/update/release family rather than a unique descriptor fork.
- The selector side is tighter too: type `0x0042` now has a dedicated transition helper, `psx_type42_transition_selector_tick`, that can dispatch low turning selectors `3/4` before the `+0x94`-style runtime latch copy. So unresolved `0x0042` presentation is no longer best framed as only a missing descriptor-table or raw-selector decode problem; part of the remaining gap is that the live turn/reseat path can be ahead of the currently latched script word.
Why the current export is incoherent:
- the current `region00`-first exporter is effectively treating that small top-level descriptor family as if it were the whole level
- those records are only the root nodes of the level bundle's object/resource system
- they are too few because the bulk level content lives elsewhere in the loaded bundle state
New executable-backed evidence for the missing bulk content:
- `psx_stream_install_type_runtime_banks` and `FUN_8003917c` populate the typed runtime resource tables rooted at `DAT_800758cc/d0/d4/d8`
- `DAT_80067720` is a small top-level `0x18` record list used by object/event-style helpers such as `FUN_80031044` and `FUN_8002b1a8`; it is not a whole-map terrain stream
- during bundle load, `FUN_8003b00c(DAT_8006769c, &DAT_8006b5d8, 0x3e00, 0x3e00)` inflates a separate compressed blob into a dedicated level buffer
- that decompressed buffer is carried through save/load helpers (`FUN_8003a0f4`, `FUN_80049890`) independently of the tiny top-level descriptor list, which is exactly what a real map substrate would do
- the two `DAT_80067720` helpers are now clearer about role too:
- `FUN_80031044` scans the `0x18`-stride rows for `0xAAAA`-tagged entries and low-6-bit selector matches, then caches a pointer to the matched row payload
- `FUN_8002b1a8` mutates matching rows by type/id and flag bits in place
- both behaviors fit a small event/marker/control list and do not look like whole-map geometry submission
- the decompressed lane is more clearly persistent substrate/state than before:
- `FUN_8003a0f4` hands `DAT_8006769c` plus `DAT_80067528` to the save helper path
- `FUN_80049890` repacks the `DAT_8006b5d8` / `0x3e00` state lane into the `0x4000` memory-card save block
- this strengthens the read that `DAT_8006769c` is the saved/restored map-state substrate while `DAT_80067720` stays the tiny top-level control list
Current safest read:
- the `~59` exported records are top-level WDL nodes, not the entire PSX map
- the real PSX level is split across:
- a small top-level descriptor stream
- typed subordinate resource tables
- at least one separate decompressed level-state blob
- the viewer looks nonsensical because it is rendering only one small layer of that system and mistaking it for the full map
Immediate consequence for the exporter:
- stop treating `post_audio_region_00` as the default whole-map scene source
- keep `post_audio_region_00` and `post_audio_region_01` as evidence sources, but pivot the next decode pass toward the multi-section WDL model recovered from the executable
- the next map-export target must include the decompressed bundle state and/or the subordinate placement/tile resources behind the top-level `0x18` records, not just the root records themselves
Exporter status after the next renderer pass:
- the earlier five-region post-audio carve was still wrong for visible-map recovery. The corrected loader-sized section probe shows that the first post-audio section already contains both the count-prefixed top-level descriptor rows and the dense 24-byte bulk placement rows that the flat maps were missing.
- `map_renderer/src/lib/psx-cache.js` now recovers visible families from loader-sized `post_audio_section_00` instead of treating the old guessed `post_audio_region_01` carve as the default bulk source.
- the exported scene metadata now records those visible families under executable-backed names instead of the old provisional labels:
- `section0_dispatch_roots` for the top-level dispatch/root records
- `section0_constructor_placements` for the dense constructor-fed placement records
- a verified full rebuild now exports all `62` PSX maps with large scene volumes and non-flat `z` stats. `LSET1/L0.WDL` now emits `1189` items, `LSET1/L1.WDL` jumps from `53` items to `754`, and the rebuilt catalog reports `62/62` maps with `section0_dispatch_roots + section0_constructor_placements` coverage and `uniqueZCount > 1`.
- the renderer-side reference payload no longer emits one atlas per resolved PSX shape. The new packed-atlas pass reduces the shared PSX reference cache from the old `4032` one-shape atlases to `1925` shared packed atlases across the same `4032` shape definitions, and a spot-check on `LSET1/L0.WDL` now exports the map scene itself with `atlasCount = 1` instead of a long per-bundle atlas list.
- the cache export now carries more than the parsed `DAT_800758d8` candidate section. In the current `psx-runtime-record-probe-v6` scene path, `map_renderer/src/lib/psx-cache.js` serializes `DAT_800758cc`, `DAT_800758d0`, `DAT_800758d4`, and the offline `FUN_8003b00c` decode candidate for `DAT_8006b5d8 -> DAT_8006769c` into `stateLayers`, and the scene writer preserves those layers in both scene metadata and `mapSource`.
- the new typed-section-16 discovery path is also broader than the earlier section-start probe: when no parsed-section candidate wins, the cache builder now falls back to an absolute-file scan, which is why the late compound-bank blobs can now land in the export even when their serialized source does not start exactly at a pre-labeled section boundary.
- the file-side header block now separates more cleanly too: `FUN_80039c40` allocates a `0x50` level runtime-header block at `DAT_80067794`, and `psx_apply_level_runtime_header_block` copies that block into globals such as `DAT_80078ab0`, `DAT_80078a88`, `DAT_80078a8c`, `DAT_80078a4c`, and `DAT_80067354` before calling `FUN_80042ec4`. So the first visible/root sections are not the only authoritative level metadata; the loader also applies a dedicated `0x50` per-level runtime header after the optional `0x3e00` decode succeeds.
- this still does not mean the PSX map decode is fully solved: the viewer now has enough volume to represent whole-level candidates across the disc, but the remaining blocker is semantic decoding of the subordinate runtime banks and the separate decompressed `0x3e00` buffer, not record-count starvation.
- the type-to-art path is only partially improved. The cache builder now scans the parsed per-type art-template payloads for bundle references, and the renderer no longer treats the disproven scan-order `u0 -> bundle` mapping as trustworthy visible art. Unverified types now stay on placeholder art instead of surfacing known-bad portrait/talk bundles as map geometry.
- the scan-order fallback is now known to be wrong at the root, not merely incomplete. In the live `.cache` output, `section0_dispatch_roots` types `0x0042` and `0x0049` repeatedly bind to portrait/talk-animation bundles such as map `0` type `0042` -> offset `0x000B2970` and map `0` type `0049` -> offset `0x000D84F4`, with the same failure pattern continuing through early maps. Those portrait bundles are useful negative evidence: they show the top-level dispatch rows are generic object/state descriptors, not a direct map-graphics stream that can be paired to bundle order.
Next decoded runtime layers from the constructor pass:
- `DAT_800758d8` is the per-type active-art-header bank, not the missing whole-map substrate. `wdl_resource_bundle_load_by_index` feeds it from two distinct late art-facing sections per WDL pass: an earlier build/install lane and a later `8`-byte header-only override lane. The later override is what leaves raw `0x58`-byte active headers in `DAT_800758d8`, and both constructors consume that final state before deciding whether to reuse `DAT_800758c8` or call `FUN_80044434`.
- `DAT_800758d0` is a per-type companion/component bank for the simpler constructor family. `FUN_800249f4` copies the resolved pointer from that bank into the local object payload at `obj->8->[0,4]`, so this looks like a per-type component/template block rather than a top-level placement stream.
- `DAT_800758cc` is a per-type offset-table bank for the compound constructor family. `FUN_80024eec` stores it at `obj+0x88`, and `FUN_800260e8` later indexes it with the placement byte at `record+0x08` to resolve a state/offset subrecord into `obj+0x8c/0x90`.
- `DAT_800758d4` is another per-type companion bank for the compound constructor family. `FUN_80024eec` stores it at `obj+0x84`, and `FUN_8002841c` queries it later using the object's `+0x94` selector, so it behaves like a variant table or companion lookup rather than raw map geometry.
- The raw-file provenance for those banks is tighter now too. Inside `wdl_resource_bundle_load_by_index`, `psx_load_type_state_banks` is called four times total: twice before the selected `LSET*.WDL` opens and twice again after the level-local section pack and palette/header lanes are loaded. The standalone `8`-byte descriptor-table read that assigns `DAT_800758d8` sits between the second pair of `psx_load_type_state_banks` calls. Current best read:
- one common/shared bank pair loads before the map-local WDL opens
- one map-local override pair loads after the level-local header and palette lanes
- `DAT_800758d8` definitely comes from its own late descriptor stream
- `DAT_800758d0` plus `DAT_800758cc/d4` are loaded through the adjacent `psx_load_type_state_banks` blobs rather than from the root section tables or the decompressed `0x3e00` state buffer
- The key functions in that chain are now renamed in the live PSX Ghidra database:
- `FUN_800249f4` -> `psx_object_create_simple_record`
- `FUN_80024eec` -> `psx_object_create_compound_record`
- `FUN_80025ce8` -> `psx_reset_type_runtime_banks_from`
- `FUN_80025d68` -> `psx_object_advance_state_script`
- `FUN_800260e8` -> `psx_object_select_state_script`
- `FUN_8002841c` -> `psx_object_lookup_variant_entry`
- `FUN_8003917c` -> `psx_load_type_state_banks`
- `FUN_80044434` -> `psx_create_image_resource_from_descriptor`
- `FUN_80045ffc` -> `psx_cache_type_art_descriptor`
- the constructor/runtime chain is now clearer too:
- `psx_reset_type_runtime_banks_from` is a bank reset helper used during init/recycle paths; it clears `DAT_800758c4/c8/cc/d0/d4/d8` from the requested type index upward and is not the state interpreter itself.
- `psx_object_create_simple_record` and `psx_object_create_compound_record` are two placement constructors for different section-0 row layouts, but both index the same per-type runtime banks by type id before any final render-facing selection is made.
- `psx_create_image_resource_from_descriptor` turns the `DAT_800758d8` per-type descriptor into a renderable resource/header object; this is why `DAT_800758d8` should be read as an art/template descriptor bank, not as a whole-map tile layer.
- `psx_object_select_state_script` selects a state or animation subrecord from `DAT_800758cc` using a placement byte (`record+0x08` in the compound family), storing the resolved script/state pointer at `obj+0x8c/0x90` and the selector at `obj+0x9e`.
- `psx_object_advance_state_script` then interprets the active state script with sentinel/control values such as `0xffff`, `0xfffe`, `0xfffd`, `0xfffc`, and `0xfffb`, so the visible frame path is explicitly state-driven rather than just "type id -> one bundle".
- `psx_object_lookup_variant_entry` is not only a constructor-time helper. Its call graph now shows three direct consumers: `psx_object_create_simple_record`, `psx_object_create_compound_record`, and `psx_object_advance_state_script`. That means unresolved families cannot be modeled as one spawn-time `type -> variant` choice; the visible companion bytes can be recomputed after state-script control flow advances.
- The current renderer-side consequence is important: section-0 word `u4` is no longer treated as a verified sprite-frame index. It is now carried forward as a state-selector candidate in exported scene metadata until the `DAT_800758cc/d4` path is decoded far enough to pick the right animation frame from executable evidence.
- Current strongest sentinel read:
- `0xfffe` dispatches `psx_script_dispatch_audio_event`, which is an audio/effect helper rather than a visible-frame selector.
- `0xfffd` is an in-script jump/re-anchor control that rewrites `obj+0x90` relative to the current script base.
- `0xfffc` resolves a fresh subsidiary script base from the table rooted at `obj+0x88`, then immediately swaps both `obj+0x8c` and `obj+0x90` to that destination before continuing from the first record there.
- `0xfffb` also resolves a subsidiary script from the same table, but first walks the current script forward until it finds an in-band `0xfffd` marker and then uses that marker's selector word to choose the destination entry.
- Current best read of those sentinels:
- `0xffff` marks a terminal or restart control that re-anchors the script at `obj+0x8c` and raises object-state flags.
- `0xfffe` dispatches a side-effect helper (`psx_script_dispatch_audio_event`) using the following word as a parameter before advancing.
- `0xfffd` is the direct indexed jump control within the current script family.
- `0xfffc` and `0xfffb` are both subsidiary-script switches through the `DAT_800758cc` offset table rooted at `obj+0x88`, but `0xfffb` is specifically the scan-forward variant that consumes the next in-band `0xfffd` selector.
- because `psx_object_advance_state_script` calls `psx_object_lookup_variant_entry` after those control-flow steps, the visible art choice for unresolved types may depend on post-jump script state rather than on the placement selector byte alone.
- `psx_object_lookup_variant_entry` finally uses `obj+0x94` to look up a companion entry in `DAT_800758d4`, which means even after construction the art-facing choice is still mediated by per-type variant/state tables.
- This means the next PSX layers are now at least structurally separated:
- visible root descriptors (`section0_dispatch_roots`, legacy alias `region00`)
- visible bulk placement candidates (`section0_constructor_placements`, legacy alias `region01`)
- per-type art/template descriptors (`DAT_800758d8`)
- per-type simple-object component blocks (`DAT_800758d0`)
- per-type compound state-offset tables (`DAT_800758cc`)
- per-type compound variant tables (`DAT_800758d4`)
- the still-separate decompressed `0x3e00` level-state buffer (`DAT_8006769c`)
- The current renderer pass now records those banks explicitly as exported scene/state layers, while still only rendering the first two as visible scene items.
- The live viewer now keeps those heavyweight bank dumps out of the hot interactive scene state after load: `stateLayers` and `decodedRuntimeLayers` are preserved for exported scene JSON, but the runtime pan/zoom/editor path uses a trimmed scene object so normal navigation does not keep dragging the full PSX research blobs through the controller.
- The renderer-side state parser is also stronger than the earlier flat selector map. `buildTypeStateFrameMaps` now reads the exported `DAT_800758cc` layer and follows the currently verified control-flow sentinels (`0xfffe`, `0xfffd`, `0xfffc`, `0xfffb`) instead of just taking the first word of each selector entry. That does not fully close type `0x0042` or `0x0049`, but it does mean the viewer can now distinguish more fallback states from real script structure rather than from a blind `selector -> first frame` heuristic.
- The live cache now tightens the selector provenance too. In exported PSX scene items, the `state_selector=...` label is written directly from raw word `u4`, while the trailing `lane=...` label is raw word `u5`; this is not a post-hoc heuristic. A full scan of the built `psx-remorse` scene cache found `3944` visible `type=0x0042` placeholders across `61` maps, and the observed selectors track `u4` exactly (`0 -> 0x0000`, `1 -> 0x0001`, `2 -> 0x0002`, `3 -> 0x0003`, `4 -> 0x0004`).
- The executable-side handoff is narrower than that label might suggest. `psx_object_select_state_script` stores the authored selector byte in `obj+0x9e` and uses it only to choose the initial `DAT_800758cc` script base at `obj+0x8c/0x90`; `psx_object_lookup_variant_entry` does not index `DAT_800758d4` by `obj+0x9e`. It indexes by `obj+0x94`, which `psx_object_advance_state_script` refreshes from the current script entry after control-flow handling.
- Immediate map-viewer consequence: the current fallback art probe should be treated only as a diagnostic overlay for candidate bundle families. A workable renderer will need to recover the per-type `DAT_800758d8` descriptor mapping and the downstream `DAT_800758cc/d4` state+variant selection path before it can decide whether a section-0 placement should show world geometry, an animated object, or something non-map-facing like a portrait/talk asset.
- Current status note: even with the recovered placement/projection path and the first subset of real bundle-backed types, the live PSX map output is still unreadable as a practical map because most section-0 placements still miss the executable's final state/variant-driven art binding and therefore collapse back to placeholders.
- The next loader-side correction is now verified in the live cache too: the effective late `LSET*.WDL` `DAT_800758d8` candidate is not the earlier small-section heuristic, but a large late section whose working descriptor stream begins at an embedded `+0x38` offset. On retail map `9` that correction alone lifts `bundleMappedItemCount` from `0` to `111`, which is enough to restore real bundle-backed art for a first subset of types without reintroducing the disproven scan-order fallback.
The still-unresolved root-dispatch families remain instructive rather than contradictory. `0x0042` and `0x0049` still stay on placeholders after the bank-selection fix, but the same pass now decodes their `DAT_800758cc` state rows more cleanly: type `0x0042` carries three selector-targeted scripts (`0`, `1`, `2`) that all terminate through `0xffff`, while type `0x0049` carries a single selector-`0` script. The built scene cache shows that this is still not the whole art-facing discriminator: `type=0x0042` placeholders now appear with selectors `0..4`, and the higher selectors `3` and `4` are real exported cases rather than parser noise. Verified maps with `0x0042` selectors above `2` include `map-4`, `map-5`, `map-8`, `map-45`, `map-69`, and `map-85`.
Two runtime reselection paths now explain how those higher selectors can arise without contradicting the earlier three-script file read. `psx_object_reselect_state_from_target_vector` and `psx_type4_reselect_motion_state` both recompute the active script with `psx_object_select_state_script(obj, psx_object_quantize_motion_heading16(obj) >> 2 & 0xf)`, where `psx_object_quantize_motion_heading16` quantizes the object's current motion vector at `obj+0x60/+0x64` through `psx_quantize_vector_heading16` into a 16-way heading bucket. So cache-visible `0x0042` selectors `3` and `4` can come from runtime heading/state reselection, not only from the original placement byte.
That cache sweep also separates selector from lane more clearly than before. `0x0042` appears heavily on lanes `0x0020` and `0x0022`, and there are also map-local `lane=0x0030` cases (for example large clusters on `map-108`) that still export `state_selector=0`. So the unresolved bridge is narrower now: the visible-art rule cannot be modeled as just `u5` or just the initial `DAT_800758cc` selector parse. The remaining unknown is the downstream interaction between `u4`/`obj+0x9e`, the active state-script pointer at `obj+0x8c/0x90`, and the `DAT_800758d4` companion lookup that reruns after state-script advancement.
A first renderer-safe bridge landed even with that exporter gap still open: the verified `0x0050` state-script mapping (`selector 0..3 -> frame 0..3`) is now applied as a narrow fallback in the cache builder, and the rebuilt live map-9 scene now shows `type=80 state_selector=1 chosen_frame=1` instead of the old forced `chosen_frame=0`. Unresolved fallback placeholders are also now clamped to `opacity=0.45` in live scene output so the still-missing families stop visually overpowering the recovered real art. This remains intentionally scoped: the fallback frame map only covers the one family with direct executable-backed frame evidence, and the opacity clamp is diagnostic relief rather than a decoding claim.
The current draw split is clearer too. `FUN_80041378` is a three-stage render pass:
- stage 2: a second special-visible list drawn by `FUN_80041144`
- stage 3: HUD/overlay/icon primitives from `FUN_800416cc`
- That split matters for the map-viewer target: stages 1 and 2 remain relevant to missing world-facing content, while stage 3 is mostly front-end or overlay material and should not be mistaken for the missing half of the map.
- Stage 2 is now materially better understood and is no longer just a read-side observation:
- `FUN_80040f78` is the queue-builder for that pass. It projects an object with the same fixed-point world-to-screen math as `FUN_80040d44`, writes the final screen rectangle to `+0x20..+0x2e`, then appends the object to `DAT_80078b70` and increments `DAT_80067472`.
- `FUN_80041144` consumes that queue directly, iterating `DAT_80078b70[0 .. DAT_80067472)` and submitting sprite primitives through the same texture draw helpers as the main object pass.
- `FUN_80044fec` resets the queue each frame by clearing `DAT_80067472` after the top-level draw pass.
- So the stage-2 list is not UI/HUD noise and not a duplicate of the main clipped visible list. It is a distinct world-facing queued-object lane, which is now a concrete candidate explanation for part of the still-missing map content in the viewer.
- The immediate caller-side consequence matters too:
- `FUN_80040d44` remains the main clipped visible-list toggle, calling the stage-1 add/remove helpers when an object enters or leaves the screen.
- The recovered post-state-advance updater family now splits into five visible call sites: `0x80012b44`, `0x80013524`, `0x80013564`, `0x80013650`, and `0x80013778` all call `psx_object_advance_state_script`.
- Three of those sites then feed the main stage-1 projector path through `FUN_80040d44` (`0x80012b60`, `0x8001357c`, `0x800136d4`), while two feed the stage-2 queue-builder path through `FUN_80040f78` (`0x8001352c`, `0x80013780`).
- That exact `3` versus `2` split matters because it tightens the earlier claim: stage-2 membership is tied to a narrower runtime object/state branch after state advance, not to the decompressed substrate buffer alone and not to all state-advanced objects indiscriminately.
- One state-script sentinel is now functionally closed too: `0xfffe` dispatches `psx_script_dispatch_audio_event`, which is an audio/effect helper rather than a visible-frame selector. That shrinks the unknown sentinel set for the remaining `DAT_800758cc` script work.
- The main visible-list helpers are now also separated cleanly enough to stop treating them as a blocker:
- `FUN_8002d240` adds an object to the stage-1 `DAT_8006ad5c` visible-list array.
- `FUN_8002d35c` removes an object from that same array.
- `FUN_8002d59c` returns the sorted slice that `FUN_80041378` iterates for the stage-1 world-object pass.
- `FUN_8002d6f8` and `FUN_8002d778` act as refresh/rebucket/sort helpers over that main list.
- This is an important scope reduction for renderer work: the remaining missing world content is now less likely to be caused by misunderstanding the main stage-1 visibility array itself, and more likely to live in the separate stage-2 queued-object pass plus the still-unresolved `DAT_800758cc/d4` state-to-art path.
Recovered next visible layer from the bulk placement family:
- The structured `section0_constructor_placements` rows are no longer height-agnostic. The `FUN_80024eec` constructor reads its authored elevation from byte `+0x06` of the input record, which corresponds to the low byte of the current exported `u3` word for the accepted bulk-placement records.
- That byte is not just random payload on the accepted rows. Under the corrected section-0 scan, the same ladder generalizes across the whole rebuilt catalog instead of only the earlier `L0` subset. `LSET1/L0.WDL` still collapses to `11` distinct height values (`0, 2, 4, 10, 12, 14, 18, 20, 22, 24, 26`), and `LSET1/L1.WDL` now exposes `9` distinct levels with a `z` range of `0..32`.
- The PSX cache builder now uses that recovered `z` byte for `section0_constructor_placements` projection instead of forcing the whole bulk layer onto `z = 0`, while the top-level `section0_dispatch_roots` descriptor stream stays at `z = 0` until its own constructor-backed height source is proven.
- This is now the first PSX export pass in the viewer pipeline that produces visibly multi-layer whole-map candidates across the rebuilt retail catalog from executable-backed height data rather than from a single flattened candidate layer.
Additional constructor-backed coordinate grounding from the current pass:
- the current constructor split is now precise enough to state both authored row layouts at once:
- `psx_object_create_compound_record` reads `type` from `record+0x00`, `x/y` from `u16` words at `+0x02/+0x04`, authored elevation from byte `+0x06`, flags from `+0x0a`, and the initial state selector from byte `+0x08`.
- `psx_object_create_simple_record` reads `type` from `record+0x04`, `x/y` from `u16` words at `+0x08/+0x0a`, authored elevation from byte `+0x0c`, and the initial state selector from byte `+0x0e`.
- both constructors write the original authored source-record pointer to object field `+0xa0`, which keeps the later palette-override and state-selection paths tied to raw placement bytes rather than to a hidden runtime copy.
- both constructors also resolve the same per-type banks before any render pass runs:
- `DAT_800758d8` -> art/template descriptor
- `DAT_800758cc` -> state-script offset table
- `DAT_800758d4` -> companion variant lookup
- `DAT_800758d0` -> simple-record-only component payload
- this strengthens the viewer/exporter rule again: the two visible section-0 row families are constructor inputs, not already-final render primitives. Any direct `type -> bundle/frame` export path still has to respect the later state-script and variant lookups.
Recovered per-level runtime-header lane:
- `FUN_80039c40` is now confirmed as a pure `0x50` allocator for `DAT_80067794`, and `psx_apply_level_runtime_header_block` is the matching applier for that block.
- `psx_apply_level_runtime_header_block` copies fixed fields from `DAT_80067794` into the active level globals, including camera/runtime anchor values and several per-level mode bytes, then calls `FUN_80042ec4` to refresh dependent runtime state.
- The downstream call graph narrows that lane further: `psx_apply_level_runtime_header_block` is the only loader-side caller that feeds those values from WDL data, while `FUN_80042ec4` is also reused by `psx_input_device_init`, `memory_card_menu_tick`, and one additional front-end/system path. Current safest read: `DAT_80067794` is shared per-level runtime mode or presentation state rather than hidden bulk map geometry.
- Practical exporter consequence: keep the `DAT_80067794` fields as a first-class raw metadata lane, but do not treat them as a missing placement stream. They are more likely to affect camera/runtime modes, screen-space behavior, or level-global toggles than to supply extra map cells directly.
- The higher-level level lifecycle is now readable too. `psx_level_session_loop` is the outer level-session loop: it loads the selected WDL through `wdl_resource_bundle_load_by_index`, applies shared overlay/resource setup through `FUN_800388a8`, resets a small per-level step-flag block with `FUN_8003a498`, and then runs `psx_world_frame_tick` as the per-frame world loop until the current level session exits.
- `wdl_resource_bundle_load_by_index` is now mapped tightly enough for viewer work. Its effective order is: load `SPEC_A.WDL` and shared type art/state banks; open the selected `LSET*.WDL`; read the `0x38` section-size header; lay out the contiguous per-level section pack at `DAT_800678f4`, `DAT_80067720`, `DAT_800678f0`, `DAT_80067938`, `DAT_80067838`, `DAT_800675f8`, `DAT_8006754c`, `DAT_80067840`, and `DAT_800676d8`; load the detached `DAT_8006767c` blob; optionally inflate `DAT_8006b5d8` into `DAT_8006769c`; apply the runtime header at `DAT_80067794`; then dispatch the `0x18`-stride root records at `DAT_800678f4` through the per-type function table in `PTR_PTR_80063118`.
- The per-frame world loop in `psx_world_frame_tick` is now split clearly enough for renderer planning. In the normal in-level branch it ticks existing live objects through `psx_run_live_object_type_updates`, instantiates or refreshes nearby authored records through `psx_dispatch_section0_dispatch_roots` and `psx_dispatch_section0_constructor_placements`, runs per-object behavior callbacks through `psx_run_live_object_behavior_callbacks`, integrates world/player motion and active-object state through `psx_update_motion_and_nearby_interactions`, updates queued transient resources through the still-structural `FUN_8002aed0` queue-drain helper, and only then submits the draw pass through `FUN_80041378`.
- The two authored record-family passes now line up directly with the viewer exporter model:
- `psx_dispatch_section0_dispatch_roots` walks the `DAT_80067720` `0x18`-stride family plus the fixed-size entries at `DAT_80067658`, culls them to roughly a `+/-0x140` neighborhood around the current focus object, and dispatches their per-type handlers. This is the closest executable match for the current `section0_dispatch_roots` viewer family.
- `psx_dispatch_section0_constructor_placements` walks the `DAT_800678f0` `0x0c`-stride family with the same neighborhood cull and per-type dispatch. This is the closest executable match for the current `section0_constructor_placements` viewer family.
- The already-instantiated-object passes are separated too:
- `psx_run_live_object_type_updates` iterates the linked live object list at `DAT_800675ac` and calls the per-type update callback (`type_vtable+8`) for active in-world objects.
- `psx_run_live_object_behavior_callbacks` then runs each live object's callback stored at `obj+0x98` / `obj[0x26]`, which is the later object-specific behavior/update pass.
- One adjacent control family in the same world/update lane is now tighter too:
- `psx_object_run_control_opcode` (`0x80023c54`) executes one opcode from the object-local control stream at owner `+0x20`.
- `psx_control_move_player_to_point` (`0x80023efc`) is control opcode case `1` for the player path: it lazily seeds a target point from the opcode payload, steers toward it through the heading solver, and completes once the player reaches that point.
- `psx_control_move_object_to_point` (`0x80024070`) is the non-player version of the same case `1` path, using the object movement-state helper to keep facing and locomotion aligned while the object advances toward the opcode target.
- `psx_queue_deferred_control_command` (`0x800241f4`) appends deferred control-command entries into the small queue rooted at `DAT_8008f608` / `DAT_8008ef90` with count `DAT_80067730`.
- `psx_control_wait_ticks` (`0x80024290`) is control opcode case `3`: a one-shot timed wait gate that snapshots `DAT_80078a28` on first entry and completes only after the opcode's tick count has elapsed.
- `psx_control_configure_fixed_camera_anchor` (`0x800242f0`) is the shared control opcode case `4/5` helper: one branch projects an opcode-provided point into the shared camera basis and seeds a fixed anchor through `DAT_80078a2c`, while the other branch clears that anchor and restores normal camera-follow behavior.
- `psx_spawn_object_compound_effect_variant3` (`0x800184e8`) is the direct effect spawner used by control opcode case `8`: it builds a type-`2` compound record at the current object position, forces variant `3`, and triggers audio event `0x2c`.
- `psx_flush_deferred_control_queue` (`0x8002aed0`) drains that queue once per world tick, applying each entry through `psx_apply_deferred_control_command`.
- `psx_apply_deferred_control_command` fans one deferred entry out into both `psx_apply_deferred_control_to_dispatch_roots` and `psx_apply_deferred_control_to_live_objects`, which is the clearest current evidence that this queue is a small deferred world/control mutation lane rather than a render queue.
- `psx_control_set_facing_direction` (`0x80024438`) is control opcode case `9`: it forces an explicit facing token and immediately refreshes the player or object movement-state around that heading.
- The remaining neighboring helper at `0x800243b8` is still intentionally unnamed for now; it looks like a short delay gate around `psx_spawn_object_compound_effect_variant3`, but it still needs stronger subsystem evidence before it should get a behavioral name.
- `psx_update_motion_and_nearby_interactions` is the broad world-motion and nearby-interaction integrator that sits between behavior updates and draw submission. For viewer purposes, this is the runtime bridge between authored map placement and the motion/state values that later feed heading-based state reselection and projection.
- The cull-to-draw bridge is now closed too. `psx_authored_record_in_view_bounds` is the authored-record screen-space gate used by the two section-0 dispatch passes, while `psx_world_point_in_view_bounds` is the corresponding gate for already-instantiated live objects. Both use the same isometric camera basis around `DAT_800678d4`, which means the viewer can treat the record-family export as feeding the same projection space as the later live object list instead of as a separate map coordinate model.
- The main world-object draw helper is now grounded more tightly as well. `FUN_80041458` builds the final sprite primitive from the authored screen rectangle at `obj+0x20..+0x2e`, then ORs in a palette override read from the original source-record pointer at `obj+0xa0`: for types `0x003e..0x00ab` it uses the high byte of source word `+0x06`, and for types `>= 0x00ac` it uses the high byte of source word `+0x0c`. That means the remaining viewer mismatch is not where the override comes from, but when the runtime chooses a different object/variant/state before draw.
- The stage split is tighter too. `psx_project_object_special_visible_queue` (`0x80040f78`) feeds a distinct world-facing stage-2 queue, and `FUN_80041144` consumes that queue with the same projected screen rectangle fields and the same resource-specific draw helpers used by the stage-1 visible list. So the unreadable output is not explained by one missing HUD lane; the dominant gap is still the unresolved final art-binding path, with the stage-2 queue as a secondary world-object lane the viewer must eventually model.
- The next high-value executable target is now partly closed. `psx_type4_reselect_motion_state` (`0x8002906c`) is now named, and the surrounding interaction cluster is finally concrete enough to describe instead of leaving it as a black box:
- `psx_type4_update_delayed_interaction` (`0x80029c20`) is the type-4-only delayed wrapper. It probes ahead, stores the hit object at the controller-local `+0x38` slot, seeds a countdown from distance and speed, and dispatches to `psx_type4_reselect_motion_state` when that delay matures.
- `psx_type4_reselect_motion_state` (`0x8002906c`) is the post-construction reselection path for those delayed type-4 interactions. Depending on target flags it either hands off to the older `psx_object_reselect_state_from_target_vector` (`0x80028c94`) helper or flips the object's motion components against the target bounds, then reseats the live state script through `psx_object_select_state_script(obj, psx_object_quantize_motion_heading16(obj) >> 2 & 0xf)` before registering bilateral contact.
- `psx_object_update_nearby_interactions` (`0x80029478`) is the broad nearby-object sweep that feeds most of the non-type-4 collision and interaction bookkeeping. It walks the active object set, culls locally, performs overlap checks, updates directional contact/block flags, and registers contact pairs.
- `psx_object_test_overlap_3d` (`0x80028298`) is the box-overlap predicate used by that sweep, and `psx_object_update_contact_block_flags` (`0x800289f0`) is the direction-sensitive flag updater that writes the contact/block bits on the active object.
- `psx_object_register_contact_pair` (`0x8002845c`) is the bilateral contact queue helper used by both the broad sweep and the type-4 delayed path, which means the interaction lane is no longer speculative glue around the art/state system. It is a verified runtime bridge that can directly reseat the live script before the later `DAT_800758d4` companion lookup.
- The motion-heading side of that bridge is now closed one step further too:
- `psx_object_reselect_state_from_target_vector` (`0x80028c94`) is the older target-relative reselection helper used by `psx_type4_reselect_motion_state`. It builds a motion vector either from a target-relative offset or from a normalized target-to-player vector, writes that vector to `obj+0x60/+0x64/+0x68`, and reseats the live state script from the resulting heading bucket.
- `psx_object_quantize_motion_heading16` (`0x8003bc1c`) is the thin wrapper that feeds the current object motion vector into `psx_quantize_vector_heading16`.
- `psx_quantize_vector_heading16` (`0x8003b980`) is the actual 16-way heading quantizer. It classifies the current x/y vector against the threshold table at `DAT_80064990`, which is why the runtime reselection callers consistently reduce its result with `>> 2 & 0xf` before indexing the `DAT_800758cc` script table.
- The local post-advance render wrappers are also no longer anonymous labels:
- `psx_spawn_compound_record_advance_state_once` (`0x80013618`) creates one compound-record object, forces its script countdown to `1`, immediately runs `psx_object_advance_state_script`, and then marks the object with `obj+0x1e |= 0x20`. This is the cleanest currently recovered example of a constructor wrapper that intentionally advances into a non-initial live state before the object joins the normal update/render flow.
- `psx_spawn_simple_record_set_active_flag` (`0x8001372c`) is the simpler sibling for the simple-record constructor: create the object, then immediately set the low active flag in `obj+0x1e`.
- `psx_object_refresh_main_visible_and_cleanup` (`0x80013688`) is the compact stage-1 handoff wrapper. When the object still has a drawable resource and the `0x20` flag is set, it feeds the object through `psx_project_object_main_visible`; if the object is not in the `obj+0x1c & 1` hold state but does carry `obj+0x1e & 0x10`, it then queues the object into the nearby-interaction active set through `psx_nearby_interaction_list_add`.
- `psx_object_advance_state_and_queue_special_visible` (`0x80013758`) is the compact stage-2 handoff wrapper. If the object still has a drawable resource, it advances the active script and immediately queues the object through `psx_project_object_special_visible_queue`, then applies a small sentinel cleanup block that clears world coordinates and selected flags for specific type/selector cases.
- The owner above those wrappers is now named too: `psx_object_integrate_motion_and_route_visible` (`0x800131a8`). It is the per-object bridge between movement state and rendering: it integrates position/velocity fields, refreshes the local visible/on-screen flags, handles the controlled-object side path, then advances the live state script and routes the object either into `psx_project_object_special_visible_queue` for the type-4/special-visible branch or into `psx_project_object_main_visible` for the normal drawable branch before the usual nearby-interaction enqueue step. This is the clearest recovered owner-level proof that state advancement and render-lane routing belong to the same runtime step.
- Those wrappers matter because they close one more gap between `psx_object_advance_state_script` and the render split. The stage-1/stage-2 divergence is not only visible in larger caller bodies such as the `0x80013524` / `0x80013564` branches; it also exists as small dedicated wrappers that either project through the main visible list after state work or advance-and-queue directly into the special-visible pass. That makes the renderer problem look even less like a missing flat table and more like a true runtime pipeline with multiple post-script routing paths.
- The render-side leaf chain is now close to end-to-end:
- `psx_project_object_main_visible` and `psx_project_object_special_visible_queue` both use the current script word at `obj+0x94` as the frame selector they pass into the frame-metric helpers.
- `psx_resource_frame_origin_x` (`0x8004513c`), `psx_resource_frame_origin_y` (`0x800451d0`), `psx_resource_frame_width` (`0x80045014`), and `psx_resource_frame_height` (`0x800450a8`) read per-frame rectangle metadata from the current drawable resource at `obj+0x10` using that same live frame index.
- `psx_draw_world_visible_passes` (`0x80041378`) is the top-level world-facing draw orchestrator: stage 1 draws the sorted main visible list through `psx_draw_main_visible_object` (`0x80041458`), stage 2 draws the queued special-visible list through `psx_draw_special_visible_queue` (`0x80041144`), and stage 3 is the non-world HUD/overlay pass.
- `psx_draw_main_visible_object` and `psx_draw_special_visible_queue` then dispatch to one of two final frame submitters depending on resource kind: `psx_sprite_resource_submit_frame` (`0x80044bdc`) handles the streamed/VRAM-backed sprite resource path, while `psx_image_table_submit_frame` (`0x80044e9c`) handles the table-backed image resource path. Both receive the live frame index from `obj+0x94`, not the original authored selector.
- The stage-1 visible list manager is now named end to end too: `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`, and `psx_main_visible_list_get_sorted_slice` cover membership, rebucketing, refresh, dependency/tie-break sorting, and final slice retrieval for the main world-object draw pass.
- The constructor/resource side closes the remaining resource-pointer handoff. `psx_create_image_resource_from_descriptor` is now confirmed as the builder used by both constructors: type-4 descriptors bind a single indexed image resource through `image_resource_bind_vram_slot`, while type-5 descriptors allocate and upload a multi-frame bundle through `image_bundle_load_to_vram`. Both `psx_object_create_simple_record` and `psx_object_create_compound_record` resolve the per-type art bank before any draw pass runs, store the resulting drawable resource on the object at `obj+0x10`, store the per-type variant bank at `obj+0x84`, store the per-type state-script bank at `obj+0x88`, and only then call `psx_object_select_state_script`. So the current strongest end-to-end model is now:
- authored record `type` -> per-type art/state/variant banks
- constructor seeds `obj+0x10` drawable resource plus `obj+0x88/0x84`
- `psx_object_select_state_script` seeds the initial script from `DAT_800758cc`
- runtime reselection / `psx_object_advance_state_script` updates the live script word at `obj+0x94`
- `psx_object_lookup_variant_entry` maps that live script word through `DAT_800758d4`
- the projectors and final draw submitters use the same live `obj+0x94` frame index against the drawable resource at `obj+0x10`
- The newly traced consumer side changes the export diagnosis in an important way. `psx_object_advance_state_script` does not treat the `DAT_800758d4` lookup result as a resource id or replacement frame index; it sign-extends the returned three bytes into `obj+0x30/+0x34/+0x38`, and the verified downstream consumers are all in the overlap/contact lane. `psx_object_test_overlap_3d` uses those fields as box extents against `obj+0x54/+0x58/+0x5c`, `psx_object_update_contact_block_flags` uses the same extents while updating directional block bits, and the reselection helpers read target-object `+0x30/+0x34/+0x38` as target bounds. By contrast, the visible projectors and both stage-1/stage-2 draw helpers still only use `obj+0x10` plus live `obj+0x94` for visible presentation.
- The renderer/exporter path now preserves that distinction too. The PSX cache builder decodes `DAT_800758d4` as a `count + packed signed xyz-extents` table, carries the decoded per-state tuples in the exported state layer, and writes the resolved `companionExtents` tuple onto each scene item and `mapSource` row for the chosen live state when a matching entry exists. So the viewer now keeps the newly verified bounds evidence explicitly instead of flattening it back into the old placeholder-art bucket.
- That is not yet the full remaining answer for unresolved placeholder-heavy families, but the missing piece is narrower again now. The `DAT_800758d4` bytes are no longer the best candidate for the final art table; they are acting like per-state companion extents used by collision/contact logic. So the remaining gap sits inside the family-specific live resource/frame presentation path after script reselection, not in projection, placement decoding, draw-lane discovery, or generic `DAT_800758d4` consumption.
- The latest dispatch-table trace also removes one remaining false lead for `0x0042` and `0x0049`. Their type-table slots do not point to unique family handlers; both currently resolve to the same generic descriptor used across `0x003e..0x0050`, whose callback slots are `psx_spawn_compound_record_advance_state_once`, `psx_object_refresh_main_visible_and_cleanup`, and `psx_object_release_to_free_list`. So the unresolved art rule for those types is not hiding in a dedicated per-type descriptor fork; it still appears to live later in generic object state/resource handling.
- The state/art split is therefore even sharper than the earlier constructor notes implied. `psx_object_select_state_script` only chooses the current script base from `DAT_800758cc` and stores the authored selector at `obj+0x9e`, but both `psx_object_reselect_state_from_target_vector` and `psx_type4_reselect_motion_state` can later overwrite that live script choice from runtime motion state. `psx_object_advance_state_script` then refreshes `obj+0x94` from the current script word and reruns `psx_object_lookup_variant_entry`, and that lookup indexes `DAT_800758d4` by `obj+0x94`, not by the original `obj+0x9e` selector byte.
- That changes the exporter diagnosis in a useful way. The unreadable map output is still real, but the remaining gap is narrower than before: it is not just that `DAT_800758cc` exists, but that runtime interaction and heading-state paths can overwrite the live script after spawn. Any renderer-safe state-to-art rule for unresolved families such as `0x0042` has to account for those post-spawn reselection paths instead of assuming the authored selector byte is final.
- that means the PSX level load now has four distinct evidence-backed layers instead of the earlier two-way split:
- root dispatch records at `DAT_800678f4`
- secondary `0x18`-stride records at `DAT_80067720`
- per-type runtime banks at `DAT_800758d8/d0/cc/d4`
- a dedicated `0x50` level runtime-header block at `DAT_80067794`
- plus the separately loaded compressed source at `DAT_8006767c` and its optional `DAT_8006b5d8 -> DAT_8006769c` decoded `0x3e00` state buffer
## PSX Coordinate Model From Executable
The current coordinate problem is no longer a renderer-only guess. The executable now closes the last projection step well enough to treat PSX placement as its own map-space model instead of as a PC-style direct world export.
Key function evidence:
- `FUN_800249f4` and `FUN_80024eec` are constructor paths that load authored coordinates into object fields `+0x3c`, `+0x40`, and `+0x44` as `16.16` fixed-point values.
- For the first family, the source record shape is now strong enough to describe directly:
- `u16` word at record `+0x08` -> object `+0x3c` as `value << 16`
- `u16` word at record `+0x0a` -> object `+0x40` as `value << 16`
- `u8` byte at record `+0x0c` -> object `+0x44` as `value << 16`
- `FUN_80040d44` and `FUN_80040f78` are the projection helpers that turn those fixed-point object coordinates into the per-object screen rectangle stored at `+0x20..+0x2e`.
- `FUN_80041458` and `FUN_80041144` then consume that already-built rectangle directly during draw submission; they do not derive screen position on the fly.
Recovered projection model:
- `+0x3e` and `+0x42` are not separate authored fields. They are the high `16`-bit halves of the fixed-point `x` and `y` values stored at `+0x3c` and `+0x40`.
- The runtime builds an intermediate screen anchor in fixed-point at `+0x78/+0x7c` from those world coordinates:
- `screen_anchor_x = y - x`
- `screen_anchor_y = 2 * z - (x + y) / 2`
- `FUN_80040d44` computes that anchor with the exact writes:
- `obj+0x78 = ((y_hi - x_hi) << 16)`
- `obj+0x7c = (obj_z * 2) - ((x_hi + y_hi) << 15)`
- The projection helper then subtracts the current camera anchor from `DAT_800678d4 + 0x3c/+0x40`, subtracts sprite-frame origin/size metadata from `FUN_8004513c`, `FUN_800451d0`, `FUN_80045014`, and `FUN_800450a8`, and writes the final visible rectangle into `+0x20..+0x2e`.
What this means for the viewer:
- the PSX map does not want the PC viewer's current synthetic `world.x/world.y/world.z` guess based directly on raw candidate words
- the most defensible renderer-side export target is now the runtime's own projected anchor or the equivalent fixed-point world tuple that reproduces the same `screen_anchor_x/screen_anchor_y` formulas
- any importer that treats the raw authored coordinates as if they were already PC-style isometric world coordinates will bunch objects together or smear them across the map because PSX uses a different projection basis
- the current cache builder no longer synthesizes PC-style world coordinates from final screen anchors; it now keeps the candidate PSX `x/y` words directly in exported scene items and applies the runtime projection basis separately during anchor generation
Open parts that still matter:
- this closes the final world-to-screen math, but it does not yet prove which raw `post_audio_region_01` or `post_audio_region_00` record family feeds each constructor path
- it also does not close the type/resource lookup that selects the correct bundle/frame through `DAT_800758cc/d0/d4/d8`
- palette override is no longer wholly unresolved in the viewer path, but the `>= 0x00ac` source-base semantics and the runtime's later variant reselection still leave some color choices provisional
Immediate consequence for the next pass:
- the next executable-guided decode step should map candidate authored record words directly onto constructor inputs, not onto PC-style scene coordinates
- once the correct record family is tied to `FUN_800249f4` or `FUN_80024eec`, the renderer can export either:
- the raw fixed-point PSX world tuple, plus a viewer-side reproduction of the runtime projection, or
- the runtime-equivalent projected anchor/rectangle directly for debug rendering
- the cache builder now uses the recovered projection basis and prefers the loader-backed record family, but the exact record-to-constructor link and the authoritative height lane still need proof before this can be called a solved map export
## PSX Script / Usecode Equivalent
Current status:
- there is no evidence yet that the PSX build carries the exact same external `USECODE`/`EUSECODE.FLX` style asset pipeline used by the DOS version
- the current PSX executable-backed work has mostly exposed compiled resource loaders, animation/audio handlers, and image upload/decode paths rather than a separate obvious bytecode container
Current working question:
- the likely PSX equivalent, if one exists, may be either:
- compiled gameplay logic directly inside `SLUS_002.68`, or
- a separate embedded event/script resource format inside the `LSET`/other disc blobs that is not yet isolated
Immediate plan:
1. scan the PSX executable and current renamed function set for script/event-dispatch terminology or obvious VM-style control loops
2. compare any candidate dispatch path against the DOS usecode model only at the behavioral level, not by assuming the asset format is shared
3. keep this as a secondary track while map decoding takes priority
## Practical Extraction Paths
### Standard media first
The easiest wins are the standard PS1 media formats:
- `MOVIES/*.STR`: treat as PS1 video streams
- `AUDIO/*.XA`: treat as XA audio
- `ZZZ.ZZZ`: try as a movie stream too, especially against `FMV3.STR`
This does not need custom reverse engineering first.
### Custom `.WDL` extraction second
The `.WDL` files are the main custom-content frontier.
Current executable-backed extraction order:
1. run `tools/psx_extract_wdl.py` over representative `LSET*.WDL` files
2. treat `post_audio_region_01` and `post_audio_region_02` as the current best map-data extraction targets
3. treat `post_audio_region_04` as the current best sprite/graphics extraction target
4. carve any strict TIM blocks first, because those now have executable support via the type `4` / type `5` image handlers
5. separately carve `SPEC_A.WDL` / `MENUS/*.WDL` as raw image-oriented blobs
The level files and menu/special files should not be assumed to share one parser until that is proven.
## Recommended Ghidra Import Candidates
### Primary
1. `E:\emu\psx\Crusader - No Remorse\SLUS_002.68`
Reason:
- confirmed by `SYSTEM.CNF`
- valid `PS-X EXE`
- main native code image
### Secondary, only if useful for subsystem RE
2. `E:\emu\psx\Crusader - No Remorse\FMV.BIN`
Reason:
- clearly tied to FMV playback
- contains path and MDEC-related strings
- could be worth importing as a raw binary/data blob if the movie subsystem becomes a target
### Not primary code imports
These currently look like content, not executables:
- `E:\emu\psx\Crusader - No Remorse\ZZZ.ZZZ`
- `E:\emu\psx\Crusader - No Remorse\SPEC_A.WDL`
- `E:\emu\psx\Crusader - No Remorse\LSET1\L0.WDL`
- `E:\emu\psx\Crusader - No Remorse\MENUS\M13.WDL`
They may still be worth loading as raw binaries later for format RE, but they are not first-choice code imports.
## Current Working Model
- `SLUS_002.68` = main PS1 executable
- `FMV.BIN` = FMV helper/support blob
- `MOVIES/*.STR` = standard movie streams
- `AUDIO/*.XA` = standard XA audio
- `ZZZ.ZZZ` = likely renamed or duplicated movie stream data
- `LSET*.WDL` = structured level/resource containers
- `MENUS/*.WDL` and `SPEC_A.WDL` = raw-looking screen/menu resource blobs, possibly with some embedded standard PS1 image content
## Executable Catalog Findings
This batch focused on the imported `SLUS_002.68` executable as a catalog source rather than on the raw `WDL` bundles alone.
### Map inventory and mission-facing structure
Current executable-backed map findings:
- `wdl_resource_bundle_load_by_index` now has a direct string-backed proof for the shipped folder layout. The loader copies one of seven hardcoded path prefixes `\LSET1\L` through `\LSET7\L` based on map-index thresholds `10`, `20`, `30`, `40`, `50`, and `60`, then formats the final `.WDL` path.
- The extracted disc tree currently ships `62` level bundles total:
- `LSET1`: `L0` through `L9`
- `LSET2`: `L10` through `L19`
- `LSET3`: `L20` through `L29`
- `LSET4`: `L30` through `L39`
- `LSET5`: `L40` through `L49`
- `LSET6`: `L50` through `L58`
- `LSET7`: `L62` through `L64`
- So the shipped PSX map-bundle range is `L0..L64` with a real on-disc gap at `L59..L61`.
- The executable also preserves only `15` plain-text `Mission Briefing ^Mission N` strings, for `Mission 1` through `Mission 15`.
Current safest read:
- the PSX disc contains `62` shipped map/resource bundles used by the `LSET` loader
- the player-facing campaign/briefing flow exposed by the executable is `15` numbered missions
- any extra bundle coverage beyond that mission-facing set is currently better treated as lower-level map/resource inventory, not automatically as `15 == all shipped WDLs`
Per-bundle shipped inventory from the extracted disc tree:
| Bundle range | Folder | Count | Size range (bytes) |
|---|---|---:|---:|
| `L0..L9` | `LSET1` | 10 | `987,932 .. 1,312,624` |
| `L10..L19` | `LSET2` | 10 | `1,107,380 .. 1,314,992` |
| `L20..L29` | `LSET3` | 10 | `904,384 .. 1,221,556` |
| `L30..L39` | `LSET4` | 10 | `1,104,316 .. 1,321,656` |
| `L40..L49` | `LSET5` | 10 | `1,120,084 .. 1,303,732` |
| `L50..L58` | `LSET6` | 9 | `1,012,956 .. 1,341,684` |
| `L62..L64` | `LSET7` | 3 | `965,072 .. 1,150,428` |
### Passcodes and password-screen cheat status
Follow-up: the hidden passcode compare lane is now materially tighter in [docs/psx/jl-9-investigation.md](docs/psx/jl-9-investigation.md). The recovered decoder at `0x8003ec8c` is table-driven rather than plain ASCII, and its special hidden rows now give the strongest current code-backed support for public PSX folklore that `L0SR` is the cheat-mode password candidate while `XXXX` routes into a separate hidden branch.
Current executable-backed passcode findings:
- The mission-complete passcode display path at `80022cd4` and `80022f1c` synthesizes a `4`-character code from generated indexes.
- Those indexes are mapped through the hardcoded alphabet at `80063ef0`:
```text
BCDFGHJKLMNPQRSTVWXZ0123456789
```
- The resulting `4` characters are written into the temporary display buffer at `80063f6e..80063f71`, null-terminated at `80063f72`, and shown through the completion message at `80063f10`:
```text
^Congratulations!^ You have completed your mission.^^The passcode for the next mission is:^
```
- So PSX mission passcodes are definitely real executable-generated `4`-character values, not just external manual text.
Current best password-screen cheat list from public PSX references:
- `XXXX` = hidden pictures
- `L0SR` or `L0SER` = cheat-mode password reported by public sources; the conflicting transcription is almost certainly a `0` vs `O` issue and is not yet closed directly from the executable
Important executable-side caveat:
- none of the known public PSX mission passwords checked in this pass (`FWQP`, `HWQP`, `LRTN`) appear as plain ASCII strings inside `SLUS_002.68`
- the same is true for the public cheat-password candidates `XXXX`, `L0SR`, and `L0SER`
- current safest read is therefore `password entry and/or validation is numeric or transformed`, not `a plain embedded string table of passcodes`
- this pass closed the visible generation/display side, but it did **not** yet directly close the hidden cheat-password compare path
### Weapons and items
The executable does preserve user-facing text tables for equipment.
Recovered ammo names:
- `INVALID AMMO`
- `JL-2 AMMO`
- `AR-7 AMMO`
- `GL-303 AMMO`
- `RP-22 AMMO`
- `SG-A1 AMMO`
Recovered item names:
- `NULL ITEM`
- `INHIBITOR`
- `CREDITS`
- `SCI PLANS`
- `BLAST PAC`
- `DET PAC`
- `DATA LINK`
- `LAND MINE`
- `SPIDER BOMB`
- `MEDICAL KIT`
- `ENERGY CUBE`
- `FUSION PAC`
- `CHEMICAL BATTERY`
- `FISSION BATTERY`
- `FUSION BATTERY`
- `GRAVITON GENERATOR`
- `IONIC GENERATOR`
- `PLASMA GENERATOR`
Recovered weapon names:
- `RP-16`
- `RP-22`
- `RP-32`
- `SG-A1`
- `AC-88`
- `PA-31`
- `EM-4`
- `PL-1`
- `UV-9`
- `GL-303`
- `AR-7`
- `JL-2`
- `JL-9`
Current safest read:
- these are real executable-backed display-name tables, not guessed carryovers from the DOS build
- the PSX build still uses a recognizable Crusader equipment taxonomy even where some item labels differ from the more familiar DOS-side vocabulary
JL-2 / JL-9 follow-up:
- neither `JL-2` nor `JL-9` appears in the known DOS `Weapon_GetNameForShapeNo` tables already extracted in this repo for retail Remorse or Regret; those tables stop at the older DOS weapon families such as `BA-40`, `BA-41`, `PA-21`, `EM-4`, `SG-A1`, `RP-22`, `RP-32`, `AR-7`, `GL-303`, `PA-31`, `PL-1`, `AC-88`, `UV-9`, and the Regret-only additions `BK-16`, `LNR-81`, `XP-5`
- that makes `JL-2` and `JL-9` strong PSX-only naming additions rather than inherited PC names
- `JL-2` is also the only one of the two with an explicit PSX ammo label (`JL-2 AMMO`) in the nearby executable text table, while no matching `JL-9 AMMO` string has been recovered
- the focused follow-up in [docs/psx/jl-9-investigation.md](docs/psx/jl-9-investigation.md) now closes one important part of the question: `JL-9` is a real executable-backed final weapon-definition row rather than just a stray PSX-only string, and the strongest current acquisition route is a hidden passcode -> debug gate -> bulk weapon unlock path that likely reaches the extra late weapon channel
- that same focused follow-up now tightens the disambiguation further: the extra hidden-passcode/L0SR-adjacent non-PC weapon is now directly identified as `JL-9` (`0x0d`), while `JL-2` is the neighboring ordinary lane (`0x0c`)
- exact sprite identity and exact level placement for `JL-9` remain open and still need runtime or asset-side correlation
### Enemies
This pass did **not** recover a comparable plain-text enemy-name table from `SLUS_002.68`.
What is closed:
- the PSX executable has clean user-facing text for mission briefings, passcode UI, ammo, items, and weapons
- the same executable does **not** expose an equally obvious plain-text enemy catalog in its main printable-string regions
Current safest read:
- enemy identities in the PSX build are probably carried primarily as numeric resource/type ids, spawn tables, or script/resource references rather than as a direct display-name list
- the next enemy-focused pass should start from enemy spawn/type dispatch or resource-stream type tables, not from more blind string hunting
## Highest-Value Next Steps
1. Run `tools/psx_extract_wdl.py` over more `LSET*.WDL` samples and compare whether the high-offset region pattern stays stable across level sets.
2. Recover the password-entry validation path directly so the hidden PSX cheat-password compare logic can be proven from code instead of only cross-referenced from public password lists.
3. Focus map decoding on `post_audio_region_01` and `post_audio_region_02`, starting with table structures, coordinate ranges, and repeated record widths.
4. Focus sprite/graphics decoding on `post_audio_region_04`, including more aggressive TIM validation and possible packed-image expansion.
5. Recover the exact type IDs consumed by `psx_stream_install_type_runtime_banks` so the sprite/image resource records can be labeled more precisely.
6. Compare carved `post_audio_region_04` image assets against on-screen level graphics to separate sprite sheets from tiles.
7. Run the raw-blob fallback across `MENUS/*.WDL` to identify which menu files contain usable embedded TIM data and which are likely packed 15-bit images.