From 2f243976b6ffaa2038764bf7d0edeeb52f1c885c Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 13 Apr 2026 15:59:50 +0200 Subject: [PATCH 1/2] psx map standalone exporter --- docs/psx/art-binding-recovery.md | 258 +++- docs/psx/map-rendering.md | 6 +- docs/psx/map-storage-model.md | 14 + docs/psx/psx.md | 2 +- docs/psx/vram-dump-bundle-grounding.md | 22 + psx-map-exporter/.gitignore | 3 + .../docs/implementation-analysis.md | 248 ++++ psx-map-exporter/docs/spec.md | 256 ++++ psx-map-exporter/package-lock.json | 22 + psx-map-exporter/package.json | 11 + psx-map-exporter/src/bundles.js | 475 ++++++++ psx-map-exporter/src/cli.js | 160 +++ psx-map-exporter/src/export-map.js | 1073 +++++++++++++++++ psx-map-exporter/src/render.js | 169 +++ psx-map-exporter/src/wdl.js | 449 +++++++ psx-map-exporter/tmp_inspect_region00.mjs | 91 ++ 16 files changed, 3254 insertions(+), 5 deletions(-) create mode 100644 docs/psx/vram-dump-bundle-grounding.md create mode 100644 psx-map-exporter/.gitignore create mode 100644 psx-map-exporter/docs/implementation-analysis.md create mode 100644 psx-map-exporter/docs/spec.md create mode 100644 psx-map-exporter/package-lock.json create mode 100644 psx-map-exporter/package.json create mode 100644 psx-map-exporter/src/bundles.js create mode 100644 psx-map-exporter/src/cli.js create mode 100644 psx-map-exporter/src/export-map.js create mode 100644 psx-map-exporter/src/render.js create mode 100644 psx-map-exporter/src/wdl.js create mode 100644 psx-map-exporter/tmp_inspect_region00.mjs diff --git a/docs/psx/art-binding-recovery.md b/docs/psx/art-binding-recovery.md index 971a941..e4f2357 100644 --- a/docs/psx/art-binding-recovery.md +++ b/docs/psx/art-binding-recovery.md @@ -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 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 - 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. - `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 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. 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/psx/map-rendering.md b/docs/psx/map-rendering.md index bc1beec..89c9bcb 100644 --- a/docs/psx/map-rendering.md +++ b/docs/psx/map-rendering.md @@ -426,7 +426,7 @@ Functions and globals inspected in this pass: - `psx_lzss_unpack_into_level_buffer` (`0x8003b00c`, renamed this pass) - `psx_lzss_pack_level_buffer` (`0x8003aba8`, renamed this pass) - `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: - `psx_dispatch_section0_dispatch_roots` (`0x800256b0`) - `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_integrate_motion_and_route_visible` - `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_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 -- `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: - `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`) diff --git a/docs/psx/map-storage-model.md b/docs/psx/map-storage-model.md index f09cd9f..c4c35cf 100644 --- a/docs/psx/map-storage-model.md +++ b/docs/psx/map-storage-model.md @@ -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_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 Live MCP pass on active `SLUS_002.68` tightened section-0 record-family dispatch evidence for unresolved graphics-heavy types. diff --git a/docs/psx/psx.md b/docs/psx/psx.md index 868aaef..55fd9f0 100644 --- a/docs/psx/psx.md +++ b/docs/psx/psx.md @@ -739,7 +739,7 @@ Exporter status after the next renderer 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_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. diff --git a/docs/psx/vram-dump-bundle-grounding.md b/docs/psx/vram-dump-bundle-grounding.md new file mode 100644 index 0000000..976c8a4 --- /dev/null +++ b/docs/psx/vram-dump-bundle-grounding.md @@ -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. \ No newline at end of file diff --git a/psx-map-exporter/.gitignore b/psx-map-exporter/.gitignore new file mode 100644 index 0000000..7d70366 --- /dev/null +++ b/psx-map-exporter/.gitignore @@ -0,0 +1,3 @@ +.cache/** +.output/** +node_modules/** \ No newline at end of file diff --git a/psx-map-exporter/docs/implementation-analysis.md b/psx-map-exporter/docs/implementation-analysis.md new file mode 100644 index 0000000..4667dbe --- /dev/null +++ b/psx-map-exporter/docs/implementation-analysis.md @@ -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. diff --git a/psx-map-exporter/docs/spec.md b/psx-map-exporter/docs/spec.md new file mode 100644 index 0000000..bff71cd --- /dev/null +++ b/psx-map-exporter/docs/spec.md @@ -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//` + +Contents: + +- `wdl-summary.json` +- `records.json` +- `bundles.json` +- `frame-manifest.json` +- `active-header-overrides.json` +- `sprites//frame_.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/.png` +- `.output/.json` +- `.output/_.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/.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 ` +- `--wdl ` +- `--disc-root ` +- `--map-source ` +- `--out-name ` + +## 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 diff --git a/psx-map-exporter/package-lock.json b/psx-map-exporter/package-lock.json new file mode 100644 index 0000000..c1472c9 --- /dev/null +++ b/psx-map-exporter/package-lock.json @@ -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" + } + } + } +} diff --git a/psx-map-exporter/package.json b/psx-map-exporter/package.json new file mode 100644 index 0000000..45a54f8 --- /dev/null +++ b/psx-map-exporter/package.json @@ -0,0 +1,11 @@ +{ + "name": "psx-map-exporter", + "private": true, + "type": "module", + "scripts": { + "export": "node src/cli.js" + }, + "dependencies": { + "pngjs": "^7.0.0" + } +} diff --git a/psx-map-exporter/src/bundles.js b/psx-map-exporter/src/bundles.js new file mode 100644 index 0000000..fe47551 --- /dev/null +++ b/psx-map-exporter/src/bundles.js @@ -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); +} diff --git a/psx-map-exporter/src/cli.js b/psx-map-exporter/src/cli.js new file mode 100644 index 0000000..52e3e38 --- /dev/null +++ b/psx-map-exporter/src/cli.js @@ -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 ) [options]', + '', + 'Options:', + ' --source WDL path relative to the PSX disc root', + ' --wdl Direct WDL path', + ' --disc-root PSX asset root, defaults to STATIC_PSX in the sibling workspace', + ' --scene-scope Probe is supported; full is intentionally disabled until raw floor and full-map decode is recovered', + ' --gpu-ram-dump PSX GPU RAM dump used for live mode-1 palette extraction', + ' --validation-bundles Comma-separated bundle absolute offsets (hex or decimal) for bundle palette-sweep validation sheets', + ' --map-source ', + ' --out-name 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; +}); diff --git a/psx-map-exporter/src/export-map.js b/psx-map-exporter/src/export-map.js new file mode 100644 index 0000000..754408d --- /dev/null +++ b/psx-map-exporter/src/export-map.js @@ -0,0 +1,1073 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + buildMode1PaletteBank, + buildMode1RuntimePalette, + choosePalette, + extractMode1PaletteFromGpuRamDump, + extractPaletteSets, +} from './bundles.js'; +import { decodeBundleFrame, encodePng, scanSpriteBundles } from './bundles.js'; +import { renderMap } from './render.js'; +import { parseLsetWdl, parseRegion00Records, parseRegion01Records, summarizeRegion02 } from './wdl.js'; + +const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 }; + +function sanitizeStem(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); +} + +async function ensureDirectory(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); +} + +async function resetDirectory(dirPath) { + await fs.rm(dirPath, { recursive: true, force: true }); + await fs.mkdir(dirPath, { recursive: true }); +} + +async function loadGpuDumpMode1Palette(gpuRamDumpPath) { + if (!gpuRamDumpPath) { + return null; + } + + try { + const buffer = await fs.readFile(gpuRamDumpPath); + return extractMode1PaletteFromGpuRamDump(buffer, 0xf0, 0x00); + } catch { + return null; + } +} + +function parseBundleOffset(value) { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { + return value; + } + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (/^0x/i.test(trimmed)) { + const parsed = Number.parseInt(trimmed, 16); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; + } + + const parsed = Number.parseInt(trimmed, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; +} + +function chooseRecordSet(wdl, mapSource) { + const section00 = wdl.sections?.find((section) => section.name === 'post_audio_section_00') ?? null; + const region00 = wdl.regions.find((region) => region.name === 'post_audio_region_00'); + const region01 = wdl.regions.find((region) => region.name === 'post_audio_region_01'); + + const constructorSource = section00 + ? { + ...section00, + size: wdl.buffer.length - section00.offset, + buffer: wdl.buffer.subarray(section00.offset), + } + : region01; + + const region00Source = section00 ?? region00; + const region01Source = constructorSource; + + const region00Records = region00Source + ? parseRegion00Records(region00Source) + : { source: 'region00', recordStartOffset: 0, records: [] }; + const region01Records = region01Source + ? parseRegion01Records(region01Source) + : { source: 'region01', recordStartOffset: 0, records: [] }; + + if (mapSource === 'region00' || mapSource === 'roots') { + return region00Records; + } + if (mapSource === 'region01' || mapSource === 'constructors') { + return region01Records; + } + + if (mapSource === 'combined' || mapSource === 'layered' || mapSource === 'auto') { + const records = [ + ...region01Records.records.map((record) => ({ ...record, authoredLayer: 'constructors' })), + ...region00Records.records.map((record) => ({ ...record, authoredLayer: 'roots' })), + ]; + + return { + source: 'combined', + recordStartOffset: 0, + records, + layers: { + constructors: region01Records.records.length, + roots: region00Records.records.length, + }, + }; + } + + return region01Records.records.length >= 8 ? region01Records : region00Records; +} + +function chooseBundleForType(bundles, typeWord) { + if (typeWord >= 0 && typeWord < bundles.length) { + return bundles[typeWord]; + } + return null; +} + +function describeMapScope(recordSet) { + if (recordSet.source === 'combined') { + return 'layered object-projection probe from both loader-sized section-0 constructor-placement and root-dispatch records in post_audio_section_00'; + } + if (recordSet.source === 'region01') { + return 'object-projection probe from the loader-sized section-0 constructor-placement records in post_audio_section_00'; + } + if (recordSet.source === 'region00') { + return 'object-projection probe from the loader-sized section-0 root-dispatch records in post_audio_section_00'; + } + return 'object-projection probe from unresolved authored record source'; +} + +function buildSceneInterpretation(recordSet, bindingDiversity) { + const interpretation = { + kind: 'unresolved-authored-probe', + confidence: 'medium', + warning: 'Current output is a standalone authored-record probe, not a loader-faithful visible-world reconstruction.', + evidence: [], + }; + + if (recordSet.source === 'combined') { + interpretation.kind = 'layered-authored-world-probe'; + interpretation.warning = 'Current output combines section-0 constructor placements with the smaller root-dispatch lane, but it is still not a full visible-world reconstruction.'; + interpretation.evidence.push( + 'The export now draws both authored section-0 lanes that are statically recoverable offline: constructor placements and root-dispatch rows.', + 'This adds a second renderable authored layer without claiming runtime-complete state, control, or dynamic effect parity.', + ); + } else if (recordSet.source === 'region01') { + interpretation.kind = 'constructor-live-object-seed-lane'; + interpretation.warning = 'Constructor-placement exports should currently be read as a constructor-fed live-object seed lane, not as the complete visible map or static architecture layer.'; + interpretation.evidence.push( + 'Source records come from loader-sized section-0 constructor placements.', + 'Executable constructors only install type-indexed art/state setup at spawn; final visible resource selection continues through downstream state-script and variant logic.', + ); + } else if (recordSet.source === 'region00') { + interpretation.kind = 'root-dispatch-probe-lane'; + interpretation.warning = 'Root-dispatch exports are a smaller authored-record probe and are not the whole map object set.'; + interpretation.evidence.push( + 'Source records come from loader-sized section-0 root dispatch rows.', + ); + } + + if ( + bindingDiversity.distinctBundleCount > 0 && + bindingDiversity.distinctTypeCount > bindingDiversity.distinctBundleCount + ) { + interpretation.evidence.push( + `Rendered type diversity (${bindingDiversity.distinctTypeCount}) exceeds bound bundle diversity (${bindingDiversity.distinctBundleCount}), which is consistent with unresolved runtime binding rather than final map-facing art.` + ); + } + + return interpretation; +} + +function summarizeAuthoredLayers(records) { + const counts = new Map(); + + for (const record of records) { + const layerKey = record.authoredLayer ?? record.sourceRole ?? record.sourceFamily ?? record.source; + counts.set(layerKey, (counts.get(layerKey) ?? 0) + 1); + } + + return [...counts.entries()] + .map(([layer, recordCount]) => ({ layer, recordCount })) + .sort((left, right) => right.recordCount - left.recordCount || left.layer.localeCompare(right.layer)); +} + +function summarizeRenderedLayers(items) { + const counts = new Map(); + + for (const item of items) { + const layerKey = item.authoredLayer ?? 'unknown'; + counts.set(layerKey, (counts.get(layerKey) ?? 0) + 1); + } + + return [...counts.entries()] + .map(([layer, renderableItemCount]) => ({ layer, renderableItemCount })) + .sort((left, right) => right.renderableItemCount - left.renderableItemCount || left.layer.localeCompare(right.layer)); +} + +function derivePaletteDiagnostics(record, bundle) { + const rawWords = Array.isArray(record.rawWords) ? record.rawWords : []; + const token06HighByte = rawWords.length >= 4 ? ((rawWords[3] >>> 8) & 0xff) : null; + const token0cHighByte = rawWords.length >= 7 ? ((rawWords[6] >>> 8) & 0xff) : null; + + let expectedAssignmentPath = 'bundle-default'; + let expectedPaletteToken = null; + + if (record.typeWord >= 0x003e && record.typeWord <= 0x00ab) { + expectedAssignmentPath = 'main-visible-source-plus-0x06-high-byte-when-nonzero'; + expectedPaletteToken = token06HighByte; + } else if (record.typeWord >= 0x00ac) { + expectedAssignmentPath = 'main-visible-source-plus-0x0c-high-byte-when-nonzero'; + expectedPaletteToken = token0cHighByte; + } + + if ((record.typeWord === 4) || ((record.laneWord & 0x0400) !== 0)) { + expectedAssignmentPath = 'special-visible-default-bank-clut-no-authored-token'; + expectedPaletteToken = null; + } + + if (bundle?.mode === 1 && expectedPaletteToken === 0) { + expectedAssignmentPath = 'default-bank-clut-or-bank-clut-proxy'; + } + + return { + rawWords, + token06HighByte, + token0cHighByte, + expectedPaletteToken, + expectedAssignmentPath, + }; +} + +function makeRecordLikeFromMapSourceItem(item) { + return { + rawWords: item?.rawWords ?? [], + typeWord: item?.typeId ?? item?.quality ?? 0, + laneWord: item?.lane ?? item?.mapNum ?? 0, + }; +} + +function summarizeBindingDiversity(items) { + const bundleCounts = new Map(); + const distinctTypes = new Set(); + const distinctBundleFrames = new Set(); + + for (const item of items) { + distinctTypes.add(item.typeWord); + bundleCounts.set(item.bundleAbsoluteOffset, (bundleCounts.get(item.bundleAbsoluteOffset) ?? 0) + 1); + distinctBundleFrames.add(`${item.bundleAbsoluteOffset}:${item.frameIndex}`); + } + + const topBundleRepeats = [...bundleCounts.entries()] + .map(([bundleAbsoluteOffset, count]) => ({ bundleAbsoluteOffset, count })) + .sort((left, right) => right.count - left.count) + .slice(0, 10); + + return { + distinctTypeCount: distinctTypes.size, + distinctBundleCount: bundleCounts.size, + distinctBundleFrameCount: distinctBundleFrames.size, + topBundleRepeats, + }; +} + +function buildResourceKey(bundle, sprite) { + const paletteIndex = Number.isInteger(bundle.resolvedPaletteIndex) ? bundle.resolvedPaletteIndex : 'na'; + return `${bundle.absoluteOffset.toString(16).padStart(8, '0')}:${sprite.clampedFrameIndex}:${paletteIndex}`; +} + +function assignResourceLabelIds(items) { + const resourceKeys = [...new Set(items.map((item) => item.resourceKey))].sort((left, right) => left.localeCompare(right)); + const resourceLabelIds = new Map(resourceKeys.map((resourceKey, index) => [resourceKey, index])); + + return items.map((item) => ({ + ...item, + labelId: resourceLabelIds.get(item.resourceKey), + })); +} + +function parseActiveHeaderOverrideCandidate(buffer, startOffset) { + if (startOffset + 8 > buffer.length) { + return null; + } + + const count = buffer.readUInt32LE(startOffset + 0x00); + const directoryOffset = buffer.readUInt32LE(startOffset + 0x04); + if (count === 0 || count > 0x200) { + return null; + } + if (directoryOffset < 8 || directoryOffset + (count * 8) > buffer.length - startOffset) { + return null; + } + + let payloadCursor = startOffset + 0x08; + const directoryBase = startOffset + directoryOffset; + let nonZeroCount = 0; + let clearCount = 0; + let size58Count = 0; + let payloadBytes = 0; + const rows = []; + + for (let index = 0; index < count; index += 1) { + const rowOffset = directoryBase + (index * 8); + const activeHeaderSize = buffer.readUInt32LE(rowOffset + 0x00); + const typeId = buffer.readUInt32LE(rowOffset + 0x04); + if (typeId > 0x1ff || activeHeaderSize > 0x1000) { + return null; + } + if (activeHeaderSize === 0) { + clearCount += 1; + rows.push({ index, typeId, activeHeaderSize, payloadOffset: null }); + continue; + } + if (payloadCursor + activeHeaderSize > directoryBase) { + return null; + } + + nonZeroCount += 1; + payloadBytes += activeHeaderSize; + if (activeHeaderSize === 0x58) { + size58Count += 1; + } + rows.push({ + index, + typeId, + activeHeaderSize, + payloadOffset: payloadCursor - startOffset, + }); + payloadCursor += activeHeaderSize; + } + + if (nonZeroCount < 4 || size58Count < 2) { + return null; + } + + return { + startOffset, + count, + directoryOffset, + payloadBytes, + nonZeroCount, + clearCount, + size58Count, + firstNonZeroTypeIds: rows.filter((row) => row.activeHeaderSize !== 0).slice(0, 16).map((row) => row.typeId), + rows, + }; +} + +function scanActiveHeaderOverrideCandidates(wdl) { + const candidates = []; + const seenAbsoluteOffsets = new Set(); + + const sources = [ + ...(wdl.sections ?? []).map((section) => ({ + kind: 'section', + name: section.name, + offset: section.offset, + buffer: section.buffer, + })), + ...(wdl.regions ?? []) + .filter((region) => region.name !== 'audio_or_spu_blob') + .map((region) => ({ + kind: 'region', + name: region.name, + offset: region.offset, + buffer: region.buffer, + })), + ]; + + for (const source of sources) { + if (!source?.buffer || source.buffer.length < 0x80) { + continue; + } + + for (let startOffset = 0; startOffset + 8 <= source.buffer.length; startOffset += 4) { + const candidate = parseActiveHeaderOverrideCandidate(source.buffer, startOffset); + if (!candidate) { + continue; + } + + const absoluteOffset = source.offset + startOffset; + if (seenAbsoluteOffsets.has(absoluteOffset)) { + continue; + } + seenAbsoluteOffsets.add(absoluteOffset); + + candidates.push({ + sourceKind: source.kind, + sectionName: source.name, + sectionOffset: source.offset, + absoluteOffset, + ...candidate, + }); + } + } + + candidates.sort((left, right) => { + if (left.size58Count !== right.size58Count) { + return right.size58Count - left.size58Count; + } + if (left.nonZeroCount !== right.nonZeroCount) { + return right.nonZeroCount - left.nonZeroCount; + } + return left.absoluteOffset - right.absoluteOffset; + }); + + return candidates; +} + +function buildMode2PaletteSweepCandidates(bundle, paletteSets) { + const candidates = []; + const seen = new Set(); + const priorityIndexes = [bundle.defaultPaletteIndex, bundle.resolvedPaletteIndex, bundle.paletteIndex, 0]; + + for (const paletteIndex of priorityIndexes) { + if (!Number.isInteger(paletteIndex) || paletteIndex < 0 || paletteIndex >= paletteSets.palettes16.length || seen.has(paletteIndex)) { + continue; + } + seen.add(paletteIndex); + candidates.push({ + label: `pal${paletteIndex}`, + paletteIndex, + palette: paletteSets.palettes16[paletteIndex], + paletteFormula: paletteIndex === bundle.defaultPaletteIndex ? 'bundle-default' : 'palette-sweep', + }); + } + + for (let paletteIndex = 0; paletteIndex < Math.min(32, paletteSets.palettes16.length); paletteIndex += 1) { + if (seen.has(paletteIndex)) { + continue; + } + seen.add(paletteIndex); + candidates.push({ + label: `pal${paletteIndex}`, + paletteIndex, + palette: paletteSets.palettes16[paletteIndex], + paletteFormula: 'palette-sweep', + }); + } + + return candidates; +} + +function buildMode1PaletteSweepCandidates(bundle, paletteSets, options = {}) { + const candidates = []; + const seen = new Set(); + const mode1PaletteBank = options.mode1PaletteBank ?? buildMode1PaletteBank(paletteSets.palettes16); + + if (options.mode1RuntimePalette?.length === 256) { + seen.add('gpu-row-f0'); + candidates.push({ + label: 'gpu-row-f0', + paletteIndex: 0, + palette: options.mode1RuntimePalette, + paletteFormula: 'mode1-live-gpu-ram-row-f0-x0', + }); + } + + const priorityIndexes = [bundle.defaultPaletteIndex, bundle.resolvedPaletteIndex, bundle.paletteIndex, 0]; + for (const paletteIndex of priorityIndexes) { + if (!Number.isInteger(paletteIndex) || seen.has(`bank-${paletteIndex}`)) { + continue; + } + const palette = mode1PaletteBank[paletteIndex] ?? null; + if (!palette) { + continue; + } + seen.add(`bank-${paletteIndex}`); + candidates.push({ + label: `bank${paletteIndex}`, + paletteIndex, + palette, + paletteFormula: paletteIndex === bundle.defaultPaletteIndex ? 'mode1-runtime-clut-bank-default' : 'mode1-runtime-clut-bank-sweep', + }); + } + + for (let paletteIndex = 0; paletteIndex < Math.min(8, mode1PaletteBank.length); paletteIndex += 1) { + if (seen.has(`bank-${paletteIndex}`) || !mode1PaletteBank[paletteIndex]) { + continue; + } + seen.add(`bank-${paletteIndex}`); + candidates.push({ + label: `bank${paletteIndex}`, + paletteIndex, + palette: mode1PaletteBank[paletteIndex], + paletteFormula: 'mode1-runtime-clut-bank-sweep', + }); + } + + return candidates; +} + +function buildBundleValidationCells(region04, bundle, paletteSets, options = {}) { + const candidates = bundle.mode === 2 + ? buildMode2PaletteSweepCandidates(bundle, paletteSets) + : buildMode1PaletteSweepCandidates(bundle, paletteSets, options); + const frameIndexes = bundle.frames.slice(0, Math.min(bundle.frames.length, 4)).map((frame) => frame.index); + const items = []; + let maxWidth = 0; + let maxHeight = 0; + + for (const candidate of candidates) { + for (const frameIndex of frameIndexes) { + const previewBundle = { + ...bundle, + resolvedPaletteIndex: candidate.paletteIndex, + paletteFormula: candidate.paletteFormula, + palette: candidate.palette, + }; + const sprite = decodeBundleFrame(region04, previewBundle, frameIndex, candidate.palette ?? null); + if (!sprite) { + continue; + } + maxWidth = Math.max(maxWidth, sprite.width); + maxHeight = Math.max(maxHeight, sprite.height); + items.push({ candidate, frameIndex, sprite }); + } + } + + const columns = Math.max(1, frameIndexes.length); + const gutter = 24; + const renderItems = items.map((entry, index) => { + const column = index % columns; + const row = Math.floor(index / columns); + return { + id: String(index), + sheetIndex: index, + recordIndex: entry.frameIndex, + typeWord: bundle.slot, + laneWord: entry.candidate.paletteIndex ?? 0, + requestedFrameIndex: entry.frameIndex, + frameIndex: entry.frameIndex, + defaultPaletteIndex: bundle.defaultPaletteIndex ?? bundle.paletteIndex ?? null, + resolvedPaletteIndex: entry.candidate.paletteIndex, + paletteFormula: entry.candidate.paletteFormula, + mappingSource: 'bundle-validation-sweep', + donorTypeId: null, + templateTypeId: null, + bundleAbsoluteOffset: bundle.absoluteOffset, + width: entry.sprite.width, + height: entry.sprite.height, + originX: 0, + originY: 0, + drawX: column * (maxWidth + gutter), + drawY: row * (maxHeight + gutter), + flipped: false, + sprite: entry.sprite, + }; + }); + + const metadata = items.map((entry, index) => ({ + sheetIndex: index, + frameIndex: entry.frameIndex, + paletteIndex: entry.candidate.paletteIndex, + label: entry.candidate.label, + paletteFormula: entry.candidate.paletteFormula, + width: entry.sprite.width, + height: entry.sprite.height, + })); + + return { renderItems, metadata }; +} + +async function writeValidationOutputs(outputRoot, mapStem, region04, bundles, paletteSets, options = {}, bundleOffsets = []) { + const outputs = []; + const bundlesByOffset = new Map(bundles.map((bundle) => [bundle.absoluteOffset, bundle])); + + for (const bundleOffset of bundleOffsets) { + const bundle = bundlesByOffset.get(bundleOffset); + if (!bundle) { + continue; + } + + const { renderItems, metadata } = buildBundleValidationCells(region04, bundle, paletteSets, options); + if (renderItems.length === 0) { + continue; + } + + const render = renderMap(renderItems, { + drawLabels: true, + background: { red: 18, green: 18, blue: 18, alpha: 255 }, + }); + const stem = `${mapStem}_bundle_${bundleOffset.toString(16).padStart(8, '0')}_sheet`; + const pngPath = path.join(outputRoot, `${stem}.png`); + const jsonPath = path.join(outputRoot, `${stem}.json`); + await fs.writeFile(pngPath, render.png); + await fs.writeFile(jsonPath, JSON.stringify({ + bundleOffset, + mode: bundle.mode, + frameCount: bundle.frames.length, + candidateCount: metadata.length, + items: metadata, + }, null, 2)); + outputs.push({ bundleOffset, pngPath, jsonPath }); + } + + return outputs; +} + +function colorizeU16Value(value) { + return { + red: value & 0x1f, + green: (value >> 5) & 0x1f, + blue: (value >> 10) & 0x1f, + }; +} + +function buildRegion02ExamplePng(region, options = {}) { + const columns = options.columns ?? 128; + const cellSize = options.cellSize ?? 4; + const sampleWordCount = Math.min(options.sampleWordCount ?? 2048, Math.floor(region.buffer.length / 2)); + const rows = Math.max(1, Math.ceil(sampleWordCount / columns)); + const width = columns * cellSize; + const height = rows * cellSize; + const rgba = Buffer.alloc(width * height * 4, 0); + + for (let index = 0; index < sampleWordCount; index += 1) { + const value = region.buffer.readUInt16LE(index * 2); + const color = colorizeU16Value(value); + const column = index % columns; + const row = Math.floor(index / columns); + const baseX = column * cellSize; + const baseY = row * cellSize; + const red = Math.round((color.red / 31) * 255); + const green = Math.round((color.green / 31) * 255); + const blue = Math.round((color.blue / 31) * 255); + + for (let y = 0; y < cellSize; y += 1) { + for (let x = 0; x < cellSize; x += 1) { + const pixel = ((baseY + y) * width + (baseX + x)) * 4; + rgba[pixel + 0] = red; + rgba[pixel + 1] = green; + rgba[pixel + 2] = blue; + rgba[pixel + 3] = 255; + } + } + } + + return { + width, + height, + sampleWordCount, + png: encodePng(rgba, width, height), + }; +} + +async function writeRegion02Example(outputRoot, cacheRoot, mapStem, region, summary) { + if (!region || !summary) { + return null; + } + + const example = buildRegion02ExamplePng(region); + const pngPath = path.join(outputRoot, `${mapStem}_region02_example.png`); + const jsonPath = path.join(outputRoot, `${mapStem}_region02_example.json`); + const cacheJsonPath = path.join(cacheRoot, 'region02-analysis.json'); + + await fs.writeFile(pngPath, example.png); + await fs.writeFile(jsonPath, JSON.stringify({ + ...summary, + exampleWidth: example.width, + exampleHeight: example.height, + sampleWordCount: example.sampleWordCount, + description: 'False-color grid of the first region-02 u16 words plus structured preview rows in the JSON. This is a raw structure preview, not a decoded floor/map layer.', + }, null, 2)); + + return { pngPath, jsonPath, cacheJsonPath }; +} + +function resolveBundlePalettes(bundles, paletteSets, options = {}) { + const mode1PaletteBank = buildMode1PaletteBank(paletteSets.palettes16); + const mode1RuntimePalette = options.mode1RuntimePalette + ?? buildMode1RuntimePalette(paletteSets.palettes16) + ?? paletteSets.palettes256[0] + ?? null; + + return bundles.map((bundle) => { + const defaultPaletteIndex = bundle.paletteIndex; + let resolvedPaletteIndex = bundle.paletteIndex; + let palette = null; + let paletteFormula = null; + + if (bundle.mode === 2) { + if (!Number.isInteger(resolvedPaletteIndex) || resolvedPaletteIndex < 0 || resolvedPaletteIndex >= paletteSets.palettes16.length) { + resolvedPaletteIndex = choosePalette(paletteSets.palettes16, bundle.frames, bundle.mode); + } + if (Number.isInteger(resolvedPaletteIndex) && resolvedPaletteIndex >= 0 && resolvedPaletteIndex < paletteSets.palettes16.length) { + palette = paletteSets.palettes16[resolvedPaletteIndex]; + paletteFormula = 'mode2-bundle-or-usage-index'; + } + } else if (bundle.mode === 1) { + if (options.mode1RuntimePalette?.length === 256) { + resolvedPaletteIndex = 0; + palette = options.mode1RuntimePalette; + } else if (mode1PaletteBank[0]?.length) { + resolvedPaletteIndex = 0; + palette = mode1PaletteBank[0]; + } else { + resolvedPaletteIndex = null; + palette = mode1RuntimePalette; + } + + if (palette) { + paletteFormula = options.mode1RuntimePalette?.length === 256 + ? 'mode1-live-gpu-ram-row-f0-x0' + : 'mode1-runtime-clut-band-start-index-0'; + } + } + + return { + ...bundle, + defaultPaletteIndex, + resolvedPaletteIndex, + paletteFormula, + palette, + }; + }); +} + +async function buildSceneItems(region04, records, bundles, options = {}) { + const items = []; + const spriteCache = new Map(); + const skippedRecords = []; + + const nonMapFacingRootTypes = new Set([0x42, 0x49]); + const nonMapFacingBundleOffsets = new Set([0x000d84f4]); + for (const record of records) { + if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) { + skippedRecords.push({ + recordIndex: record.index, + typeWord: record.typeWord, + sourceFamily: record.sourceFamily, + reason: 'non-map-facing portrait/talk root type', + }); + continue; + } + + const rawTypeBundle = chooseBundleForType(bundles, record.typeWord); + const bundle = rawTypeBundle; + if (!bundle) { + continue; + } + if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) { + skippedRecords.push({ + recordIndex: record.index, + typeWord: record.typeWord, + sourceFamily: record.sourceFamily, + bundleAbsoluteOffset: bundle.absoluteOffset, + reason: 'known non-map-facing portrait/talk bundle', + }); + continue; + } + + const requestedFrameIndex = record.selectorWord; + const clampedFrameIndex = Math.max(0, Math.min(requestedFrameIndex, bundle.frames.length - 1)); + const spriteKey = `${bundle.absoluteOffset}:${clampedFrameIndex}`; + let sprite = spriteCache.get(spriteKey); + if (!sprite) { + sprite = decodeBundleFrame(region04, bundle, requestedFrameIndex, bundle.palette ?? null); + if (!sprite) { + continue; + } + spriteCache.set(spriteKey, sprite); + } + + const resourceKey = buildResourceKey(bundle, sprite); + items.push({ + id: record.index, + instanceId: record.index, + recordIndex: record.index, + recordSource: record.source, + sourceFamily: record.sourceFamily, + authoredLayer: record.authoredLayer ?? record.sourceRole ?? null, + recordSide: record.recordSide, + rowIndex: record.rowIndex, + typeWord: record.typeWord, + laneWord: record.laneWord, + screenX: record.screenX, + screenY: record.screenY, + bundleSlot: bundle.slot, + bundleAbsoluteOffset: bundle.absoluteOffset, + bundleSource: bundle.bundleSource ?? 'raw-scan', + requestedFrameIndex, + frameIndex: sprite.clampedFrameIndex, + defaultPaletteIndex: bundle.defaultPaletteIndex ?? null, + resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null, + paletteFormula: bundle.paletteFormula ?? null, + mappingSource: 'raw-typeword-bundle-slot-diagnostic', + templateTypeId: null, + donorTypeId: null, + rawWords: record.rawWords ?? record.words, + flipped: (record.laneWord & 0x0002) !== 0, + width: sprite.width, + height: sprite.height, + originX: sprite.originX, + originY: sprite.originY, + drawX: record.screenX - sprite.originX, + drawY: record.screenY - sprite.originY, + stage: record.typeWord === 4 || (record.laneWord & 0x0400) !== 0 ? 1 : 0, + resourceKey, + paletteDiagnostics: derivePaletteDiagnostics(record, bundle), + sprite, + }); + } + + items.sort((left, right) => { + if (left.stage !== right.stage) { + return left.stage - right.stage; + } + if (left.screenY !== right.screenY) { + return left.screenY - right.screenY; + } + return left.screenX - right.screenX; + }); + + return { + items: assignResourceLabelIds(items), + skippedRecords, + }; +} + +async function writeBundleCache(spriteRoot, region04, bundles) { + const manifest = []; + + for (const bundle of bundles) { + const bundleDir = path.join(spriteRoot, `bundle_${bundle.absoluteOffset.toString(16).padStart(8, '0')}`); + await ensureDirectory(bundleDir); + + const frames = []; + for (const frame of bundle.frames) { + const decoded = decodeBundleFrame(region04, bundle, frame.index, bundle.palette ?? null); + const fileName = `frame_${String(frame.index).padStart(3, '0')}.png`; + await fs.writeFile(path.join(bundleDir, fileName), encodePng(decoded.rgba, decoded.width, decoded.height)); + frames.push({ + index: frame.index, + width: decoded.width, + height: decoded.height, + originX: decoded.originX, + originY: decoded.originY, + fileName, + }); + } + + manifest.push({ + slot: bundle.slot, + absoluteOffset: bundle.absoluteOffset, + offsetInRegion: bundle.offsetInRegion, + kind: bundle.kind, + mode: bundle.mode, + paletteIndex: bundle.paletteIndex, + defaultPaletteIndex: bundle.defaultPaletteIndex ?? null, + resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null, + paletteFormula: bundle.paletteFormula ?? null, + bundleSource: bundle.bundleSource, + frameCount: bundle.frameCount, + frameTableOffset: bundle.frameTableOffset, + dataOffset: bundle.dataOffset, + frames, + }); + } + + return manifest; +} + +export async function exportMap(options) { + const buffer = await fs.readFile(options.wdlPath); + const wdl = parseLsetWdl(buffer, options.wdlPath); + const mapStem = sanitizeStem(options.outName ?? path.parse(options.wdlPath).name); + const cacheBaseRoot = path.join(options.projectRoot, '.cache'); + const cacheRoot = path.join(options.projectRoot, '.cache', mapStem); + const spriteRoot = path.join(cacheRoot, 'sprites'); + const outputRoot = path.join(options.projectRoot, '.output'); + + await ensureDirectory(cacheBaseRoot); + await resetDirectory(cacheRoot); + await ensureDirectory(cacheRoot); + await ensureDirectory(spriteRoot); + await resetDirectory(outputRoot); + + const recordSet = chooseRecordSet(wdl, options.mapSource); + const region04 = wdl.regions.find((region) => region.name === 'post_audio_region_04'); + const region02 = wdl.regions.find((region) => region.name === 'post_audio_region_02'); + if (!region04) { + throw new Error('The WDL did not expose post_audio_region_04.'); + } + const sceneScope = options.sceneScope === 'full' ? 'full' : 'probe'; + if (sceneScope === 'full') { + throw new Error('Full-scene export is disabled. Current research does not support a trustworthy raw floor or full-map reconstruction yet.'); + } + const paletteSets = extractPaletteSets(wdl.buffer, wdl.headerWords); + const region02Summary = region02 ? summarizeRegion02(region02) : null; + const activeHeaderOverrideCandidates = scanActiveHeaderOverrideCandidates(wdl); + + let bundles = scanSpriteBundles(region04).map((bundle) => ({ ...bundle, bundleSource: 'raw-scan' })); + const mode1RuntimePalette = await loadGpuDumpMode1Palette(options.gpuRamDumpPath); + const mode1PaletteBank = buildMode1PaletteBank(paletteSets.palettes16); + bundles = resolveBundlePalettes( + bundles, + paletteSets, + { mode1RuntimePalette } + ); + + const { items: sceneItems, skippedRecords } = await buildSceneItems(region04, recordSet.records, bundles, { + paletteSets, + mode1RuntimePalette, + mode1PaletteBank, + }); + const bindingDiversity = summarizeBindingDiversity(sceneItems); + const authoredLayerSummary = summarizeAuthoredLayers(recordSet.records); + const renderedLayerSummary = summarizeRenderedLayers(sceneItems); + const sceneInterpretation = buildSceneInterpretation(recordSet, bindingDiversity); + const renderOptions = { background: DEFAULT_BACKGROUND }; + const render = renderMap(sceneItems, renderOptions); + const debugRender = options.debugLabels ? renderMap(sceneItems, { ...renderOptions, drawLabels: true }) : null; + const layerRenders = renderedLayerSummary.map(({ layer }) => { + const layerItems = sceneItems.filter((item) => item.authoredLayer === layer); + return { + layer, + itemCount: layerItems.length, + render: renderMap(layerItems, renderOptions), + debugRender: options.debugLabels ? renderMap(layerItems, { ...renderOptions, drawLabels: true }) : null, + }; + }); + const bundleManifest = await writeBundleCache(spriteRoot, region04, bundles); + const validationBundleOffsets = (options.validationBundles ?? []) + .map((value) => parseBundleOffset(value)) + .filter((value) => Number.isInteger(value)); + const validationOutputs = await writeValidationOutputs(outputRoot, mapStem, region04, bundles, paletteSets, { + mode1RuntimePalette, + mode1PaletteBank, + }, validationBundleOffsets); + + const summary = { + sourceFile: options.wdlPath, + mapSource: recordSet.source, + mapScope: describeMapScope(recordSet), + recordStartOffset: recordSet.recordStartOffset, + streamHeaderOffset: recordSet.streamHeaderOffset ?? null, + streamRecordCount: recordSet.streamRecordCount ?? null, + streamStructuredPrefixCount: recordSet.streamStructuredPrefixCount ?? null, + recordCount: recordSet.records.length, + renderableItemCount: sceneItems.length, + skippedProbeRecordCount: skippedRecords.length, + authoredLayerSummary, + renderedLayerSummary, + bundleCount: bundles.length, + bundleSource: bundles[0]?.bundleSource ?? 'none', + gpuRamDumpPath: options.gpuRamDumpPath ?? null, + artBindingSource: 'raw-typeword-bundle-slot-diagnostic', + activeHeaderOverrideCandidateCount: activeHeaderOverrideCandidates.length, + bestActiveHeaderOverrideCandidate: activeHeaderOverrideCandidates[0] + ? { + sectionName: activeHeaderOverrideCandidates[0].sectionName, + absoluteOffset: activeHeaderOverrideCandidates[0].absoluteOffset, + count: activeHeaderOverrideCandidates[0].count, + directoryOffset: activeHeaderOverrideCandidates[0].directoryOffset, + nonZeroCount: activeHeaderOverrideCandidates[0].nonZeroCount, + clearCount: activeHeaderOverrideCandidates[0].clearCount, + size58Count: activeHeaderOverrideCandidates[0].size58Count, + } + : null, + bindingDiversity, + sceneInterpretation, + region02Note: region02Summary?.note ?? null, + limitations: [ + ...(sceneScope === 'full' + ? ['Full-scene export is intentionally disabled until raw floor and subordinate-state decode is recovered from current research.'] + : [ + 'Exports only standalone object-placement probes from the loader-sized section-0 root or constructor-placement families.', + 'Auto mode now emits a layered probe that combines section-0 constructor placements with the smaller root-dispatch lane when both are available.', + 'Constructor-placement output should currently be interpreted as a constructor-fed live-object seed lane rather than the final visible map.', + 'The root-dispatch lane is now rendered as a second authored layer, but runtime-driven control mutations and dynamic effect spawns are still out of scope.', + 'Known non-map-facing portrait/talk root types `0x0042` and `0x0049`, plus the known portrait bundle `0x000D84F4`, are excluded from probe rendering.', + 'Viewer-derived sidecars, donor mappings, and cached scene references are intentionally disabled in this standalone exporter.', + 'Current bundle selection is still diagnostic-only until the late DAT_800758d8 art bank is parsed directly.', + 'No floor or tile layer is decoded directly yet; post_audio_region_02 and the decompressed level-state lane remain unresolved.', + ]), + 'Palette routing remains partly heuristic when authored token and default bank evidence are both absent.', + ], + debugLabels: Boolean(options.debugLabels), + headerSize: wdl.headerSize, + audioSize: wdl.audioSize, + boundaryCandidates: wdl.boundaryCandidates, + regions: wdl.regions.map((region) => ({ + name: region.name, + offset: region.offset, + size: region.size, + })), + }; + + const sceneManifest = { + ...summary, + renderWidth: render.width, + renderHeight: render.height, + bounds: render.bounds, + skippedRecords, + items: sceneItems.map((item) => ({ + recordIndex: item.recordIndex, + recordSource: item.recordSource, + sourceFamily: item.sourceFamily, + authoredLayer: item.authoredLayer, + recordSide: item.recordSide, + rowIndex: item.rowIndex, + rawWords: item.rawWords, + typeWord: item.typeWord, + laneWord: item.laneWord, + screenX: item.screenX, + screenY: item.screenY, + bundleSlot: item.bundleSlot, + bundleAbsoluteOffset: item.bundleAbsoluteOffset, + bundleSource: item.bundleSource, + requestedFrameIndex: item.requestedFrameIndex, + frameIndex: item.frameIndex, + instanceId: item.instanceId, + resourceKey: item.resourceKey, + resourceLabelId: item.labelId, + defaultPaletteIndex: item.defaultPaletteIndex, + resolvedPaletteIndex: item.resolvedPaletteIndex, + paletteFormula: item.paletteFormula, + mappingSource: item.mappingSource, + templateTypeId: item.templateTypeId, + donorTypeId: item.donorTypeId, + token06HighByte: item.paletteDiagnostics?.token06HighByte ?? null, + token0cHighByte: item.paletteDiagnostics?.token0cHighByte ?? null, + expectedPaletteToken: item.paletteDiagnostics?.expectedPaletteToken ?? null, + expectedAssignmentPath: item.paletteDiagnostics?.expectedAssignmentPath ?? null, + flipped: item.flipped, + drawX: item.drawX, + drawY: item.drawY, + width: item.width, + height: item.height, + originX: item.originX, + originY: item.originY, + stage: item.stage, + })), + }; + + await fs.writeFile(path.join(cacheRoot, 'wdl-summary.json'), JSON.stringify(summary, null, 2)); + await fs.writeFile(path.join(cacheRoot, 'records.json'), JSON.stringify(recordSet, null, 2)); + await fs.writeFile(path.join(cacheRoot, 'bundles.json'), JSON.stringify(bundleManifest, null, 2)); + await fs.writeFile(path.join(cacheRoot, 'frame-manifest.json'), JSON.stringify(bundleManifest, null, 2)); + await fs.writeFile(path.join(cacheRoot, 'active-header-overrides.json'), JSON.stringify(activeHeaderOverrideCandidates, null, 2)); + if (region02Summary) { + await fs.writeFile(path.join(cacheRoot, 'region02-analysis.json'), JSON.stringify(region02Summary, null, 2)); + } + const region02Example = await writeRegion02Example(outputRoot, cacheRoot, mapStem, region02, region02Summary); + await fs.writeFile(path.join(outputRoot, `${mapStem}.png`), render.png); + if (debugRender) { + await fs.writeFile(path.join(outputRoot, `${mapStem}_labels.png`), debugRender.png); + } + for (const layerRender of layerRenders) { + await fs.writeFile(path.join(outputRoot, `${mapStem}_${layerRender.layer}.png`), layerRender.render.png); + if (layerRender.debugRender) { + await fs.writeFile(path.join(outputRoot, `${mapStem}_${layerRender.layer}_labels.png`), layerRender.debugRender.png); + } + } + await fs.writeFile(path.join(outputRoot, `${mapStem}.json`), JSON.stringify(sceneManifest, null, 2)); + + return { + mapStem, + cacheRoot, + outputRoot, + summary, + outputPngPath: path.join(outputRoot, `${mapStem}.png`), + debugPngPath: debugRender ? path.join(outputRoot, `${mapStem}_labels.png`) : null, + outputJsonPath: path.join(outputRoot, `${mapStem}.json`), + validationOutputs, + region02Example, + }; +} diff --git a/psx-map-exporter/src/render.js b/psx-map-exporter/src/render.js new file mode 100644 index 0000000..0b2881e --- /dev/null +++ b/psx-map-exporter/src/render.js @@ -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), + }; +} diff --git a/psx-map-exporter/src/wdl.js b/psx-map-exporter/src/wdl.js new file mode 100644 index 0000000..8df3350 --- /dev/null +++ b/psx-map-exporter/src/wdl.js @@ -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, + }; +} diff --git a/psx-map-exporter/tmp_inspect_region00.mjs b/psx-map-exporter/tmp_inspect_region00.mjs new file mode 100644 index 0000000..d0269d5 --- /dev/null +++ b/psx-map-exporter/tmp_inspect_region00.mjs @@ -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)); From 8d34c85c222bd94dfa8a77d6604cf6a1127cc61f Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 13 Apr 2026 16:50:28 +0200 Subject: [PATCH 2/2] PSX Research --- .../raw-ne-patch-conversion.instructions.md | 109 ++ .../idata/01/~0000001b.db/change.data.gbf | Bin 163840 -> 163840 bytes .../idata/01/~0000001b.db/change.map.gbf | Bin 32768 -> 32768 bytes .../01/~0000001b.db/{db.61.gbf => db.65.gbf} | Bin 8028160 -> 8044544 bytes .../01/~0000001b.db/{db.60.gbf => db.66.gbf} | Bin 8011776 -> 8044544 bytes Crusader.rep/projectState | 1175 ++++++++++++++++- .../00/~0000000d.db/{db.8.gbf => db.10.gbf} | Bin 163840 -> 163840 bytes plan-mid.md | 30 + psx-map-exporter/docs/spec.md | 9 + psx-map-exporter/src/cli.js | 8 + psx-map-exporter/src/export-map.js | 159 ++- .../tmp_correlate_runtime_map0.mjs | 79 ++ .../tmp_dump_runtime_snapshot.mjs | 159 +++ 13 files changed, 1720 insertions(+), 8 deletions(-) create mode 100644 .github/instructions/raw-ne-patch-conversion.instructions.md rename Crusader.rep/idata/01/~0000001b.db/{db.61.gbf => db.65.gbf} (97%) rename Crusader.rep/idata/01/~0000001b.db/{db.60.gbf => db.66.gbf} (96%) rename Crusader.rep/user/00/~0000000d.db/{db.8.gbf => db.10.gbf} (99%) create mode 100644 psx-map-exporter/tmp_correlate_runtime_map0.mjs create mode 100644 psx-map-exporter/tmp_dump_runtime_snapshot.mjs diff --git a/.github/instructions/raw-ne-patch-conversion.instructions.md b/.github/instructions/raw-ne-patch-conversion.instructions.md new file mode 100644 index 0000000..c5c9f31 --- /dev/null +++ b/.github/instructions/raw-ne-patch-conversion.instructions.md @@ -0,0 +1,109 @@ +--- +description: 'Workflow for converting live Ghidra NE patch experiments into raw executable patcher definitions' +applyTo: '**/patch_*.ps1, **/patch_*.md, **/docs/**/*.md' +--- + +# Raw NE Patch Conversion + +Use these instructions when a live Ghidra byte-patch experiment needs to become a reusable raw executable patch in a PowerShell patcher. + +## Goal + +- Treat the live Ghidra patch as the prototype, not the shipping artifact. +- Convert the final behavior into raw file-byte edits and NE relocation edits that a patcher script can apply and restore deterministically. +- Do not depend on the Ghidra export processor to preserve byte edits unless that exact processor path has already been verified on the target binary. + +## Required Capture From Ghidra + +Before writing the patcher definition, capture all of these from the live session: + +- Final target program name and whether it was a writable copy. +- Every patched selector address and the final intended behavior at that site. +- Exact original bytes and exact patched bytes for any plain code/data window. +- Every far call or far jump target change, including both the source address and the final target selector:offset. +- Any overwritten helper window boundaries, especially when the patch replaces existing functions instead of using empty space. +- Any branch-family coverage requirements, such as seasonal paths or alternate message lanes. + +If the patch needs to be restorable, also capture the exact original bytes and the exact original relocation-record payloads. + +## NE Conversion Rules + +### Selector To Segment Index + +For these Crusader NE executables, convert a selector-style code address to the NE segment index with: + +```text +segment_index = ((selector - 0x1000) / 8) + 1 +``` + +Use the executable's NE header to resolve the segment table entry, alignment shift, segment file offset, segment length, and relocation table location. + +### Raw Code/Data Bytes + +For a plain byte rewrite inside one code segment: + +```text +raw_file_offset = segment_file_offset + segment_relative_offset +``` + +Use the exact original byte window for verification and restore. + +### Relocation-Bearing Far Calls + +For NE `CALLF` and other relocation-backed far operands: + +- Patch the relocation record, not the placeholder immediate bytes in the opcode. +- The relocation source offset is the operand location, which starts one byte after the `9A` opcode. +- Keep the opcode bytes as `9A FF FF 00 00` unless the instruction itself is being replaced. +- Restore must write the retail relocation payload back verbatim. + +### Overwritten Windows With Existing Relocations + +If you overwrite a helper window that already contains relocation-backed far calls: + +- Enumerate every relocation record whose source offset falls inside the overwritten range. +- Either preserve that source offset as a far-call slot in the new helper or retarget it explicitly. +- Never leave a stale relocation entry attached to bytes that are no longer a relocation-bearing instruction operand. +- Prefer reusing existing relocation slots over inventing new ones. + +This is the safest way to convert a live in-memory proof patch into a raw NE patch without rewriting relocation tables structurally. + +## Patcher-Script Rules + +- Define each raw patch site with `Offset`, `Original`, and `Enabled` bytes. +- For large helper windows, keep the full original byte block in the script so restore is exact. +- For relocation-record edits, store the full 8-byte record image, not only the target segment/offset words. +- Group related sites into one named patch family with one status function and one apply/restore function. +- Status must accept both retail bytes and fully patched bytes. Any other state is unknown and must block writes. +- Restore must always write the complete retail state for the whole family, even if an older or partial patch was enabled first. + +## Validation Checklist + +After converting a Ghidra patch into a raw patcher definition: + +1. Verify the raw offsets against the current retail executable. +2. Verify every `Original` byte block matches the live unpatched file. +3. Apply the patch through the script, then re-read every site and confirm the `Enabled` bytes match exactly. +4. Confirm all overlapping relocation-backed callsites decode to the intended targets. +5. If the patch has alternate trigger branches, test each one or document the untested branch explicitly. +6. Keep the runtime test order staged so failures isolate bootstrap, UI open, and seeded-runtime behavior separately. + +## Documentation Requirements + +When a patch graduates from Ghidra prototype to raw patcher support: + +- Update the working note with the final raw offsets and what each site does. +- Record any helper-window overwrite boundaries and reused relocation slots. +- Record any export limitation that forced the raw-patch conversion. +- If MCP friction caused extra work, append a concise entry to the wishlist with the missing capability and the manual fallback. + +## Example Pattern + +The Regret `debug menu 2.0` patch is the reference model for this workflow: + +- one raw helper-window rewrite inside segment `13f8` +- one wrapper relocation retarget into that helper +- two helper relocation retargets that reuse the overwritten window's existing far-call slots +- multiple `loosecannon` fixup retargets so all trigger branches land in the debugger modal path + +Use that pattern when a live Ghidra proof patch needs to become a stable, restorable patcher option. diff --git a/Crusader.rep/idata/01/~0000001b.db/change.data.gbf b/Crusader.rep/idata/01/~0000001b.db/change.data.gbf index 7730ed0c997bbaef0c0871ecedcc0dd25cd6cbca..5daeace6efd9255f599c44614984c871cb727bcd 100644 GIT binary patch literal 163840 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 literal 163840 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 QyX%AKwEk+>hok!c0h4`2+W-In delta 19102 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 diff --git a/Crusader.rep/idata/01/~0000001b.db/db.60.gbf b/Crusader.rep/idata/01/~0000001b.db/db.66.gbf similarity index 96% rename from Crusader.rep/idata/01/~0000001b.db/db.60.gbf rename to Crusader.rep/idata/01/~0000001b.db/db.66.gbf index d4d51c00e6349faa35a4ed219e831038bbbdbb2c..f632e1e256170ed61a4263a6cdd96c5d904423fb 100644 GIT binary patch delta 48727 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 delta 19280 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 - @@ -12,7 +11,1179 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Crusader.rep/user/00/~0000000d.db/db.8.gbf b/Crusader.rep/user/00/~0000000d.db/db.10.gbf similarity index 99% rename from Crusader.rep/user/00/~0000000d.db/db.8.gbf rename to Crusader.rep/user/00/~0000000d.db/db.10.gbf index 5f7bfdb5a005887e7d3df285553fabdf6abe72a0..e78bbc3d86e0bdbb10b51bf16cd3e191b4e2f228 100644 GIT binary patch delta 154 zcmZo@;A&{#63{m=)G^W2(fXp4*&gf47t<)vD!{l^fQiR}$slsZMp*_1 z22mf!;E?!$Ab3VfcCj0?K`RPT8xjy;Hi6xo&c@RC5>X?kDU#e%)0{|=wFZKWc delta 203 zcmZo@;A&{#63{m=)G^W2(MnD;<(#Iw^=6|$s{rFx0VW;?CR^i;in6xbt!tTt8KX!CUGIPoD%C$KgZx; wPj^387q#>fYp?=A{~#AnKS!T