psx map standalone exporter

This commit is contained in:
Marco 2026-04-13 15:59:50 +02:00
commit 2f243976b6
16 changed files with 3254 additions and 5 deletions

View file

@ -101,6 +101,51 @@ Those counts are now historical rather than current for the focused `map 104` ex
- The immediate export-side blocker was config: `psx-remorse` was excluded from static export even though the prebuilt catalog/build-manager path already supports multi-map PSX scenes. - The immediate export-side blocker was config: `psx-remorse` was excluded from static export even though the prebuilt catalog/build-manager path already supports multi-map PSX scenes.
- The renderer config now includes `psx-remorse` in static export so full PSX exports can surface the full processed map set instead of dropping the version entirely. - The renderer config now includes `psx-remorse` in static export so full PSX exports can surface the full processed map set instead of dropping the version entirely.
## Live MCP Follow-Up (2026-04-13): Main-Visible Palette-Token Submit Normalization
- Scope for this focused pass: exact submit-flag packing and pre-submit normalization centered on `psx_draw_main_visible_object` (`0x80041458`), wrapper context in `psx_lset_world_frame_wrapper` (`0x80031f0c`) and `psx_draw_world_visible_passes` (`0x80041378`), with submitter override gates at `0x80044e10` and `0x80044eb8`.
- Main-visible now has instruction-level confirmation for flag packing at the call sites (`0x800415c0` / `0x800415e0`):
- `a3 = (obj_flags & 0x0002) | token_hi`
- `token_hi` is normalized earlier as `source_palette_word & 0xFF00` at `0x80041590`.
- Exact palette-token carriage for this lane is therefore bits `15:8` of submit flags (`token = flags >> 8`). Low nibble bits remain non-palette control bits.
- Submitter override gate behavior is now explicitly aligned across image-table and sprite submitters:
- gate expression is `(flags & ~0xF) != 0` (equivalent to `(flags & 0xfffffff0) != 0`)
- because world callers only contribute `obj_flags&0x0002` plus token high byte, bit `0x0002` alone does not trigger override; nonzero token high byte is the effective palette-override activator.
- Non-obvious normalization outcome for exporter logic: no additional wrapper-stage or no-op-hook mutation exists before `0x80041458` submits; palette-relevant normalization is local to main-visible draw and consists of high-byte extraction only.
Conservative live-artifact updates applied in Ghidra for this pass:
- nearby helper renames:
- `0x8003a3b0 -> psx_world_draw_tint_fade_step`
- `0x80038f10 -> psx_noop_frame_hook_38f10`
- `0x80044018 -> psx_noop_frame_hook_44018`
- targeted decompiler comments:
- `0x80041590` (token high-byte normalization)
- `0x800415c0` (final submit-flag packing)
- `0x80044e10` / `0x80044eb8` (shared override gate and token indexing)
- `0x80031f34` / `0x80031f3c` (wrapper no-op hooks do not mutate submit flags)
## Live MCP Follow-Up (2026-04-13): World Draw Pass + CLUT Routing Refresh
- Scope for this focused pass: live `SLUS_002.68` world draw and submitter routing at `0x80041378`, `0x80041458`, `0x80041144`, `0x80044bdc`, and `0x80044e9c`, plus CLUT table lanes `0x800a9f48` and `0x800a9f66`.
- World draw pass ordering remains fixed and explicit: stage-1 main-visible sorted slice first, then stage-2 special-visible queue, then HUD/overlay.
- Stage-1 and stage-2 world lanes still share submitter dispatch by bound resource kind (`kind==5` image-table submitter, otherwise sprite submitter).
- Palette-token handling remains lane-split and exporter-critical: main-visible injects authored high-byte palette token into submit flags; special-visible does not.
- CLUT override gate remains shared (`submit_flags & 0xfffffff0`), but CLUT table resolution still branches by submitter/resource-format lane:
- `psx_image_table_submit_frame`: high-byte token selects `psx_clut_override_table_by_palette_token[token]`, otherwise default bank CLUT.
- `psx_sprite_resource_submit_frame`: format-2 lane follows override table path; non-format-2 lane remaps token through bank CLUT indexing.
- Nearby anonymous helper cleanup in the same draw wrapper lane:
- `0x8002e534 -> psx_marker_channel_runtime_get_u16_86`
- `0x8002eee8 -> psx_marker_channel_runtime_get_u16_84`
Conservative live-artifact updates applied in Ghidra for this pass:
- `0x80041378`: decompiler comment clarifying stage-1 -> stage-2 -> HUD order.
- `0x800415c0`: decompiler comment clarifying main-visible authored token injection before image-table submit.
- `0x800412dc`: decompiler comment clarifying special-visible omits authored high-byte token injection.
- `0x80044ed0`: decompiler comment clarifying image-table CLUT override-table path.
- `0x80044e5c`: decompiler comment clarifying sprite default bank-CLUT path when override gate is inactive.
## Live MCP Follow-Up (2026-04-12): Wall-Family Split Failure Mode ## Live MCP Follow-Up (2026-04-12): Wall-Family Split Failure Mode
- Scope for this focused pass: live `SLUS_002.68` around the wall-heavy generic family band (`0x003e..0x004f`) and exporter donor-heavy bundles (`0x0008b48c`, `0x00085c40`). - Scope for this focused pass: live `SLUS_002.68` around the wall-heavy generic family band (`0x003e..0x004f`) and exporter donor-heavy bundles (`0x0008b48c`, `0x00085c40`).
@ -143,9 +188,220 @@ Conservative live-artifact updates applied in Ghidra for this pass:
- `0x80044e10`: comment clarifying sprite submit override gate and token-0 fallthrough. - `0x80044e10`: comment clarifying sprite submit override gate and token-0 fallthrough.
- `0x80044eb8`: comment clarifying image-table override keying behavior. - `0x80044eb8`: comment clarifying image-table override keying behavior.
## Live MCP Follow-Up (2026-04-13): Selector Install, Transition Selection, and Final Latch Closure
- Scope for this focused pass: selector-install and post-construction reselection chain centered on `psx_object_select_state_script` (`0x800260e8`), `psx_object_advance_state_script` (`0x80025d68`), `psx_object_select_state_from_transition_table` (`0x8001bca0`), `psx_type42_transition_selector_tick` (`0x80018578`), and delayed type-4 reselection around `0x8002906c`.
- `psx_object_select_state_script` is confirmed as install-only in exporter terms: it writes selector `obj+0x9e`, seeds state-script cursor (`obj+0x8c/0x90`), and does not write final visible frame token.
- Final visible frame/state token is latched in `psx_object_advance_state_script` where current script word is copied to `obj+0x94`; projection/draw lanes consume this live token.
- Transition-table selection remains two-stage and row-driven: transition code from `psx_type_transition_mode_policy_rows` (`0x80063a00`) selects selector base from `psx_type_transition_selector_rows` (`0x80063b4c`), then `psx_object_select_state_script` installs selector.
- In that transition path, runtime flag mutation is narrow and explicit: selector logic toggles `obj+0x1c` bit `0x0002` only; broad authored lane bits such as `0x0020` are not synthesized by this function.
- `psx_type42_transition_selector_tick` adds an early pre-latch gate before reseat/turn-driven selector dispatch: object must be within view margin and pass the object-lane `obj+0x1c & 0x0020` condition. Selector updates there still occur before the later `obj+0x94` latch point.
- The unresolved `FUN_8002906c` path is now closed by symbol state in live Ghidra: `0x8002906c` is `psx_type4_reselect_motion_state`, reached from `psx_type4_update_delayed_interaction` (`0x80029dac`) when delayed countdown reaches trigger. This is a post-construction reselection lane, not constructor-side initial bind.
Exporter-facing implication from executable evidence:
- A standalone JS exporter should treat selector install (`obj+0x9e`) and final latch (`obj+0x94`) as distinct channels.
- Transition-table and type-`0x0042` reselection may alter pre-latch selector/runtime flag state without directly proving final frame token at draw time.
- Cohort split logic should therefore prioritize latched `obj+0x94` capture (or strong proxy) over authored selector or transition slot alone.
Conservative live-artifact updates applied in Ghidra for this pass:
- `0x800260e8`: comment clarifying install-only selector semantics and no direct `obj+0x94` write.
- `0x80025d68`: comment clarifying final frame/state latch into `obj+0x94`.
- `0x8001bca0`: comment clarifying two-stage transition-row lookup and `0x0002`-only bit toggle scope.
- `0x80018578`: comment clarifying type-`0x0042` pre-latch gate and reseat ordering.
- `0x8002906c`: comment clarifying delayed post-construction reselection role.
- `0x80029dac`: comment clarifying delayed countdown trigger into post-construction reselection.
## Live MCP Follow-Up (2026-04-13): Authored Family Descriptor Convergence and Constructor Bind Closure
- Scope for this focused pass: authored section-0 family dispatch and constructor bind semantics on active `SLUS_002.68` at `psx_dispatch_section0_dispatch_roots` (`0x800256b0`), `psx_dispatch_section0_constructor_placements` (`0x800258cc`), `psx_object_create_simple_record` (`0x800249f4`), `psx_object_create_compound_record` (`0x80024eec`), and descriptor row `0x800626f8`.
- Section-0 root and constructor-placement records are now reconfirmed as one convergence lane for unresolved families (`0x0042`, `0x0049`, `0x0055..0x0063`): both dispatch through descriptor slot0 and converge on row `0x800626f8` callback `0x80013618`.
- Descriptor row role at `0x800626f8` is now explicitly preserved in disassembly comments as:
- slot0 `0x80013618` (`psx_spawn_compound_record_advance_state_once`)
- slot1 `0x80013688` (`psx_object_refresh_main_visible_and_cleanup`)
- slot2 `0x800254c8` (`psx_object_release_to_free_list`)
- Constructor bind semantics are now restated at function entry for exporter extraction:
- simple path copies authored route word `record+0x10 -> obj+0x1c`
- compound path copies authored route word `record+0x0A -> obj+0x1c`
- both read active art header from `DAT_800758d8[type]`
- both reuse `DAT_800758c8[type]` for kind-5 resources and otherwise build per-instance resource.
- Practical exporter consequence: authored-family divergence should continue to be modeled post-constructor (state/policy/route/latch channels), not as a section-0 descriptor callback split.
Conservative live-artifact updates applied in Ghidra for this pass:
- `0x80031c34`: `FUN_80031c34` renamed to `psx_spawn_type0b_compound_burst_for_active_object_sweep`.
- `0x800256b0`: decompiler comment clarifying root-dispatch descriptor convergence.
- `0x800258cc`: decompiler comment clarifying constructor-placement descriptor convergence.
- `0x800249f4`: decompiler comment clarifying simple constructor route-word copy and art-bind/cache split.
- `0x80024eec`: decompiler comment clarifying compound constructor route-word copy and art-bind/cache split.
- `0x800626f8`: disassembly comment clarifying shared descriptor-row slot mapping.
## Live MCP Follow-Up (2026-04-13): Source-Record Palette Token Provenance (`obj+0xa0`) and Region00 12-byte Fit
- Scope for this focused pass: live `SLUS_002.68` constructor/source-pointer storage and world main-visible token read at `0x80024b50`, `0x80025048`, `0x800258cc`, and `0x80041458`.
- Main-visible draw token read is now source-exact and band-split:
- for `0x003e..0x00ab`: token high byte from `(*(obj+0xa0)+0x06) & 0xff00`
- for `>=0x00ac`: token high byte from `(*(obj+0xa0)+0x0c) & 0xff00`
- Constructor storage of `obj+0xa0` is now instruction-explicit in both create paths:
- simple record path writes source pointer at `0x80024b50`
- compound/region00 path writes source pointer at `0x80025048`
- Section0 constructor-placement dispatch (`0x800258cc`) steps records by `+0x0c`, confirming region00-style authored records are 12 bytes in this lane.
- For currently visible unresolved families in this workflow (`0x0042`, `0x0049`, `0x0055..0x0063`), type band is `<0x00ac`, so main-visible token read uses source offset `+0x06` only; this offset is inside 12-byte records and is therefore a usable authored palette-token carrier.
- Consequence for exporter assumptions: treating region00 12-byte records as inherently unable to carry palette override bits is incorrect for these current families; only the `>=0x00ac` `+0x0c` read requires a longer source layout.
Conservative live-artifact updates applied in Ghidra for this pass:
- `0x80027f38`: `FUN_80027f38` renamed to `psx_alloc_runtime_snapshot_record_payload`.
- `0x80024b50`: decompiler comment clarifying simple-path `obj+0xa0` source-record pointer storage and later main-visible `+0x06/+0x0c` token reads.
- `0x80025048`: decompiler comment clarifying compound-path `obj+0xa0` storage and 12-byte-record compatibility with `<0x00ac` `+0x06` token read.
- `0x8004156c`: decompiler comment clarifying the exact type-band split and source offsets used for main-visible palette-token injection.
## Next Steps ## Next Steps
1. Trace the remaining high-volume band `0x0055..0x0063` in Ghidra with the same question used for `0x0042`: why does `DAT_800758d8` stay zero-sized while visible art still exists at runtime? 1. Trace the remaining high-volume band `0x0055..0x0063` in Ghidra with the same question used for `0x0042`: why does `DAT_800758d8` stay zero-sized while visible art still exists at runtime?
2. Use `map 104` as the primary regression target and dump its remaining fallback type/state/lane distribution before doing any broader heuristic expansion. 2. Use `map 104` as the primary regression target and dump its remaining fallback type/state/lane distribution before doing any broader heuristic expansion.
3. Compare unresolved zero-block types against nearby resolved donor types at the constructor/resource level, not only at the script-signature level, so borrowed bundles can be replaced with an executable-backed alias rule. 3. Compare unresolved zero-block types against nearby resolved donor types at the constructor/resource level, not only at the script-signature level, so borrowed bundles can be replaced with an executable-backed alias rule.
4. Keep the `DAT_800758d4` work on the bounds side unless a family-specific caller proves otherwise; this pass did not reopen that conclusion. 4. Keep the `DAT_800758d4` work on the bounds side unless a family-specific caller proves otherwise; this pass did not reopen that conclusion.
## Live MCP Follow-Up (2026-04-13): Art Payload Decode Semantics For Standalone Exporters
- Scope for this focused pass: active PSX `SLUS_002.68` type-4/type-5 install and submit corridor centered on `psx_resource_bind_single_image_vram_slot` (`0x800444e4`), `image_bundle_load_to_vram` (`0x80044614`), `sprite_rle_decode_rows` (`0x80045264`), and frame-geometry helpers (`0x80045014`, `0x800450a8`, `0x8004513c`, `0x800451d0`).
- Kind-4 decode lane is now extractor-explicit: single-image descriptors bind one VRAM slot and preserve descriptor width/height/format/payload metadata in the runtime resource; this is the minimal schema a standalone decoder must mirror before frame submission.
- Kind-5 decode lane is now exporter-explicit: bundle install builds a runtime frame table (`0x10` stride), allocates frame VRAM slots, and uploads either raw rows or RLE-decoded rows depending on frame flag bit0.
- RLE semantics are now pinned in live comments: positive control repeats a byte value, negative control copies literal run bytes, and zero terminates each row. This is the required offline decode contract for compressed WDL image payloads.
- Geometry extraction rule is now explicit for offline export: width/height/origin come from kind-specific frame schemas (`0x14` stride kind-4 descriptor rows versus `0x10` stride kind-5 runtime rows), not from visibility-lane flags.
Conservative live-artifact updates applied in Ghidra for this pass:
- Renames:
- `0x80045440` -> `psx_ui_image_bundle_get_frame_payload`
- `0x800455d4` -> `psx_ui_image_bundle_draw_temp_vram_frame`
- `0x80045d78` -> `psx_ui_image_bundle_draw_temp_vram_frame_static`
- Decompiler comments:
- `0x800444e4` (kind-4 single-image bind semantics)
- `0x80044614` (kind-5 bundle upload table and raw-vs-RLE branch)
- `0x80045264` (row-RLE decode contract for offline tools)
- `0x80045014`, `0x800450a8`, `0x8004513c`, `0x800451d0` (kind-split frame geometry schema for exporter parity)
## Live MCP Follow-Up (2026-04-13): Loader Bundle Install, Stream Runtime Banks, and Inflate Lane
- Scope for this focused pass: `wdl_resource_bundle_load_by_index` (`0x80039444`), `psx_stream_install_type_runtime_banks` (`0x80038f18`), `psx_install_type_state_script_component_extents_banks` (`0x8003917c`), `psx_install_type_art_active_header_and_built_resource` (`0x80045ffc`), detached-stream installer (`0x80040768`), and compressed-state inflate (`0x8003b00c`).
- `psx_stream_install_type_runtime_banks` now has exporter-relevant record layout closure: each streamed type entry begins with a fixed `0x14` header `(type_id, state_size, component_size, extents_size, active_art_header_size)` before payload bytes.
- Stream-lane install behavior is now role-split and explicit:
- installs state/script, component, and extents pointers into `DAT_800758cc/d0/d4`
- clears `DAT_800758c8[type]` (`psx_type_art_built_resource_bank`) to null
- installs only raw active-header pointer into `DAT_800758d8[type]` when `active_art_header_size != 0`.
- Practical consequence for standalone exporters: stream-runtime-bank lane seeds metadata/header pointers only; built drawable resources are resolved in the separate WDL art installer (`psx_install_type_art_active_header_and_built_resource`) through kind-4 bind and kind-5 image-table build.
- `wdl_resource_bundle_load_by_index` is now preserved as a multi-pass install sequence with two art/state waves, section-pack pointer install, detached runtime-stream install, optional compressed-state inflate, and only then root-record dispatch.
- Compressed-state lane remains pre-dispatch and persistent-state-gated: `psx_lzss_unpack_into_level_buffer` inflates a `0x3e00` block into `psx_level_decompressed_state_buffer` before runtime-header apply and root replay; a zero backref token terminates decode.
- Detached runtime-stream blob install (`0x80040768`) is now extraction-structured: header carries three leading lengths, then two 9-entry size arrays, then tail-transfer length used for SPU upload and sequence/VAB setup.
Conservative live-artifact updates applied in Ghidra for this pass:
- Renames:
- `0x8002b6b8` -> `psx_level_heap_push_cursor_mark`
- `0x8002b6e0` -> `psx_level_heap_pop_cursor_mark`
- `0x80024720` -> `psx_level_runtime_node_pool_init_0x32`
- Decompiler comments:
- `0x80038f18` (stream runtime-bank install role split and built-resource clear)
- `0x80040768` (detached runtime-stream blob format/install semantics)
- `0x8003b00c` (compressed-state inflate role, size lane, and termination)
- `0x80024720` (0x32-node cyclic runtime-node pool initialization)
## Live MCP Follow-Up (2026-04-13): Late Art-Bank Dual Feed and Constructor `0x58` Raw-Header Fast Path
- Scope for this focused pass: `wdl_resource_bundle_load_by_index` (`0x80039444`), late write sites `0x8003977c` / `0x80039a64`, `psx_install_type_art_active_header_and_built_resource` (`0x80045ffc`), `psx_create_image_resource_from_descriptor` (`0x80044434`), constructors `0x80024ab4` / `0x80024fac`, and sibling packed-stream helper `psx_stream_install_type_runtime_banks` (`0x80038f18`).
- `wdl_resource_bundle_load_by_index` does not leave `DAT_800758d8` from one monolithic late art bank. For each WDL pass (`SPEC_A.WDL` first, selected `LSET*.WDL` second), it performs two distinct art-facing installs:
- an earlier art-install blob (`header` field used as `local_a0`) that calls `psx_install_type_art_active_header_and_built_resource`
- a later header-only override blob (`header` field used as `local_98`) whose `8`-byte rows write raw active-header pointers directly into `DAT_800758d8[type]`
- The earlier art-install blob is now role-exact but still only medium confidence for raw standalone parsing:
- `psx_install_type_art_active_header_and_built_resource` first stores the incoming header pointer to `DAT_800758d8[type]`
- it then resolves kind `4` versus kind `5` resource build and writes the materialized resource to `DAT_800758c8[type]`
- it finally mirrors that built resource pointer back into `DAT_800758d8[type]`
- practical consequence: this pass is an install/build lane, not the final raw-header state seen by constructors after load completes
- The later header-only override blob is the safer standalone parsing target and now has a tighter in-memory schema:
- blob header begins with `count` and a directory offset
- payload base is `blob + 0x08`
- directory rows are `8` bytes each and are consumed as `(active_header_size, type_id)`
- when `active_header_size != 0`, loader stores the current payload cursor to `DAT_800758d8[type]`
- when `active_header_size == 0`, loader clears `DAT_800758d8[type]`
- payload cursor then advances by `active_header_size`
- Constructor-side reuse closure is now disassembly-backed and corrects an older decompiler-shaped reading: both constructors branch on `*(DAT_800758d8[type]) == 0x58`, not on `kind == 5`.
- `0x80024b0c`: simple constructor raw-header fast path
- `0x80025004`: compound constructor raw-header fast path
- when the first dword is `0x58`, constructors treat `DAT_800758d8[type]` as a raw active header and reuse `DAT_800758c8[type]` instead of calling `psx_create_image_resource_from_descriptor`
- `psx_create_image_resource_from_descriptor` remains the per-instance fallback builder for descriptors that do not arrive on that raw-header fast path. Its role did not change, but the exact condition for constructor reuse is now narrower and stronger.
- `psx_stream_install_type_runtime_banks` still matters as a sibling negative-evidence lane: it proves there is a separate packed per-type bank format with a fixed `0x14` entry header `(type_id, state_size, component_size, extents_size, active_art_header_size)`. That stream format should not be conflated with the later `8`-byte header-only override blob used by `wdl_resource_bundle_load_by_index`.
Candidate standalone raw-schema read after this pass:
- High confidence: the late header-only override blob is the final post-load source that leaves raw `0x58`-byte active headers in `DAT_800758d8`, and its row walk is `8`-byte `(size,type)` with payloads packed immediately after the blob header.
- Medium confidence: those `0x58`-byte active headers are the right standalone parser target for executable-faithful direct art binding because constructors discriminate on `0x58` and then reuse `DAT_800758c8`.
- Low to medium confidence: the earlier art-install blob also feeds the same type lane, but its raw on-disk row encoding is still unresolved for standalone parsing because the runtime walk consumes pointer-like aux entries without an intervening relocation helper.
Conservative live-artifact updates applied in Ghidra for this pass:
- Decompiler comments:
- `0x8003977c` (late header-only override writes raw active-header pointers)
- `0x80039a64` (map-local repeat of the same override lane)
- `0x800460d4` (built-resource mirror into `DAT_800758d8` is later overwritten by header-only pass)
- `0x80024b0c` (simple constructor `0x58` raw-header fast path)
- `0x80025004` (compound constructor `0x58` raw-header fast path)
## Live MCP Follow-Up (2026-04-13): Visibility Routing, Stage Lane Choice, and Main-Visible Ordering
- Scope for this focused pass: active PSX `SLUS_002.68` around `psx_object_integrate_motion_and_route_visible` (`0x800131a8`), neighboring player/object update corridor (`0x8001263c..0x80013688`), and main-visible ordering helpers centered on `0x8002be6c`, `0x8002c89c`, `0x8002ca74`, `0x8002d778`, and `0x8002e064`.
- Route split is now pinned as object-local in executable terms: `psx_object_integrate_motion_and_route_visible` chooses stage-2 only when `type==4` or `obj+0x1c` has bit `0x0400`; otherwise it remains in stage-1 main-visible projection/sort lane.
- In the same function, policy table reads (`DAT_800675f8[type]`) are downstream gating and ordering controls, not lane selectors. The key stage route decision remains the object-local `0x0400` branch.
- Main-visible ordering is now graph-explicit: `psx_main_visible_order_compare_pair_for_graph` computes relation codes from projected extents and policy bits, `psx_main_visible_order_graph_link_new_object` records edges, and `psx_main_visible_list_sort_range` resolves a dependency-eligible ordered slice while unlinking conflicts.
- Stage-1 ordering is therefore not a simple distance sort. It is a dependency-graph sort with policy-biased tie resolution, and that graph order feeds the main-visible draw lane.
Conservative live-artifact updates applied in Ghidra for this pass:
- Renames:
- `0x8002cd60` -> `psx_main_visible_order_graph_release_node`
- `0x8002ce38` -> `psx_main_visible_order_graph_compact_nodes`
- `0x8002cf3c` -> `psx_main_visible_order_graph_reset_node_slot`
- `0x8002cfb0` -> `psx_main_visible_order_graph_swap_node_slots`
- Decompiler comments:
- `0x800131a8` (stage-1 vs stage-2 route split for exporter lane choice)
- `0x8002be6c` (pair-order relation semantics and policy bit role)
- `0x8002d778` (dependency-aware main-visible sort behavior)
- `0x8002cd60` (node release semantics)
- `0x8002ce38` (node compaction/prune semantics)
- `0x8002cf3c` (node slot reset semantics)
- `0x8002cfb0` (node slot swap semantics during compaction)
Exporter-facing implication from executable evidence:
- A standalone JS exporter should keep route lane and order phase separate in diagnostics and matching:
- lane selection key: `type==4 || (obj+0x1c & 0x0400)`
- stage-1 ordering key family: graph relation codes plus policy-adjusted compare/unlink behavior
- Flattening stage-1 order to simple Y/depth or ignoring graph edge pruning will diverge from executable main-visible draw order even when lane choice is correct.
## Live MCP Follow-Up (2026-04-13): Kind-4/Kind-5 Palette Selection Field Closure
- Scope for this focused pass: active PSX `SLUS_002.68` around `psx_resource_bind_single_image_vram_slot` (`0x800444e4`), `image_bundle_load_to_vram` (`0x80044614`), and submitters `psx_sprite_resource_submit_frame` (`0x80044bdc`) / `psx_image_table_submit_frame` (`0x80044e9c`) with lane callers in `psx_draw_main_visible_object` (`0x80041458`) and `psx_draw_special_visible_queue` (`0x80041144`).
- Default CLUT source for both kind-4 and kind-5 remains resource field `resource+0x08`, seeded from descriptor/bundle header `+0x14` during bind.
- No additional per-frame resource header field in the `0x800444e4/0x80044614` bind corridor directly selects CLUT index at submit time; frame-table records in this corridor drive geometry/payload upload, not palette-bank selection.
- One additional resource header field does materially affect palette-routing semantics: resource format (`resource+0x04`, sourced from header `+0x10`) changes sprite override behavior.
- format `== 2`: override token uses `psx_clut_override_table_by_palette_token[token]` directly.
- format `!= 2`: override token is remapped as a bank-table row key in `psx_clut_table_by_resource_bank` (`(token<<4)` halfword lane).
- Submit high-byte token source remains draw-lane/object-authored, not resource-header-local:
- main-visible may inject authored high byte (`source+0x06` for `0x003e..0x00ab`, `source+0x0c` for `>=0x00ac`).
- special-visible does not inject authored high byte.
Conservative live-artifact updates applied in Ghidra for this pass:
- Decompiler comments:
- `0x800444e4` (kind-4 default palette-bank seed from header `+0x14`)
- `0x80044614` (kind-5 default palette-bank seed and format-2 `+0x10` offset behavior)
- `0x80044bdc` (sprite submit default/override CLUT resolution split)
- `0x80044e9c` (image-table submit default/override CLUT resolution)
Exporter-facing implication from executable evidence:
- Standalone palette selection should treat header `+0x14` as the default bank key only.
- Additional CLUT selection must come from runtime submit flags (lane/object authored token), with sprite format-aware override routing.
- Do not infer palette index from frame-table width/height/origin/offset fields in the bind/upload helpers; those fields are geometry/payload metadata, not CLUT selectors.

View file

@ -426,7 +426,7 @@ Functions and globals inspected in this pass:
- `psx_lzss_unpack_into_level_buffer` (`0x8003b00c`, renamed this pass) - `psx_lzss_unpack_into_level_buffer` (`0x8003b00c`, renamed this pass)
- `psx_lzss_pack_level_buffer` (`0x8003aba8`, renamed this pass) - `psx_lzss_pack_level_buffer` (`0x8003aba8`, renamed this pass)
- `psx_load_type_state_banks` (`0x8003917c`) - `psx_load_type_state_banks` (`0x8003917c`)
- `psx_cache_type_art_descriptor_and_resource` (`0x80045ffc`) - `psx_install_type_art_active_header_and_built_resource` (`0x80045ffc`)
- section-0 authored dispatch: - section-0 authored dispatch:
- `psx_dispatch_section0_dispatch_roots` (`0x800256b0`) - `psx_dispatch_section0_dispatch_roots` (`0x800256b0`)
- `psx_dispatch_section0_constructor_placements` (`0x800258cc`) - `psx_dispatch_section0_constructor_placements` (`0x800258cc`)
@ -603,7 +603,7 @@ Functions/data inspected in this pass:
- `psx_object_select_state_from_transition_table` - `psx_object_select_state_from_transition_table`
- `psx_object_integrate_motion_and_route_visible` - `psx_object_integrate_motion_and_route_visible`
- `psx_draw_main_visible_object` - `psx_draw_main_visible_object`
- `psx_cache_type_art_descriptor_and_resource` - `psx_install_type_art_active_header_and_built_resource`
- `psx_level_post_load_runtime_reset` - `psx_level_post_load_runtime_reset`
- `psx_section0_dispatch_root_seed_marker_channel_table` - `psx_section0_dispatch_root_seed_marker_channel_table`
@ -1033,7 +1033,7 @@ The resource creation/submission lane is now explicit enough to treat as stable
### Creation and per-type cache ### Creation and per-type cache
- `psx_cache_type_art_descriptor_and_resource` (`0x80045ffc`) stores the per-type descriptor at `DAT_800758d8[type]` and materialized drawable resource at `DAT_800758c8[type]`. - `psx_install_type_art_active_header_and_built_resource` (`0x80045ffc`) first stores the incoming active header at `DAT_800758d8[type]`, then materializes and caches the drawable resource at `DAT_800758c8[type]`, and temporarily mirrors that built resource back into `DAT_800758d8[type]` until the later header-only override stream restores raw `0x58`-byte headers.
- Exact kind branch in this cache helper: - Exact kind branch in this cache helper:
- `0x80046048`: `kind == 4` -> `psx_resource_bind_single_image_vram_slot` (`0x800444e4`) - `0x80046048`: `kind == 4` -> `psx_resource_bind_single_image_vram_slot` (`0x800444e4`)
- `0x80046054`: `kind == 5` -> allocate bundle wrapper and call `image_bundle_load_to_vram` (`0x80044614`) - `0x80046054`: `kind == 5` -> allocate bundle wrapper and call `image_bundle_load_to_vram` (`0x80044614`)

View file

@ -36,6 +36,20 @@ Extractor-relevant clarified schema in this pass:
- `psx_lzss_pack_level_buffer` is the save-side counterpart (caller `0x80049890`) and repacks the same level-state lane, confirming this blob family is persistent runtime substrate rather than a direct authored placement stream. - `psx_lzss_pack_level_buffer` is the save-side counterpart (caller `0x80049890`) and repacks the same level-state lane, confirming this blob family is persistent runtime substrate rather than a direct authored placement stream.
- `psx_load_type_state_banks` installs per-type runtime payload pointers into `psx_type_state_script_bank` / `psx_type_simple_component_bank` / `psx_type_companion_extents_bank`; constructors consume `psx_type_simple_component_bank[type]` at `0x80024c60` to seed object behavior program fields. - `psx_load_type_state_banks` installs per-type runtime payload pointers into `psx_type_state_script_bank` / `psx_type_simple_component_bank` / `psx_type_companion_extents_bank`; constructors consume `psx_type_simple_component_bank[type]` at `0x80024c60` to seed object behavior program fields.
## 2026-04-13 Live Subordinate Section Deltas
Live MCP pass on active `SLUS_002.68` tightened the first genuinely concrete subordinate-section read for the still-unresolved level bundle lanes:
- `psx_apply_deferred_control_command` reads both `psx_ctor_placement_section_ptr` (`DAT_80067938`) and `psx_level_section_pack_base` (`DAT_80067838`).
- The function treats constructor-placement-adjacent data as an index lane: it reads a `u16` from `(psx_ctor_placement_section_ptr - 2) + index*2`, multiplies it by `8`, and then walks `8`-byte rows from `psx_level_section_pack_base + index*8` until a row with `bit15` set in the leading halfword terminates the chain.
- Those `8`-byte rows are not renderer-facing floor cells. They are consumed as deferred world/control mutation records and are fanned out into both `psx_apply_deferred_control_to_dispatch_roots` and `psx_apply_deferred_control_to_live_objects`, where they mutate authored root records and already-instantiated live objects by type/id and small state bytes.
- Adjacent subordinate lane `psx_control_opcode_stream_table` (`DAT_80067840`) is also now tighter: `psx_control_assign_opcode_stream_by_index` reads it as a pointer/offset table for nested control opcode streams and transition/state-machine setup, not as geometry.
Practical consequence for region-02 work:
- At least part of the broad "missing map" hypothesis for this area is now closed in the negative direction. The subordinate slices installed from the level section pack are already proven to include deferred control/event infrastructure and opcode-stream pointers, not just hidden floor or wall placement tables.
- For `LSET1/L0.WDL`, the raw `post_audio_region_02` leading bytes also reinforce that read: the region begins with mixed/high-entropy payload rather than a clean count-prefixed offset table or plausible direct `0x0c` placement rows. Current safest read is that region `02` is a mixed resource/control payload zone that must be split into smaller typed sub-lanes before any floor-specific decode claim is credible.
## 2026-04-12 Live Section-0 Descriptor Dispatch Deltas ## 2026-04-12 Live Section-0 Descriptor Dispatch Deltas
Live MCP pass on active `SLUS_002.68` tightened section-0 record-family dispatch evidence for unresolved graphics-heavy types. Live MCP pass on active `SLUS_002.68` tightened section-0 record-family dispatch evidence for unresolved graphics-heavy types.

View file

@ -739,7 +739,7 @@ Exporter status after the next renderer pass:
Next decoded runtime layers from the constructor pass: Next decoded runtime layers from the constructor pass:
- `DAT_800758d8` is the per-type art/template bank, not the missing whole-map substrate. `wdl_resource_bundle_load_by_index` populates it from an `8`-byte descriptor table, and both `FUN_800249f4` and `FUN_80024eec` consume it before calling `FUN_80044434` through the loader-side helper path. - `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_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_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. - `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.

View file

@ -0,0 +1,22 @@
# PSX VRAM Dump Bundle Grounding
## Scope
- Active dump-grounding target: `binary/Crusader - No Remorse (USA) GPU RAM 2.bin`
- Immediate goal: keep a short evidence log for bundle ids that are visibly present in live PSX VRAM so bundle export/palette work can stay anchored to known runtime art.
## Known Bundles
- `bundle_00b3158`: chest NE, appears in the dump
- `bundle_0011ad4c`: generator, appears in the dump
- `bundle_0015b80`: part of a console, appears in the dump
## Current Palette Rule
- For dump-grounded `mode 1` bundle export, prefer the live GPU RAM CLUT slice at row `0xF0`, `x=0..255`, treated as one contiguous `256`-entry palette.
- This is the same rule already recorded in `docs/psx/psx.md` for the verified cabinet console family and is now the first palette source to test before falling back to WDL-local palette heuristics.
## Follow-Up
- Map these absolute bundle ids back to extracted `out/psx_wdl_disc/.../sprite_bundles` directories when their owning WDL/region is identified.
- Add matching framebuffer/VRAM crop evidence here as specific bundles are confirmed.

3
psx-map-exporter/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.cache/**
.output/**
node_modules/**

View file

@ -0,0 +1,248 @@
# PSX Map Exporter Implementation Analysis
## Summary
The exporter should be treated as a controlled probe, not as a final renderer.
The key design choice is to keep the whole path raw-file-based and auditable:
- raw WDL in
- explicit carve + record extraction + bundle extraction
- cached sprite/frame artifacts out
- final composed map PNG out
That keeps the work independent from the existing viewer and makes every wrong assumption inspectable.
## Why This Architecture
The existing PSX work already proved two important negative results:
- direct raw bundle-order art binding is too weak to count as solved
- viewer-side polish is low value until extraction is isolated and testable
So the new exporter should optimize for:
- small number of assumptions
- easy intermediate inspection
- direct correspondence to documented executable behavior where possible
## Chosen `v0` Path
### 1. Parse only the parts of the WDL we can justify now
Implemented directly from docs:
- `0x34` header
- audio-size dword
- absolute region boundaries recovered from high offset words in the header
Not implemented in `v0`:
- full loader section choreography
- detached runtime stream install
- inflated runtime-state interpretation
Those are preserved as future extension points but not required for the first PNG.
### 2. Prefer loader-sized `post_audio_section_00` as a layered authored probe
Why:
- the old region00-first path is now known to overfit the small root-dispatch family
- loader-sized section parsing recovers the dense constructor-placement records from the same first real section, currently modeled as paired 12-byte records inside 24-byte row chunks
- the same section also exposes the smaller root-dispatch lane, which is independently renderable offline and now belongs in the default layered probe
Tradeoff:
- the art binding is still diagnostic-only for many types
- constructor placements are better understood as one runtime object seed layer, not the final visible map or the static world substrate
- root-dispatch rows now render as a second authored layer, but they still do not close the runtime-only control, state, and dynamic effect gaps
This is acceptable for `v0` because the project goal is a fresh, inspectable layered baseline rather than a falsely confident full renderer.
### 3. Decode art from raw bundles, but keep binding diagnostic
What is strong already:
- bundle scan can be constrained by executable-backed header fields
- frame decode and row-RLE semantics are pinned
What is still weak:
- exact late-`DAT_800758d8` parse and type-to-resource selection path
- exact palette path
So the current standalone probe does the right split:
- strong part: raw bundle/frame decode
- diagnostic part: `typeWord -> bundle slot`
It also exports candidate late active-header override blobs to cache so the Ghidra-backed `DAT_800758d8` header-only lane can be inspected per run without pretending that binding is already solved.
The newer conclusion from `LSET1/L0` label failures is narrower than the earlier wording: if one type repeatedly paints a coherent room footprint with obviously wrong art, the exporter is probably visualizing valid world-object seed placement while still missing the separate static-world layer and the downstream executable bind/state path that chooses the final drawable resource.
Viewer-derived sidecars and donor mappings are no longer acceptable here because they blur exactly the binding problem the exporter is meant to isolate.
## Module Plan
### `src/wdl.js`
Responsibilities:
- read header words
- compute post-audio start
- derive regions from absolute boundary values
- expose region buffers and summary metadata
Reason to isolate it:
- the carve is likely to change as more loader details land
- record extraction should not depend on header internals
### `src/bundles.js`
Responsibilities:
- scan the graphics bank for plausible kind-4/kind-5 bundles
- parse bundle headers and frame entries
- decode frame bytes
- emit grayscale PNG-ready RGBA buffers
When the standalone scan yields zero bundles for a map, `src/export-map.js` may hydrate bundle offsets and frame geometry from `out/psx_wdl_disc/.../summary.json` and continue decoding the actual frame bytes from the raw WDL.
Reason to isolate it:
- this code is reusable even if the map schema changes
- it is the strongest raw-file-backed part of the exporter
### `src/export-map.js`
Responsibilities:
- choose the record source
- choose diagnostic art binding
- normalize screen bounds
- write cache metadata and composed outputs
This file holds the intentionally weak parts of `v0` so they remain easy to replace.
### `src/render.js`
Responsibilities:
- sprite compositing
- sort order approximation
- PNG encoding
- neutral opaque background for evaluation-friendly probe output
## Data Contracts
### Record
```json
{
"index": 0,
"source": "region00",
"typeWord": 74,
"xWord": 5635,
"yWord": 3815,
"zWord": 0,
"selectorWord": 1,
"laneWord": 32,
"screenX": -1820,
"screenY": -4725
}
```
### Bundle
```json
{
"offsetInRegion": 58808,
"absoluteOffset": 534068,
"kind": 5,
"mode": 2,
"paletteIndex": 12,
"frameCount": 3,
"dataOffset": 112,
"frameTableOffset": 52
}
```
### Scene Item
```json
{
"recordIndex": 0,
"bundleSlot": 74,
"bundleAbsoluteOffset": 954728,
"frameIndex": 1,
"screenX": -1820,
"screenY": -4725,
"drawX": -1879,
"drawY": -4815,
"width": 96,
"height": 91,
"originX": 59,
"originY": 90
}
```
## Validation Strategy
`v0` validation should answer four questions only:
1. Did the raw WDL parse into the documented regions?
2. Did the graphics-bank scanner recover plausible bundles with decoded frames?
3. Did the constructor-placement extractor recover plausible section-0 rows from the loader-sized section view?
4. Did the compositor produce a non-empty PNG with recognizable art silhouettes on a neutral background?
This is enough for the first pass.
## Risks
### Binding risk
The diagnostic bundle binding is the weakest part of the pipeline.
Expected failure modes:
- correct placement with wrong art family
- repeated art across several type families
- frame clamping where selector words exceed available bundle frames
Mitigation:
- keep the chosen bundle slot, frame clamp count, and bundle-repeat metrics in output metadata
### Schema risk
The `region00` record extractor uses a plausibility scan instead of a final loader schema.
Expected failure modes:
- false positives in some maps
- missing records when the preamble differs
Mitigation:
- preserve `recordStartOffset`
- make `region01` fallback selectable from CLI
### Palette risk
Grayscale is intentionally not faithful to the executable color path.
Mitigation:
- keep the grayscale rule explicit
- do not mix partial CLUT heuristics into `v0`
## Immediate Follow-Up Options
After `v0` works, the next pass should choose one of these:
1. Replace provisional art binding with a loader-backed type/resource lookup.
2. Parse the late `DAT_800758d8` bank directly from the large late graphics area instead of relying on slot order.
3. Add executable-backed CLUT reconstruction once the palette path is pinned tightly enough.
4. Recover stage-1 graph ordering when sprite placement is stable enough to make sort differences meaningful.

View file

@ -0,0 +1,256 @@
# PSX Map Exporter Spec
## Goal
`psx-map-exporter` is a standalone Node.js probe for Crusader PSX map extraction.
It exists to prove a fresh end-to-end path from raw `LSET*.WDL` input to:
- extracted intermediate sprite assets under `.cache`
- a rendered map PNG under `.output`
This project does not reuse `Crusader-Map-Viewer` code, scene caches, donor mappings, or sidecar summaries as binding inputs. It only consumes raw PSX assets plus the documented executable-backed findings from `docs/psx` and the live Ghidra session.
## Scope
Version `v0` is intentionally narrow.
It will:
- read one PSX `LSET*.WDL` file
- parse the documented `0x38`-byte top-level header
- carve the post-audio map/art regions from header-derived boundaries
- parse the loader-sized post-audio sections as a second, higher-value view of the file layout
- extract the dense constructor-placement family from `post_audio_section_00`
- keep the smaller root-dispatch family available as a comparison probe
- render a layered authored probe that can combine constructor placements with the smaller root-dispatch lane
- scan `post_audio_region_04` for type-4/type-5 sprite bundles
- decode bundle frames directly from the raw WDL
- write extracted frame PNGs to `.cache`
- compose a probe map PNG to `.output`
It will not claim full runtime parity yet.
Known non-goals for `v0`:
- exact `DAT_800758d8/d0/cc/d4` parity
- exact CLUT reproduction
- full stage-1 dependency-graph ordering
- exact type-to-resource binding for unresolved families
- full `post_audio_region_01` / `post_audio_region_02` semantic decode
## Evidence Constraints
The implementation is grounded in these current facts from the docs and Ghidra:
- `LSET*.WDL` uses a fixed `0x38`-byte top-level header.
- The second dword is the audio/SPU blob size.
- The old region-only carve is not sufficient on its own for visible-object recovery; loader-sized `post_audio_section_00` contains both the small root-dispatch rows and the dense constructor-placement rows.
- The file contains a post-audio area with four high-confidence absolute boundaries that split:
- `post_audio_region_00`
- `post_audio_region_01`
- `post_audio_region_02`
- `post_audio_region_03`
- `post_audio_region_04`
- The small count-prefixed section-0 root-dispatch rows are real, but they are not the whole map object set.
- The dense constructor-placement records recovered from loader-sized `post_audio_section_00` are currently the best standalone live-object seed source, not a proven final visible-map layer.
- Current strongest standalone layout read: the constructor-placement lane is a count-prefixed `12`-byte substream inside the loader-sized section-0 span rather than a whole-section `24`-byte row grid. For `LSET1/L0.WDL`, the best current candidate has a section-relative header at `0x38`, a record start at `0x3c`, and a reported count of `1182` records.
- The constructor-placement stream can extend slightly past the nominal `post_audio_section_00` slice, so standalone parsing must follow the detected stream count from the section-0 base instead of truncating strictly at the section object boundary.
- `post_audio_region_04` is the strongest current graphics bank candidate.
- The direct `typeWord -> bundle slot` scan-order binding is disproven as a final art rule and is retained only as a diagnostic bundle-family probe.
- The real art/template lane is `DAT_800758d8`, but the executable now shows two distinct late art feeds per WDL pass rather than one monolithic bank:
- an earlier art-install blob that builds resources and temporarily mirrors them into `DAT_800758d8`
- a later `8`-byte header-only override blob that restores raw active-header pointers into `DAT_800758d8`
- The later header-only override is the safer standalone parser target: constructors branch on first dword `0x58` and then reuse `DAT_800758c8[type]`, so the final post-load `DAT_800758d8` state is a raw-header lane, not a permanently built-resource lane.
- Type-4/type-5 drawable bundles expose width, height, palette mode/index, frame count, frame table offset, and data offset in the raw bundle header.
- Bundle frame entries use a `20`-byte row with size, relative data offset, width, height, origin x/y, and flags.
- `sprite_rle_decode_rows` uses row-local control bytes:
- positive: repeat next byte N times
- negative: copy next `abs(N)` literal bytes
- zero: end row
- The executable projection basis is:
$$
screen_x = y - x
$$
$$
screen_y = 2z - \frac{x + y}{2}
$$
## Input Model
The exporter accepts either:
- a direct `--wdl` path
- or a `--source` path relative to a PSX disc root
Default disc root for local workspace runs:
- `d:/Ghidra/Crusader-Map-Viewer/map_renderer/STATIC_PSX`
Expected source examples:
- `LSET1/L0.WDL`
- `LSET4/L37.WDL`
## Output Layout
### `.cache`
Per-run cache path:
- `.cache/<map-stem>/`
Contents:
- `wdl-summary.json`
- `records.json`
- `bundles.json`
- `frame-manifest.json`
- `active-header-overrides.json`
- `sprites/<bundle-offset>/frame_<n>.png`
The cache is disposable. It exists to preserve intermediate evidence and make re-runs inspectable.
`records.json` now also records constructor-stream detection metadata when available: stream header offset, record start offset, reported count, and the initial structured-prefix run.
The cache also records candidate late `DAT_800758d8` header-only override blobs as a standalone diagnostic. Those candidates are not used as final art binding yet.
`wdl-summary.json` now also emits `sceneInterpretation`, which is an explicit warning-bearing classification of what the current export most likely represents. For constructor-placement exports this should currently read as a constructor-fed live-object seed lane rather than a final visible-world reconstruction.
### `.output`
Per-run final outputs:
- `.output/<map-stem>.png`
- `.output/<map-stem>.json`
- `.output/<map-stem>_<layer>.png` for each rendered authored layer when layered mode is active
The JSON stores the final probe scene manifest used to draw the PNG.
The `.output` folder is reset at the start of each export so evaluation only sees artifacts from the current run.
The `.output/<map-stem>.json` manifest inherits `sceneInterpretation` from `wdl-summary.json` so consumers do not need to infer that warning from prose docs alone.
## Record Extraction Rules
`v0` now uses the loader-sized `post_audio_section_00` extraction paths as the primary scene source.
Current interpretation constraint:
- `section0_constructor_placements` should currently be treated as constructor-fed world-object seed records.
- They preserve meaningful layout and projection structure, but current evidence does not support treating them as the complete visible map or static architecture layer.
- If a render shows coherent room layout with globally wrong or repeated art, the exporter is currently visualizing one runtime object lane without the downstream per-type bind/state path and without the separate static-world substrate.
Record extraction rule:
- `auto` / `combined` / `layered` mode merges both authored section-0 families into one layered probe:
- constructor placements provide the dense live-object seed lane
- root-dispatch rows provide the smaller comparison and auxiliary authored lane
- `constructors` / `region01` mode first searches the section-0 span for a count-prefixed `12`-byte constructor stream and, when found, treats each record as six little-endian `u16` words:
- `typeWord`
- `xWord`
- `yWord`
- `zWord`
- `selectorWord`
- `laneWord`
- If a count-prefixed constructor stream is not found, the exporter falls back to the older whole-section `24`-byte paired-record scan as a compatibility probe.
- `roots` / `region00` mode keeps the small count-prefixed root-dispatch probe for comparison and negative-evidence checks
Plausibility filter:
- `typeWord` in a conservative visible-family range
- not all coordinate words are zero
- `laneWord` is non-zero and within the current conservative control-word range
This is explicitly a probe schema, not a final loader-faithful schema.
Current negative result:
- Correcting the constructor stream start/count for `LSET1/L0.WDL` only changes the standalone constructor probe slightly (`1130 -> 1135` records, `1090 -> 1095` rendered items) and does not materially change the repeated wrong-art output. Current evidence therefore points to unresolved art/runtime binding as the primary blocker, not a missed constructor-tail decode.
## Art Binding Rule
`v0` uses one explicit diagnostic binding rule:
- `typeWord -> bundle slot index`
That means the sorted bundle list from `post_audio_region_04` is indexed directly by `typeWord` when the slot exists.
This rule is explicitly not claimed as final executable truth. Current docs and Ghidra evidence show the final art path goes through the late `DAT_800758d8` art bank plus downstream state-script/runtime selection. The slot rule remains useful only as a clean standalone negative-evidence probe.
For the generic family band now dominating `LSET1/L0` failures (`0x003e`, `0x0042`, `0x0044`, `0x0045`, `0x004f`, `0x0059`, `0x005b`), repeated wrong art is now understood as both a binding failure and a semantic-layer failure: the exporter is currently visualizing constructor-fed runtime object seeds as though they were the final visible world.
The chosen bundle and clamped frame index, plus binding-diversity metrics, are preserved in output metadata so failures stay auditable.
When debug labels are enabled for a map render, labels now identify unique rendered resources rather than per-instance placements. The stable label key is currently `bundle offset + clamped frame + resolved palette`. Validation atlas sheets still use progressive cell indices.
## Rendering Rule
For each record:
- compute `screenX` and `screenY` from the documented projection basis
- select frame index from `selectorWord`, clamped to available frames
- place sprite top-left at:
- `screenX - originX`
- `screenY - originY`
Current draw order is conservative:
- main-visible before special-visible
- then ascending `screenY`
- then ascending `screenX`
This is a probe approximation. The later graph-based stage-1 ordering still belongs to a future pass.
The rendered PNG uses a neutral opaque background by default so probe silhouettes are legible without relying on transparency.
## Color Rule
`v0` emits grayscale art from raw pixel indices.
Reason:
- bundle frame decode is already well constrained
- full CLUT parity is not
- grayscale preserves shape/variant evidence without pretending the palette problem is solved
Transparent index `0` stays transparent.
## CLI
Primary command:
```powershell
node src/cli.js --source LSET1/L0.WDL
```
Supported options:
- `--source <relative-path>`
- `--wdl <absolute-or-relative-file>`
- `--disc-root <path>`
- `--map-source <auto|combined|layered|constructors|roots|region01|region00>`
- `--out-name <stem>`
## Success Criteria
`v0` is successful if it can:
- parse a raw `LSET*.WDL`
- recover the loader-sized section view alongside the region carve
- scan bundles directly from `post_audio_region_04`
- decode at least one frame from raw data
- extract a stable constructor-placement record set from `post_audio_section_00`
- write extracted sprite PNGs into `.cache`
- write a readable diagnostic probe PNG into `.output`
## Planned Follow-Ups
- replace diagnostic slot binding with a direct parser for the late header-only `DAT_800758d8` override stream and bundle match path
- recover the exact raw on-disk encoding of the earlier built-resource art-install blob so the two late art feeds are modeled separately instead of flattened into one guessed bank
- identify and parse the separate static-world or subordinate level substrate that complements the constructor-fed live-object lane, instead of treating section-0 constructor placements as the whole map
- add palette/CLUT reconstruction
- add stage-1 graph ordering recovery
- compare the probe scene against fixed live samples such as `map 104` without reintroducing viewer-side donor assumptions

22
psx-map-exporter/package-lock.json generated Normal file
View file

@ -0,0 +1,22 @@
{
"name": "psx-map-exporter",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psx-map-exporter",
"dependencies": {
"pngjs": "^7.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"name": "psx-map-exporter",
"private": true,
"type": "module",
"scripts": {
"export": "node src/cli.js"
},
"dependencies": {
"pngjs": "^7.0.0"
}
}

View file

@ -0,0 +1,475 @@
import { PNG } from 'pngjs';
function readU32LE(buffer, offset) {
return buffer.readUInt32LE(offset);
}
function readU16LE(buffer, offset) {
return buffer.readUInt16LE(offset);
}
function rowByteWidth(width, mode) {
return mode === 2 ? Math.ceil(width / 2) : width;
}
function psx555ToRgba(color) {
const red = (color & 0x1f) * 255 / 31;
const green = ((color >> 5) & 0x1f) * 255 / 31;
const blue = ((color >> 10) & 0x1f) * 255 / 31;
const alpha = (color & 0x7fff) === 0 ? 0 : 255;
return {
red: Math.round(red),
green: Math.round(green),
blue: Math.round(blue),
alpha,
};
}
export function extractPaletteSets(buffer, headerWords) {
if (!Array.isArray(headerWords) || headerWords.length < 4) {
return { palettes16: [], palettes256: [] };
}
const paletteOffset = headerWords[2];
const paletteSize = headerWords[3];
if (paletteSize !== 0x1000 || paletteOffset < 0 || paletteOffset + paletteSize > buffer.length) {
return { palettes16: [], palettes256: [] };
}
const blob = buffer.subarray(paletteOffset, paletteOffset + paletteSize);
const palettes16 = [];
const palettes256 = [];
for (let offset = 0; offset + 0x20 <= blob.length; offset += 0x20) {
const palette = [];
for (let entry = 0; entry < 16; entry += 1) {
palette.push(readU16LE(blob, offset + entry * 2));
}
palettes16.push(palette);
}
for (let offset = 0; offset + 0x200 <= blob.length; offset += 0x200) {
const palette = [];
for (let entry = 0; entry < 256; entry += 1) {
palette.push(readU16LE(blob, offset + entry * 2));
}
palettes256.push(palette);
}
return { palettes16, palettes256 };
}
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
if (!Array.isArray(palettes16) || palettes16.length < 16) {
return null;
}
if (!Number.isInteger(startIndex) || startIndex < 0 || startIndex + 16 > palettes16.length) {
return null;
}
const palette = [];
for (let paletteIndex = startIndex; paletteIndex < startIndex + 16; paletteIndex += 1) {
const clut = palettes16[paletteIndex];
if (!Array.isArray(clut) || clut.length < 16) {
return null;
}
palette.push(...clut.slice(0, 16));
}
return palette.length === 256 ? palette : null;
}
export function buildMode1RuntimePalette(palettes16) {
return buildMode1RuntimePaletteForIndex(palettes16, 0);
}
export function extractMode1PaletteFromGpuRamDump(buffer, row = 0xf0, startX = 0) {
const vramWidthWords = 1024;
const vramHeight = 512;
const expectedSize = vramWidthWords * vramHeight * 2;
if (!buffer || buffer.length < expectedSize) {
return null;
}
if (!Number.isInteger(row) || row < 0 || row >= vramHeight) {
return null;
}
if (!Number.isInteger(startX) || startX < 0 || startX + 256 > vramWidthWords) {
return null;
}
const palette = [];
const rowStart = (row * vramWidthWords * 2) + (startX * 2);
for (let index = 0; index < 256; index += 1) {
palette.push(readU16LE(buffer, rowStart + index * 2));
}
return palette;
}
export function buildMode1PaletteBank(palettes16) {
if (!Array.isArray(palettes16) || palettes16.length < 16) {
return [];
}
const paletteBank = [];
for (let startIndex = 0; startIndex < palettes16.length; startIndex += 1) {
const palette = buildMode1RuntimePaletteForIndex(palettes16, startIndex);
if (palette?.length === 256) {
paletteBank[startIndex] = palette;
}
}
return paletteBank;
}
export function choosePalette(palettes16, frames, mode) {
if (mode !== 2 || !Array.isArray(palettes16) || palettes16.length === 0) {
return null;
}
const usedIndices = new Set();
for (const frame of frames) {
const rawPixels = frame.rawPixels;
if (!rawPixels) {
continue;
}
for (const value of rawPixels) {
usedIndices.add(value & 0x0f);
usedIndices.add((value >> 4) & 0x0f);
}
}
usedIndices.delete(0);
if (usedIndices.size === 0) {
return 0;
}
let bestIndex = null;
let bestScore = -1;
for (let paletteIndex = 0; paletteIndex < palettes16.length; paletteIndex += 1) {
const palette = palettes16[paletteIndex];
const distinct = new Set();
for (const index of usedIndices) {
distinct.add((palette[index] ?? 0) & 0x7fff);
}
let channelSpread = 0;
let nonZeroCount = 0;
for (const value of distinct) {
if (value === 0) {
continue;
}
nonZeroCount += 1;
const rgba = psx555ToRgba(value);
channelSpread += rgba.red + rgba.green + rgba.blue;
}
if (nonZeroCount === 0) {
continue;
}
const score = nonZeroCount * 100000 + channelSpread;
if (score > bestScore) {
bestScore = score;
bestIndex = paletteIndex;
}
}
return bestIndex;
}
function isValidBundleHeader(buffer, offset) {
if (offset + 0x34 > buffer.length) {
return false;
}
const kind = readU32LE(buffer, offset + 0x00);
const width = readU32LE(buffer, offset + 0x08);
const height = readU32LE(buffer, offset + 0x0c);
const mode = readU32LE(buffer, offset + 0x10);
const dataOffset = readU32LE(buffer, offset + 0x1c);
const frameCount = readU32LE(buffer, offset + 0x20);
const frameTableOffset = readU32LE(buffer, offset + 0x24);
if (kind !== 4 && kind !== 5) {
return false;
}
if (width === 0 || height === 0 || width > 512 || height > 512) {
return false;
}
if (mode !== 1 && mode !== 2) {
return false;
}
if (frameCount === 0 || frameCount > 256) {
return false;
}
if (offset + dataOffset > buffer.length) {
return false;
}
const recordTableSize = frameCount * 20;
if (dataOffset < 0x34 + recordTableSize) {
return false;
}
if (frameTableOffset !== 0x34) {
return false;
}
return true;
}
export function scanSpriteBundles(region) {
const bundles = [];
const seenRanges = [];
for (let offset = 0; offset + 0x34 <= region.buffer.length; offset += 4) {
if (!isValidBundleHeader(region.buffer, offset)) {
continue;
}
if (seenRanges.some(([start, end]) => offset >= start && offset < end)) {
continue;
}
const kind = readU32LE(region.buffer, offset + 0x00);
const width = readU32LE(region.buffer, offset + 0x08);
const height = readU32LE(region.buffer, offset + 0x0c);
const mode = readU32LE(region.buffer, offset + 0x10);
const paletteIndex = readU32LE(region.buffer, offset + 0x14);
const dataOffset = readU32LE(region.buffer, offset + 0x1c);
const frameCount = readU32LE(region.buffer, offset + 0x20);
const frameTableOffset = 0x34;
if (paletteIndex > 127) {
continue;
}
const frames = [];
let valid = true;
for (let index = 0; index < frameCount; index += 1) {
const entryOffset = offset + frameTableOffset + (index * 20);
const flags = readU32LE(region.buffer, entryOffset + 0x00);
const relativeDataOffset = readU32LE(region.buffer, entryOffset + 0x08);
const frameWidth = readU16LE(region.buffer, entryOffset + 0x0c);
const frameHeight = readU16LE(region.buffer, entryOffset + 0x0e);
const originX = readU16LE(region.buffer, entryOffset + 0x10);
const originY = readU16LE(region.buffer, entryOffset + 0x12);
const dataStart = offset + dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
if (
frameWidth === 0 ||
frameHeight === 0 ||
frameWidth > 512 ||
frameHeight > 512 ||
dataStart >= region.buffer.length ||
(((flags & 1) === 0) && (dataStart + rawSize > region.buffer.length))
) {
valid = false;
break;
}
let consumed;
if ((flags & 1) === 1) {
const decoded = decodeRleRows(region.buffer, dataStart, frameWidth, frameHeight, mode);
if (!decoded) {
valid = false;
break;
}
consumed = decoded.consumed;
} else {
consumed = rawSize;
}
frames.push({
index,
consumed,
relativeDataOffset,
width: frameWidth,
height: frameHeight,
originX,
originY,
flags,
dataStart,
absoluteDataStart: region.offset + dataStart,
});
}
if (!valid) {
continue;
}
seenRanges.push([offset, offset + dataOffset]);
bundles.push({
slot: bundles.length,
offsetInRegion: offset,
absoluteOffset: region.offset + offset,
kind,
width,
height,
mode,
paletteIndex,
dataOffset,
frameCount,
frameTableOffset,
frames,
});
}
return bundles;
}
function decodeRleRows(buffer, start, width, height, mode) {
const expectedSize = rowByteWidth(width, mode) * height;
const output = [];
let cursor = start;
let rows = 0;
while (rows < height) {
if (cursor >= buffer.length) {
return null;
}
const controlByte = buffer[cursor];
cursor += 1;
const signedControl = controlByte < 0x80 ? controlByte : controlByte - 0x100;
if (signedControl === 0) {
rows += 1;
continue;
}
if (signedControl < 0) {
const count = controlByte & 0x7f;
if (cursor + count > buffer.length) {
return null;
}
output.push(...buffer.subarray(cursor, cursor + count));
cursor += count;
} else {
if (cursor >= buffer.length) {
return null;
}
const value = buffer[cursor];
cursor += 1;
for (let repeat = 0; repeat < signedControl; repeat += 1) {
output.push(value);
}
}
if (output.length > expectedSize) {
return null;
}
}
if (output.length !== expectedSize) {
return null;
}
return {
rawPixels: Buffer.from(output),
consumed: cursor - start,
};
}
function decodeIndexedPixels(rawPixels, width, height, mode) {
if (mode === 2) {
const indexed = Buffer.alloc(width * height, 0);
let source = 0;
let target = 0;
const rowBytes = rowByteWidth(width, mode);
for (let row = 0; row < height; row += 1) {
const rowEnd = Math.min(source + rowBytes, rawPixels.length);
while (source < rowEnd && target < indexed.length) {
const value = rawPixels[source];
source += 1;
indexed[target] = value & 0x0f;
target += 1;
if (target < indexed.length) {
indexed[target] = (value >> 4) & 0x0f;
target += 1;
}
}
}
return indexed;
}
return Buffer.from(rawPixels.subarray(0, width * height));
}
function indexedToGrayscaleRgba(pixels, mode) {
const rgba = Buffer.alloc(pixels.length * 4, 0);
for (let index = 0; index < pixels.length; index += 1) {
const sourceValue = pixels[index];
const value = mode === 2 ? sourceValue * 17 : sourceValue;
const out = index * 4;
rgba[out + 0] = value;
rgba[out + 1] = value;
rgba[out + 2] = value;
rgba[out + 3] = value === 0 ? 0 : 255;
}
return rgba;
}
function indexedToColorRgba(pixels, palette) {
const rgba = Buffer.alloc(pixels.length * 4, 0);
for (let index = 0; index < pixels.length; index += 1) {
const paletteIndex = pixels[index];
const color = palette[paletteIndex] ?? 0;
const converted = psx555ToRgba(color);
const out = index * 4;
rgba[out + 0] = converted.red;
rgba[out + 1] = converted.green;
rgba[out + 2] = converted.blue;
rgba[out + 3] = paletteIndex === 0 ? 0 : converted.alpha;
}
return rgba;
}
export function decodeBundleFrame(region, bundle, frameIndex, palette = null) {
const frame = bundle.frames[Math.max(0, Math.min(frameIndex, bundle.frames.length - 1))];
const rawSize = rowByteWidth(frame.width, bundle.mode) * frame.height;
let rawPixels;
let consumed;
if ((frame.flags & 1) === 1) {
const decoded = decodeRleRows(region.buffer, frame.dataStart, frame.width, frame.height, bundle.mode);
if (!decoded) {
throw new Error(`Failed to decode RLE frame at 0x${frame.absoluteDataStart.toString(16)}`);
}
rawPixels = decoded.rawPixels;
consumed = decoded.consumed;
} else {
if (frame.dataStart + rawSize > region.buffer.length) {
throw new Error(`Frame overruns bundle region at 0x${frame.absoluteDataStart.toString(16)}`);
}
rawPixels = region.buffer.subarray(frame.dataStart, frame.dataStart + rawSize);
consumed = rawSize;
}
const indexedPixels = decodeIndexedPixels(rawPixels, frame.width, frame.height, bundle.mode);
const rgba = Array.isArray(palette)
? indexedToColorRgba(indexedPixels, palette)
: indexedToGrayscaleRgba(indexedPixels, bundle.mode);
return {
...frame,
consumed,
rawPixels: Buffer.from(rawPixels),
indexedPixels,
requestedFrameIndex: frameIndex,
clampedFrameIndex: frame.index,
rgba,
};
}
export function encodePng(rgba, width, height) {
const png = new PNG({ width, height });
png.data = Buffer.from(rgba);
return PNG.sync.write(png);
}

160
psx-map-exporter/src/cli.js Normal file
View file

@ -0,0 +1,160 @@
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { exportMap } from './export-map.js';
function parseArgs(argv) {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const options = {
discRoot: path.resolve(
moduleDir,
'..',
'..',
'..',
'Crusader-Map-Viewer',
'map_renderer',
'STATIC_PSX'
),
gpuRamDump: path.resolve(
moduleDir,
'..',
'..',
'binary',
'Crusader - No Remorse (USA) GPU RAM 2.bin'
),
mapSource: 'auto',
sceneScope: 'probe',
validationBundles: [],
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === '--source') {
options.source = next;
index += 1;
continue;
}
if (arg === '--wdl') {
options.wdl = next;
index += 1;
continue;
}
if (arg === '--disc-root') {
options.discRoot = path.resolve(next);
index += 1;
continue;
}
if (arg === '--map-source') {
options.mapSource = next;
index += 1;
continue;
}
if (arg === '--scene-scope') {
options.sceneScope = next;
index += 1;
continue;
}
if (arg === '--gpu-ram-dump') {
options.gpuRamDump = path.resolve(next);
index += 1;
continue;
}
if (arg === '--validation-bundles') {
options.validationBundles = String(next)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
index += 1;
continue;
}
if (arg === '--out-name') {
options.outName = next;
index += 1;
continue;
}
if (arg === '--debug-labels') {
options.debugLabels = true;
continue;
}
if (arg === '--help' || arg === '-h') {
options.help = true;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printHelp() {
console.log([
'Usage: node src/cli.js (--source LSET1/L0.WDL | --wdl <file>) [options]',
'',
'Options:',
' --source <relative path> WDL path relative to the PSX disc root',
' --wdl <file> Direct WDL path',
' --disc-root <path> PSX asset root, defaults to STATIC_PSX in the sibling workspace',
' --scene-scope <probe|full> Probe is supported; full is intentionally disabled until raw floor and full-map decode is recovered',
' --gpu-ram-dump <path> PSX GPU RAM dump used for live mode-1 palette extraction',
' --validation-bundles <csv> Comma-separated bundle absolute offsets (hex or decimal) for bundle palette-sweep validation sheets',
' --map-source <auto|combined|layered|constructors|roots|region01|region00>',
' --out-name <stem> Override the output stem',
' --debug-labels Write an additional labeled scene PNG for item identification',
'',
'Notes:',
' auto now prefers a layered probe that combines constructor placements with root-dispatch rows.',
' combined/layered explicitly renders both authored section-0 lanes together.',
' roots/region00 keeps the smaller section-0 root-dispatch probe for comparison.',
].join('\n'));
}
async function main() {
const options = parseArgs(process.argv);
if (options.help) {
printHelp();
return;
}
if (!options.source && !options.wdl) {
printHelp();
throw new Error('Either --source or --wdl is required.');
}
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const wdlPath = options.wdl
? path.resolve(options.wdl)
: path.resolve(options.discRoot, options.source);
const result = await exportMap({
projectRoot,
wdlPath,
sourceRelPath: options.source,
mapSource: options.mapSource,
sceneScope: options.sceneScope,
gpuRamDumpPath: options.gpuRamDump,
validationBundles: options.validationBundles,
outName: options.outName,
debugLabels: Boolean(options.debugLabels),
});
console.log(JSON.stringify({
sourceFile: wdlPath,
mapStem: result.mapStem,
recordCount: result.summary.recordCount,
renderableItemCount: result.summary.renderableItemCount,
bundleCount: result.summary.bundleCount,
outputPngPath: result.outputPngPath,
debugPngPath: result.debugPngPath,
outputJsonPath: result.outputJsonPath,
validationOutputs: result.validationOutputs,
region02Example: result.region02Example,
}, null, 2));
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,169 @@
import { encodePng } from './bundles.js';
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
const GLYPHS = {
'0': ['111', '101', '101', '101', '111'],
'1': ['010', '110', '010', '010', '111'],
'2': ['111', '001', '111', '100', '111'],
'3': ['111', '001', '111', '001', '111'],
'4': ['101', '101', '111', '001', '001'],
'5': ['111', '100', '111', '001', '111'],
'6': ['111', '100', '111', '101', '111'],
'7': ['111', '001', '001', '001', '001'],
'8': ['111', '101', '111', '101', '111'],
'9': ['111', '101', '111', '001', '111'],
};
function clearCanvas(width, height, background = null) {
const canvas = Buffer.alloc(width * height * 4, 0);
if (!background) {
return canvas;
}
fillRect(
canvas,
width,
height,
0,
0,
width,
height,
background.red ?? 0,
background.green ?? 0,
background.blue ?? 0,
background.alpha ?? 255,
);
return canvas;
}
function fillRect(canvas, canvasWidth, canvasHeight, x, y, width, height, red, green, blue, alpha) {
const startX = Math.max(0, x);
const startY = Math.max(0, y);
const endX = Math.min(canvasWidth, x + width);
const endY = Math.min(canvasHeight, y + height);
for (let drawY = startY; drawY < endY; drawY += 1) {
for (let drawX = startX; drawX < endX; drawX += 1) {
const target = ((drawY * canvasWidth) + drawX) * 4;
canvas[target + 0] = red;
canvas[target + 1] = green;
canvas[target + 2] = blue;
canvas[target + 3] = alpha;
}
}
}
function drawGlyph(canvas, canvasWidth, canvasHeight, glyph, x, y, red, green, blue, alpha) {
const rows = GLYPHS[glyph];
if (!rows) {
return;
}
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex];
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
if (row[columnIndex] !== '1') {
continue;
}
fillRect(canvas, canvasWidth, canvasHeight, x + columnIndex, y + rowIndex, 1, 1, red, green, blue, alpha);
}
}
}
function drawLabel(canvas, canvasWidth, canvasHeight, text, x, y) {
const label = String(text);
const glyphWidth = 3;
const glyphHeight = 5;
const spacing = 1;
const boxWidth = (label.length * (glyphWidth + spacing)) - spacing + 2;
const boxHeight = glyphHeight + 2;
fillRect(canvas, canvasWidth, canvasHeight, x, y, boxWidth, boxHeight, 0, 0, 0, 220);
let cursorX = x + 1;
for (const glyph of label) {
drawGlyph(canvas, canvasWidth, canvasHeight, glyph, cursorX, y + 1, 255, 255, 0, 255);
cursorX += glyphWidth + spacing;
}
}
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
for (let y = 0; y < sprite.height; y += 1) {
const canvasY = dstY + y;
if (canvasY < 0 || canvasY >= canvasHeight) {
continue;
}
for (let x = 0; x < sprite.width; x += 1) {
const canvasX = dstX + x;
if (canvasX < 0 || canvasX >= canvasWidth) {
continue;
}
const sourceX = flipped ? (sprite.width - 1 - x) : x;
const source = ((y * sprite.width) + sourceX) * 4;
const alpha = sprite.rgba[source + 3];
if (alpha === 0) {
continue;
}
const target = ((canvasY * canvasWidth) + canvasX) * 4;
canvas[target + 0] = sprite.rgba[source + 0];
canvas[target + 1] = sprite.rgba[source + 1];
canvas[target + 2] = sprite.rgba[source + 2];
canvas[target + 3] = alpha;
}
}
}
export function renderMap(items, options = {}) {
if (items.length === 0) {
throw new Error('No renderable scene items were produced.');
}
const bounds = items.reduce(
(state, item) => ({
minX: Math.min(state.minX, item.drawX),
minY: Math.min(state.minY, item.drawY),
maxX: Math.max(state.maxX, item.drawX + item.width),
maxY: Math.max(state.maxY, item.drawY + item.height),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
);
const padding = 16;
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
const height = Math.max(1, (bounds.maxY - bounds.minY) + (padding * 2));
const canvas = clearCanvas(width, height, options.background ?? DEFAULT_BACKGROUND);
for (const item of items) {
blitRgba(
canvas,
width,
height,
item.sprite,
item.drawX - bounds.minX + padding,
item.drawY - bounds.minY + padding,
Boolean(item.flipped)
);
}
if (options.drawLabels) {
for (const item of items) {
drawLabel(
canvas,
width,
height,
item.labelId ?? item.id,
item.drawX - bounds.minX + padding,
item.drawY - bounds.minY + padding
);
}
}
return {
width,
height,
bounds,
png: encodePng(canvas, width, height),
};
}

449
psx-map-exporter/src/wdl.js Normal file
View file

@ -0,0 +1,449 @@
import path from 'node:path';
function readU32LE(buffer, offset) {
return buffer.readUInt32LE(offset);
}
function readU16LE(buffer, offset) {
return buffer.readUInt16LE(offset);
}
const ALLOWED_LANE_WORDS = new Set([0x20, 0x22, 0x30]);
const PSX_SCREEN_SCALE = 2;
function uniqueSorted(values) {
return [...new Set(values)].sort((left, right) => left - right);
}
export function parseLsetWdl(buffer, filePath) {
if (buffer.length < 0x34) {
throw new Error(`File too small for LSET header: ${filePath}`);
}
const headerSize = readU32LE(buffer, 0);
if (headerSize < 0x34 || headerSize % 4 !== 0 || headerSize > buffer.length) {
throw new Error(`Unexpected header size 0x${headerSize.toString(16)} in ${filePath}`);
}
const headerWords = [];
for (let offset = 0; offset < headerSize; offset += 4) {
headerWords.push(readU32LE(buffer, offset));
}
const audioSize = readU32LE(buffer, 4);
const postAudioStart = headerSize + audioSize;
const sectionSizes = [];
for (let offset = 0x08; offset < 0x38 && offset + 4 <= buffer.length; offset += 4) {
sectionSizes.push(readU32LE(buffer, offset));
}
const sections = [];
let sectionCursor = postAudioStart;
for (let index = 0; index < sectionSizes.length; index += 1) {
const size = sectionSizes[index];
if (size <= 0 || sectionCursor + size > buffer.length) {
break;
}
sections.push({
name: `post_audio_section_${String(index).padStart(2, '0')}`,
offset: sectionCursor,
size,
buffer: buffer.subarray(sectionCursor, sectionCursor + size),
});
sectionCursor += size;
}
const boundaryCandidates = uniqueSorted(
headerWords
.slice(2)
.filter((value) => value > postAudioStart && value < buffer.length)
);
if (boundaryCandidates.length < 4) {
throw new Error(
`Expected at least 4 post-audio boundaries, found ${boundaryCandidates.length} in ${filePath}`
);
}
const selectedBoundaries = boundaryCandidates.slice(0, 4);
const regions = [];
const regionStarts = [postAudioStart, ...selectedBoundaries];
const regionEnds = [...selectedBoundaries, buffer.length];
regions.push({
name: 'audio_or_spu_blob',
offset: headerSize,
size: audioSize,
buffer: buffer.subarray(headerSize, postAudioStart),
});
for (let index = 0; index < regionStarts.length; index += 1) {
const offset = regionStarts[index];
const end = regionEnds[index];
regions.push({
name: `post_audio_region_${String(index).padStart(2, '0')}`,
offset,
size: end - offset,
buffer: buffer.subarray(offset, end),
});
}
return {
filePath,
fileName: path.basename(filePath),
buffer,
headerSize,
audioSize,
postAudioStart,
headerWords,
sectionSizes,
sections,
boundaryCandidates,
regions,
};
}
function isPlausibleRecord(words) {
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
if (typeWord < 0x20 || typeWord > 0x1ff) {
return false;
}
if ((xWord | yWord | zWord) === 0) {
return false;
}
if (laneWord === 0 || laneWord > 0x1fff) {
return false;
}
if (selectorWord > 0x03ff) {
return false;
}
return true;
}
function isStructuredCandidate(words) {
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
if (typeWord >= 0x200) {
return false;
}
if (xWord === 0 && yWord === 0) {
return false;
}
if (xWord >= 0x4000 || yWord >= 0x4000) {
return false;
}
if (zWord > 0x20 || selectorWord > 0x04) {
return false;
}
if (!ALLOWED_LANE_WORDS.has(laneWord)) {
return false;
}
return true;
}
function buildRecord(words, source, offset, rawWords = words) {
if (!isPlausibleRecord(words)) {
return null;
}
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
const screenX = (yWord - xWord) * PSX_SCREEN_SCALE;
const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE;
return {
index: -1,
source,
offset,
words,
rawWords,
typeWord,
xWord,
yWord,
zWord,
selectorWord,
laneWord,
screenX,
screenY,
sourceFamily: null,
sourceRole: null,
recordSide: null,
rowIndex: -1,
};
}
function decodeRecord(buffer, offset, source) {
const words = [];
for (let cursor = 0; cursor < 12; cursor += 2) {
words.push(readU16LE(buffer, offset + cursor));
}
return buildRecord(words, source, offset, words);
}
function makeAsciiPreview(buffer, length = 64) {
const slice = buffer.subarray(0, Math.min(length, buffer.length));
let text = '';
for (const value of slice) {
text += value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : '.';
}
return text;
}
function scanOffsetTableCandidates(buffer, maxBase = 0x200) {
const candidates = [];
const limit = Math.min(maxBase, Math.max(0, buffer.length - 8));
for (let base = 0; base <= limit; base += 2) {
const count = readU16LE(buffer, base);
if (count <= 0 || count >= 0x200) {
continue;
}
const tableEnd = base + 2 + count * 2;
if (tableEnd > buffer.length) {
continue;
}
let previous = -1;
let monotonic = true;
const firstOffsets = [];
for (let index = 0; index < count; index += 1) {
const offset = readU16LE(buffer, base + 2 + index * 2);
if (index < 8) {
firstOffsets.push(offset);
}
if (offset < previous || offset >= buffer.length) {
monotonic = false;
break;
}
previous = offset;
}
if (monotonic) {
candidates.push({ base, count, firstOffsets });
}
}
return candidates;
}
function scanPlausible12ByteRecordStarts(buffer, maxBase = 0x200) {
const starts = [];
const limit = Math.min(maxBase, Math.max(0, buffer.length - 12));
for (let base = 0; base <= limit; base += 2) {
const words = [];
for (let cursor = 0; cursor < 12; cursor += 2) {
words.push(readU16LE(buffer, base + cursor));
}
if (isPlausibleRecord(words)) {
starts.push({ base, words });
}
}
return starts;
}
function buildPreviewRows(buffer, rowWordWidth = 8, rowCount = 24) {
const rows = [];
const maxRows = Math.min(rowCount, Math.floor(buffer.length / (rowWordWidth * 2)));
for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
const offset = rowIndex * rowWordWidth * 2;
const words = [];
for (let wordIndex = 0; wordIndex < rowWordWidth; wordIndex += 1) {
words.push(readU16LE(buffer, offset + wordIndex * 2));
}
const bytes = buffer.subarray(offset, offset + rowWordWidth * 2);
rows.push({
rowIndex,
offset,
words,
ascii: makeAsciiPreview(bytes, bytes.length),
});
}
return rows;
}
export function summarizeRegion02(region) {
const firstU32 = [];
const firstU16 = [];
for (let offset = 0; offset + 4 <= region.buffer.length && firstU32.length < 8; offset += 4) {
firstU32.push(readU32LE(region.buffer, offset));
}
for (let offset = 0; offset + 2 <= region.buffer.length && firstU16.length < 16; offset += 2) {
firstU16.push(readU16LE(region.buffer, offset));
}
const offsetTableCandidates = scanOffsetTableCandidates(region.buffer);
const plausible12ByteRecordStarts = scanPlausible12ByteRecordStarts(region.buffer);
return {
offset: region.offset,
size: region.size,
firstU32,
firstU16,
asciiPreview: makeAsciiPreview(region.buffer, 96),
previewRows: buildPreviewRows(region.buffer),
offsetTableCandidates: offsetTableCandidates.slice(0, 16),
plausible12ByteRecordStarts: plausible12ByteRecordStarts.slice(0, 16),
note: offsetTableCandidates.length === 0 && plausible12ByteRecordStarts.length === 0
? 'Leading region-02 bytes do not look like a count-prefixed offset table or direct 12-byte placement rows.'
: 'Region-02 exposes candidate structure and should be correlated against live loader-installed subordinate slices.',
};
}
export function parseRegion00Records(region) {
const rowCount = region.buffer.length >= 4 ? readU32LE(region.buffer, 0) : 0;
const records = [];
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
const rowBase = 4 + rowIndex * 24;
if (rowBase + 24 > region.buffer.length) {
break;
}
const rowWords = [];
for (let wordIndex = 0; wordIndex < 12; wordIndex += 1) {
rowWords.push(readU16LE(region.buffer, rowBase + wordIndex * 2));
}
const leftRawWords = rowWords.slice(0, 6);
const rightRawWords = rowWords.slice(6, 12);
const leftWords = [rowWords[4], rowWords[5], rowWords[0], rowWords[1], rowWords[2], rowWords[3]];
const rightWords = [rowWords[10], rowWords[11], rowWords[6], rowWords[7], rowWords[8], rowWords[9]];
for (const [recordSide, wordSet, rawWordSet, sourceByteOffset] of [
['left', leftWords, leftRawWords, 0],
['right', rightWords, rightRawWords, 12],
]) {
const record = buildRecord(wordSet, 'region00', rowBase + sourceByteOffset, rawWordSet);
if (!record) {
continue;
}
record.sourceFamily = 'section0_dispatch_roots';
record.sourceRole = 'root-dispatch';
record.rowIndex = rowIndex;
record.recordSide = recordSide;
records.push(record);
}
}
records.forEach((record, index) => {
record.index = index;
record.absoluteOffset = region.offset + record.offset;
});
return {
source: 'region00',
recordStartOffset: 4,
records,
};
}
function detectStructured12ByteStream(buffer) {
let bestCandidate = null;
for (let headerOffset = 0; headerOffset + 16 <= buffer.length; headerOffset += 4) {
const count = readU32LE(buffer, headerOffset);
const recordStartOffset = headerOffset + 4;
const maxPossibleCount = Math.floor((buffer.length - recordStartOffset) / 12);
if (count === 0 || count > maxPossibleCount) {
continue;
}
let prefixStructuredCount = 0;
for (let index = 0; index < count; index += 1) {
const recordOffset = recordStartOffset + index * 12;
const words = [];
for (let cursor = 0; cursor < 12; cursor += 2) {
words.push(readU16LE(buffer, recordOffset + cursor));
}
if (!isStructuredCandidate(words)) {
break;
}
prefixStructuredCount += 1;
}
if (prefixStructuredCount < 16) {
continue;
}
if (
!bestCandidate ||
prefixStructuredCount > bestCandidate.prefixStructuredCount ||
(prefixStructuredCount === bestCandidate.prefixStructuredCount && headerOffset < bestCandidate.headerOffset)
) {
bestCandidate = {
headerOffset,
recordStartOffset,
count,
prefixStructuredCount,
};
}
}
return bestCandidate;
}
export function parseRegion01Records(region) {
const records = [];
const stream = detectStructured12ByteStream(region.buffer);
if (stream) {
for (let index = 0; index < stream.count; index += 1) {
const recordOffset = stream.recordStartOffset + index * 12;
if (recordOffset + 12 > region.buffer.length) {
break;
}
const record = decodeRecord(region.buffer, recordOffset, 'region01');
if (!record || !isStructuredCandidate(record.words)) {
continue;
}
record.sourceFamily = 'section0_constructor_placements';
record.sourceRole = 'constructor-placement';
record.rowIndex = index;
record.recordSide = null;
records.push(record);
}
} else {
for (let rowOffset = 0; rowOffset + 24 <= region.buffer.length; rowOffset += 24) {
const left = decodeRecord(region.buffer, rowOffset, 'region01-left');
const right = decodeRecord(region.buffer, rowOffset + 12, 'region01-right');
if (left && isStructuredCandidate(left.words)) {
left.sourceFamily = 'section0_constructor_placements';
left.sourceRole = 'constructor-placement';
left.rowIndex = Math.floor(rowOffset / 24);
left.recordSide = 'left';
records.push(left);
}
if (right && isStructuredCandidate(right.words)) {
right.sourceFamily = 'section0_constructor_placements';
right.sourceRole = 'constructor-placement';
right.rowIndex = Math.floor(rowOffset / 24);
right.recordSide = 'right';
records.push(right);
}
}
}
records.forEach((record, index) => {
record.index = index;
record.absoluteOffset = region.offset + record.offset;
});
return {
source: 'region01',
recordStartOffset: stream?.recordStartOffset ?? 0,
streamHeaderOffset: stream?.headerOffset ?? null,
streamRecordCount: stream?.count ?? null,
streamStructuredPrefixCount: stream?.prefixStructuredCount ?? null,
records,
};
}

View file

@ -0,0 +1,91 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseLsetWdl } from './src/wdl.js';
const wdlPath = path.resolve('..', '..', 'Crusader-Map-Viewer', 'map_renderer', 'STATIC_PSX', 'LSET1', 'L0.WDL');
const buffer = fs.readFileSync(wdlPath);
const wdl = parseLsetWdl(buffer, wdlPath);
const section = wdl.sections.find((entry) => entry.name === 'post_audio_section_00');
const region = wdl.regions.find((entry) => entry.name === 'post_audio_region_00');
function readWords(sourceBuffer, offset, wordCount = 6) {
return Array.from({ length: wordCount }, (_, index) => sourceBuffer.readUInt16LE(offset + index * 2));
}
function isStructuredCandidate(words) {
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
if (typeWord >= 0x200) {
return false;
}
if (xWord === 0 && yWord === 0) {
return false;
}
if (xWord >= 0x4000 || yWord >= 0x4000) {
return false;
}
if (zWord > 0x20 || selectorWord > 0x04) {
return false;
}
return laneWord === 0x20 || laneWord === 0x22 || laneWord === 0x30;
}
function inspectCountPrefixed12ByteStreams(source) {
const hits = [];
for (let offset = 0; offset + 4 + 12 <= source.buffer.length; offset += 4) {
const count = source.buffer.readUInt32LE(offset);
if (count === 0 || count > 0x2000) {
continue;
}
let good = 0;
const preview = [];
for (let index = 0; index < count && offset + 4 + (index + 1) * 12 <= source.buffer.length; index += 1) {
const recordOffset = offset + 4 + index * 12;
const words = readWords(source.buffer, recordOffset);
const structured = isStructuredCandidate(words);
if (index < 6) {
preview.push({ index, recordOffset, words, structured });
}
if (!structured) {
break;
}
good += 1;
}
if (good >= 16) {
hits.push({
offset,
absoluteOffset: source.offset + offset,
count,
good,
preview,
});
}
}
return hits;
}
const sectionHits = inspectCountPrefixed12ByteStreams(section);
const regionHits = inspectCountPrefixed12ByteStreams(region);
const preview = [];
for (let offset = 0; offset < 0x90; offset += 12) {
const words = readWords(section.buffer, offset);
preview.push({
offset,
absoluteOffset: section.offset + offset,
words,
structured: isStructuredCandidate(words),
});
}
console.log(JSON.stringify({
sectionOffset: section.offset,
sectionSize: section.size,
regionOffset: region.offset,
regionSize: region.size,
sectionHits,
regionHits,
preview,
}, null, 2));