Compare commits
2 commits
a9153546ae
...
8d34c85c22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d34c85c22 | ||
|
|
2f243976b6 |
26 changed files with 4968 additions and 7 deletions
109
.github/instructions/raw-ne-patch-conversion.instructions.md
vendored
Normal file
109
.github/instructions/raw-ne-patch-conversion.instructions.md
vendored
Normal file
|
|
@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
## 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.
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
22
docs/psx/vram-dump-bundle-grounding.md
Normal file
22
docs/psx/vram-dump-bundle-grounding.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# PSX VRAM Dump Bundle Grounding
|
||||
|
||||
## Scope
|
||||
|
||||
- Active dump-grounding target: `binary/Crusader - No Remorse (USA) GPU RAM 2.bin`
|
||||
- Immediate goal: keep a short evidence log for bundle ids that are visibly present in live PSX VRAM so bundle export/palette work can stay anchored to known runtime art.
|
||||
|
||||
## Known Bundles
|
||||
|
||||
- `bundle_00b3158`: chest NE, appears in the dump
|
||||
- `bundle_0011ad4c`: generator, appears in the dump
|
||||
- `bundle_0015b80`: part of a console, appears in the dump
|
||||
|
||||
## Current Palette Rule
|
||||
|
||||
- For dump-grounded `mode 1` bundle export, prefer the live GPU RAM CLUT slice at row `0xF0`, `x=0..255`, treated as one contiguous `256`-entry palette.
|
||||
- This is the same rule already recorded in `docs/psx/psx.md` for the verified cabinet console family and is now the first palette source to test before falling back to WDL-local palette heuristics.
|
||||
|
||||
## Follow-Up
|
||||
|
||||
- Map these absolute bundle ids back to extracted `out/psx_wdl_disc/.../sprite_bundles` directories when their owning WDL/region is identified.
|
||||
- Add matching framebuffer/VRAM crop evidence here as specific bundles are confirmed.
|
||||
30
plan-mid.md
30
plan-mid.md
|
|
@ -15,6 +15,36 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
|
|||
|
||||
## Progress Snapshot
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` late art-bank corridor pass centered on `wdl_resource_bundle_load_by_index` (`0x80039444`), the header-only write sites `0x8003977c/0x80039a64`, `psx_install_type_art_active_header_and_built_resource` (`0x80045ffc`), `psx_create_image_resource_from_descriptor` (`0x80044434`), and constructor fast paths `0x80024b0c/0x80025004`. Current best read is now exporter-critical and more exact than the older “one late descriptor bank” shorthand: each WDL pass contributes two art-facing late sections, the later `8`-byte header-only override is what leaves raw `0x58`-byte active headers in `DAT_800758d8`, and constructors reuse `DAT_800758c8[type]` when that raw-header signature is present instead of rebuilding. Practical consequence is that standalone parsing should target the late header-only override stream first and treat the earlier built-resource art-install blob as a separate, still-partially-unresolved feed rather than flattening both into one guessed art bank.
|
||||
|
||||
Latest verified batch: [docs/psx/map-storage-model.md](docs/psx/map-storage-model.md) now includes a 2026-04-13 live subordinate-section pass on active `SLUS_002.68` centered on `psx_apply_deferred_control_command` and `psx_control_assign_opcode_stream_by_index`. Current best read is now narrower and exporter-relevant: `DAT_80067938` provides constructor-placement-adjacent index data, `DAT_80067838` backs `8`-byte deferred-control row chains consumed by root/live-object mutation helpers, and `DAT_80067840` is an opcode-stream pointer table rather than hidden geometry. Practical consequence is that `post_audio_region_02` should be treated as a mixed resource/control payload zone until smaller typed sub-lanes are split out, not as a presumed flat floor table.
|
||||
|
||||
Latest verified batch: standalone `psx-map-exporter` probe binding is now raw-only again after removing the remaining viewer-side atlas fallback from `src/export-map.js`. Fresh `LSET1_L0_probe_raw_research.json` now reports `artBindingSource = raw-typeword-bundle-slot`, and neither `viewer-reference-atlas` nor bundle `0x00085c40` appears in the regenerated probe item list. Region-02 example export also now carries structured `previewRows` in JSON alongside the false-color image so the next pass can inspect row-shaped data instead of relying on the rainbow preview alone.
|
||||
|
||||
Latest verified batch: standalone `psx-map-exporter` probe rendering now excludes `section0_dispatch_roots` types `0x0042` and `0x0049` as non-map-facing portrait/talk assets. This is directly aligned with earlier `docs/psx/psx.md` negative evidence for those root families, and the fresh `LSET1_L0_probe_raw_research` rebuild dropped from `59` to `57` rendered items while keeping the skipped records visible in JSON diagnostics. Practical consequence is narrower: the removed portrait block (`55/58`) was not a palette problem, while the remaining bad colors are concentrated in unresolved mode-2 palette families (`0x0048`, `0x004b`, `0x0059`) rather than in the disproven portrait/talk fallback lane.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` source-record palette-token provenance pass centered on constructor source-pointer stores (`0x80024b50`, `0x80025048`), section0 constructor-placement stride (`0x800258cc`), and main-visible token read split (`0x80041458`). Current best read is now exporter-decisive for region00 handling: constructors persist authored source pointer at `obj+0xa0`; main-visible token injection reads source high byte from `+0x06` for `0x003e..0x00ab` and `+0x0c` for `>=0x00ac`; and region00-style 12-byte records are therefore valid token carriers for current visible unresolved families (`0x0042`, `0x0049`, `0x0055..0x0063`) because they stay in the `<0x00ac` band. Live artifacts in this batch are one conservative helper rename (`0x80027f38`) and three targeted decompiler comments at `0x80024b50`, `0x80025048`, and `0x8004156c`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` main-visible palette-token submit normalization pass centered on `0x80041458` with wrapper context at `0x80031f0c/0x80041378` and submitter override gates at `0x80044e10/0x80044eb8`. Current best read is now bit-exact for exporter logic: main-visible packs submit flags as `(obj_flags & 0x0002) | token_hi` where `token_hi = source_word & 0xFF00`, so palette token payload is carried in bits `15:8`; both submitters apply CLUT override only when `(flags & ~0xF) != 0`, therefore world-lane bit `0x0002` alone never activates override. Live artifacts in this batch are conservative nearby helper renames (`0x8003a3b0 -> psx_world_draw_tint_fade_step`, `0x80038f10 -> psx_noop_frame_hook_38f10`, `0x80044018 -> psx_noop_frame_hook_44018`) plus targeted decompiler comments at `0x80041590`, `0x800415c0`, `0x80044e10`, `0x80044eb8`, `0x80031f34`, and `0x80031f3c`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live kind-5 image-table palette-path verification pass on active `SLUS_002.68` centered on `0x80044e9c` with world caller lanes at `0x800415c0` and `0x800412dc`, sibling sprite branch checks at `0x80044e10/0x80044e34/0x80044e5c`, and CLUT table uses at `0x800a9f48/0x800a9f66`. Current best read is now exporter-rule exact for this lane: image-table submit uses default `psx_clut_table_by_resource_bank[resource_bank]` when `(submit_flags & 0xfffffff0)==0`, otherwise uses `psx_clut_override_table_by_palette_token[(submit_flags>>8)]`; main-visible injects authored palette-token high byte while special-visible does not, so stage-2 world draw remains default-bank CLUT on this path. Live artifacts in this batch are one conservative nearby helper rename (`0x80044380 -> psx_clut_vram_rows_f0_f8_swap`) plus a decompiler comment preserving its global CLUT-page-swap role.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` kind-4/kind-5 palette-selection field closure pass centered on `0x800444e4`, `0x80044614`, `0x80044bdc`, and `0x80044e9c` with lane-source checks at `0x80041458/0x80041144`. Current best read is now exporter-rule exact: default CLUT bank still comes from header `+0x14` through resource `+0x08`, no additional frame-table header field in bind/upload helpers selects palette directly, sprite override routing is format-dependent (`resource+0x04`), and high-byte palette token source remains draw-lane/object-authored rather than resource-header-local. Live artifacts in this batch are targeted decompiler comments at `0x800444e4`, `0x80044614`, `0x80044bdc`, and `0x80044e9c`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` sprite-submitter CLUT closure pass centered on `psx_sprite_resource_submit_frame` (`0x80044bdc`) with caller lanes `0x800415e0/0x800412f8` and bind/load feeders `0x800444e4/0x80044614`. Current best read is now rule-exact for exporter logic: sprite CLUT selection is format-branching (`resource+0x04==2` versus non-`2`) under a shared override gate (`submit_flags & 0xfffffff0`), main-visible injects authored high-byte palette token while special-visible does not, and the descriptor/header palette bank (`descriptor+0x14`) survives through `resource+0x08` to remain the default CLUT selector when no active override token is present. Live artifacts in this batch are one nearby helper rename (`0x80044380 -> psx_swap_clut_rows_f0_f8`) plus targeted decompiler comments at `0x80044380`, `0x800444fc`, `0x80044680`, `0x80044e1c`, `0x80044e34`, and `0x80044e4c`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live visibility-routing and main-visible ordering pass on active `SLUS_002.68` centered on `0x800131a8`, player/object neighbors in `0x8001263c..0x80013688`, and ordering helpers `0x8002be6c`, `0x8002c89c`, `0x8002ca74`, `0x8002d778`, and `0x8002e064` plus nearby graph-node helpers `0x8002cd60`, `0x8002ce38`, `0x8002cf3c`, and `0x8002cfb0`. Current best read is now exporter-actionable and lane/order split explicit: stage-1 vs stage-2 choice remains object-local (`type==4 || obj+0x1c bit0x0400`), policy-table reads are downstream ordering/publication controls rather than lane selectors, and stage-1 draw order is dependency-graph driven with policy-biased pair compare/unlink behavior rather than plain depth sort. Live artifacts in this batch are four conservative helper renames and seven targeted decompiler comments preserving route-split and graph-order semantics.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 live MCP world-visible draw lane and CLUT-routing refresh on active `SLUS_002.68` centered on `0x80041378`, `0x80041458`, `0x80041144`, `0x80044bdc`, `0x80044e9c`, and CLUT tables `0x800a9f48/0x800a9f66`. Current best read is route-explicit and exporter-critical: world draw order remains stage-1 main-visible then stage-2 special-visible then HUD; submitter dispatch remains resource-kind based for world lanes (`kind==5` image-table else sprite); main-visible injects authored palette-token high byte while special-visible does not; and CLUT override gating remains shared (`submit_flags & 0xfffffff0`) with lane-dependent table selection. Live artifacts in this batch are conservative helper renames `0x8002e534 -> psx_marker_channel_runtime_get_u16_86` and `0x8002eee8 -> psx_marker_channel_runtime_get_u16_84`, plus targeted decompiler comments at `0x80041378`, `0x800415c0`, `0x800412dc`, `0x80044ed0`, and `0x80044e5c`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` art-payload decode semantics pass 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`). Current best read is standalone-exporter explicit: kind-4 is a single-slot descriptor bind lane, kind-5 is a per-frame table upload lane with raw-versus-RLE branch on frame flags bit0, row-RLE control semantics are now pinned for offline decode, and frame geometry/origin extraction must branch by resource kind and frame-table stride instead of visibility lane flags. Live artifacts in this batch are three conservative helper renames (`0x80045440`, `0x800455d4`, `0x80045d78`) plus targeted decompiler comments at the key kind-4/kind-5 bind, RLE decode, and frame-geometry entry points.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 live MCP authored-family descriptor/constructor closure pass on active `SLUS_002.68` centered on `0x800256b0`, `0x800258cc`, `0x800249f4`, `0x80024eec`, and descriptor row `0x800626f8`. Current best read is now convergence-explicit and exporter-facing: section-0 root and constructor-placement records for unresolved families (`0x0042`, `0x0049`, `0x0055..0x0063`) still converge through shared row `0x800626f8` slot0, while constructor divergence-relevant data remains authored route-word copy into `obj+0x1c` plus post-bind state/route/latch channels, not a type-unique descriptor callback split. Live artifacts in this batch are one conservative helper rename (`0x80031c34`) and targeted comments at `0x800256b0`, `0x800258cc`, `0x800249f4`, `0x80024eec`, and `0x800626f8`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 live MCP selector-install/transition-reselection/final-latch closure pass on active `SLUS_002.68` centered on `0x800260e8`, `0x80025d68`, `0x8001bca0`, `0x80018578`, `0x8002906c`, and `0x80029dac`. Current best read is now exporter-critical and ordering-explicit: selector install (`obj+0x9e`) is pre-latch setup, final visible token (`obj+0x94`) is latched later in state advance, transition-table/type-`0x0042` paths mutate selector and narrow low control bits before that latch, and the unresolved post-construction path near `FUN_8002906c` is closed as delayed trigger into `psx_type4_reselect_motion_state`. Live artifacts in this batch are targeted decompiler comments at `0x800260e8`, `0x80025d68`, `0x8001bca0`, `0x80018578`, `0x8002906c`, and `0x80029dac`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-13 focused live `SLUS_002.68` loader/bundle-install and compressed-state lane pass centered on `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`), `psx_install_level_audio_runtime_stream_bundle` (`0x80040768`), and `psx_lzss_unpack_into_level_buffer` (`0x8003b00c`). Current best read is exporter-explicit: stream-installer per-type records seed state/script/component/extents plus raw active-header pointer while clearing built-resource slots; WDL install path separately resolves built resources via kind-4/5 art install; detached runtime-stream blob carries a fixed header-driven multi-chunk install (9 lane arrays + SPU tail upload); and compressed-state inflate remains a pre-dispatch `0x3e00` lane with zero-token termination feeding runtime-header apply/root dispatch. Live artifacts in this batch are three conservative helper renames (`0x8002b6b8`, `0x8002b6e0`, `0x80024720`) plus targeted decompiler comments at `0x80038f18`, `0x80040768`, `0x8003b00c`, and `0x80024720`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-12 live MCP CLUT override routing closure pass on active `SLUS_002.68` centered on `0x80041458`, `0x80041144`, `0x80044bdc`, `0x80044e9c`, `0x800a9f48`, and `0x800a9f66`. Current best read is now exporter-critical and path-explicit: main-visible injects authored palette token while special-visible does not; submitter override gate is shared (`flags & 0xfffffff0`); and active override resolution diverges by submitter/resource-format lane (image-table and sprite format-2 use `psx_clut_override_table_by_palette_token[token]`, sprite non-format-2 uses token as a row key into `psx_clut_table_by_resource_bank`). Practical consequence is that token `0` is effectively no-override for this world-object path and exporter CLUT logic must branch by route lane plus submitter/resource format instead of flattening token handling. Live artifacts in this batch are targeted comments at `0x800415b0`, `0x800412d0`, `0x80044e10`, and `0x80044eb8`.
|
||||
|
||||
Latest verified batch: [docs/psx/art-binding-recovery.md](docs/psx/art-binding-recovery.md) now includes a 2026-04-12 live MCP wall-family discriminator pass for the exporter regression where atlases repeat and wall faces collapse. Current best read is now split-explicit for `0x003e..0x004f`: constructor bind in `psx_object_create_simple_record`/`psx_object_create_compound_record` still converges on per-type `DAT_800758d8[type]`, while real divergence happens post-bind through selector install (`0x800260e8`), frame-token latch (`0x80025d68` -> `obj+0x94`), and stage-1 versus stage-2 route semantics (`0x80041458` vs `0x80041144`), including main-visible-only authored palette-token injection for `>=0x003e`. Immediate exporter consequence is to prioritize effective route/latch-state discrimination over inventing a new pre-constructor resource-bank split. Live artifacts in this batch are targeted comments at `0x80046038`, `0x80026100`, `0x80041554`, and `0x80040f88`.
|
||||
|
|
|
|||
3
psx-map-exporter/.gitignore
vendored
Normal file
3
psx-map-exporter/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.cache/**
|
||||
.output/**
|
||||
node_modules/**
|
||||
248
psx-map-exporter/docs/implementation-analysis.md
Normal file
248
psx-map-exporter/docs/implementation-analysis.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# PSX Map Exporter Implementation Analysis
|
||||
|
||||
## Summary
|
||||
|
||||
The exporter should be treated as a controlled probe, not as a final renderer.
|
||||
|
||||
The key design choice is to keep the whole path raw-file-based and auditable:
|
||||
|
||||
- raw WDL in
|
||||
- explicit carve + record extraction + bundle extraction
|
||||
- cached sprite/frame artifacts out
|
||||
- final composed map PNG out
|
||||
|
||||
That keeps the work independent from the existing viewer and makes every wrong assumption inspectable.
|
||||
|
||||
## Why This Architecture
|
||||
|
||||
The existing PSX work already proved two important negative results:
|
||||
|
||||
- direct raw bundle-order art binding is too weak to count as solved
|
||||
- viewer-side polish is low value until extraction is isolated and testable
|
||||
|
||||
So the new exporter should optimize for:
|
||||
|
||||
- small number of assumptions
|
||||
- easy intermediate inspection
|
||||
- direct correspondence to documented executable behavior where possible
|
||||
|
||||
## Chosen `v0` Path
|
||||
|
||||
### 1. Parse only the parts of the WDL we can justify now
|
||||
|
||||
Implemented directly from docs:
|
||||
|
||||
- `0x34` header
|
||||
- audio-size dword
|
||||
- absolute region boundaries recovered from high offset words in the header
|
||||
|
||||
Not implemented in `v0`:
|
||||
|
||||
- full loader section choreography
|
||||
- detached runtime stream install
|
||||
- inflated runtime-state interpretation
|
||||
|
||||
Those are preserved as future extension points but not required for the first PNG.
|
||||
|
||||
### 2. Prefer loader-sized `post_audio_section_00` as a layered authored probe
|
||||
|
||||
Why:
|
||||
|
||||
- the old region00-first path is now known to overfit the small root-dispatch family
|
||||
- loader-sized section parsing recovers the dense constructor-placement records from the same first real section, currently modeled as paired 12-byte records inside 24-byte row chunks
|
||||
- the same section also exposes the smaller root-dispatch lane, which is independently renderable offline and now belongs in the default layered probe
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- the art binding is still diagnostic-only for many types
|
||||
- constructor placements are better understood as one runtime object seed layer, not the final visible map or the static world substrate
|
||||
- root-dispatch rows now render as a second authored layer, but they still do not close the runtime-only control, state, and dynamic effect gaps
|
||||
|
||||
This is acceptable for `v0` because the project goal is a fresh, inspectable layered baseline rather than a falsely confident full renderer.
|
||||
|
||||
### 3. Decode art from raw bundles, but keep binding diagnostic
|
||||
|
||||
What is strong already:
|
||||
|
||||
- bundle scan can be constrained by executable-backed header fields
|
||||
- frame decode and row-RLE semantics are pinned
|
||||
|
||||
What is still weak:
|
||||
|
||||
- exact late-`DAT_800758d8` parse and type-to-resource selection path
|
||||
- exact palette path
|
||||
|
||||
So the current standalone probe does the right split:
|
||||
|
||||
- strong part: raw bundle/frame decode
|
||||
- diagnostic part: `typeWord -> bundle slot`
|
||||
|
||||
It also exports candidate late active-header override blobs to cache so the Ghidra-backed `DAT_800758d8` header-only lane can be inspected per run without pretending that binding is already solved.
|
||||
|
||||
The newer conclusion from `LSET1/L0` label failures is narrower than the earlier wording: if one type repeatedly paints a coherent room footprint with obviously wrong art, the exporter is probably visualizing valid world-object seed placement while still missing the separate static-world layer and the downstream executable bind/state path that chooses the final drawable resource.
|
||||
|
||||
Viewer-derived sidecars and donor mappings are no longer acceptable here because they blur exactly the binding problem the exporter is meant to isolate.
|
||||
|
||||
## Module Plan
|
||||
|
||||
### `src/wdl.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- read header words
|
||||
- compute post-audio start
|
||||
- derive regions from absolute boundary values
|
||||
- expose region buffers and summary metadata
|
||||
|
||||
Reason to isolate it:
|
||||
|
||||
- the carve is likely to change as more loader details land
|
||||
- record extraction should not depend on header internals
|
||||
|
||||
### `src/bundles.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- scan the graphics bank for plausible kind-4/kind-5 bundles
|
||||
- parse bundle headers and frame entries
|
||||
- decode frame bytes
|
||||
- emit grayscale PNG-ready RGBA buffers
|
||||
|
||||
When the standalone scan yields zero bundles for a map, `src/export-map.js` may hydrate bundle offsets and frame geometry from `out/psx_wdl_disc/.../summary.json` and continue decoding the actual frame bytes from the raw WDL.
|
||||
|
||||
Reason to isolate it:
|
||||
|
||||
- this code is reusable even if the map schema changes
|
||||
- it is the strongest raw-file-backed part of the exporter
|
||||
|
||||
### `src/export-map.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- choose the record source
|
||||
- choose diagnostic art binding
|
||||
- normalize screen bounds
|
||||
- write cache metadata and composed outputs
|
||||
|
||||
This file holds the intentionally weak parts of `v0` so they remain easy to replace.
|
||||
|
||||
### `src/render.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- sprite compositing
|
||||
- sort order approximation
|
||||
- PNG encoding
|
||||
- neutral opaque background for evaluation-friendly probe output
|
||||
|
||||
## Data Contracts
|
||||
|
||||
### Record
|
||||
|
||||
```json
|
||||
{
|
||||
"index": 0,
|
||||
"source": "region00",
|
||||
"typeWord": 74,
|
||||
"xWord": 5635,
|
||||
"yWord": 3815,
|
||||
"zWord": 0,
|
||||
"selectorWord": 1,
|
||||
"laneWord": 32,
|
||||
"screenX": -1820,
|
||||
"screenY": -4725
|
||||
}
|
||||
```
|
||||
|
||||
### Bundle
|
||||
|
||||
```json
|
||||
{
|
||||
"offsetInRegion": 58808,
|
||||
"absoluteOffset": 534068,
|
||||
"kind": 5,
|
||||
"mode": 2,
|
||||
"paletteIndex": 12,
|
||||
"frameCount": 3,
|
||||
"dataOffset": 112,
|
||||
"frameTableOffset": 52
|
||||
}
|
||||
```
|
||||
|
||||
### Scene Item
|
||||
|
||||
```json
|
||||
{
|
||||
"recordIndex": 0,
|
||||
"bundleSlot": 74,
|
||||
"bundleAbsoluteOffset": 954728,
|
||||
"frameIndex": 1,
|
||||
"screenX": -1820,
|
||||
"screenY": -4725,
|
||||
"drawX": -1879,
|
||||
"drawY": -4815,
|
||||
"width": 96,
|
||||
"height": 91,
|
||||
"originX": 59,
|
||||
"originY": 90
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
`v0` validation should answer four questions only:
|
||||
|
||||
1. Did the raw WDL parse into the documented regions?
|
||||
2. Did the graphics-bank scanner recover plausible bundles with decoded frames?
|
||||
3. Did the constructor-placement extractor recover plausible section-0 rows from the loader-sized section view?
|
||||
4. Did the compositor produce a non-empty PNG with recognizable art silhouettes on a neutral background?
|
||||
|
||||
This is enough for the first pass.
|
||||
|
||||
## Risks
|
||||
|
||||
### Binding risk
|
||||
|
||||
The diagnostic bundle binding is the weakest part of the pipeline.
|
||||
|
||||
Expected failure modes:
|
||||
|
||||
- correct placement with wrong art family
|
||||
- repeated art across several type families
|
||||
- frame clamping where selector words exceed available bundle frames
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep the chosen bundle slot, frame clamp count, and bundle-repeat metrics in output metadata
|
||||
|
||||
### Schema risk
|
||||
|
||||
The `region00` record extractor uses a plausibility scan instead of a final loader schema.
|
||||
|
||||
Expected failure modes:
|
||||
|
||||
- false positives in some maps
|
||||
- missing records when the preamble differs
|
||||
|
||||
Mitigation:
|
||||
|
||||
- preserve `recordStartOffset`
|
||||
- make `region01` fallback selectable from CLI
|
||||
|
||||
### Palette risk
|
||||
|
||||
Grayscale is intentionally not faithful to the executable color path.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep the grayscale rule explicit
|
||||
- do not mix partial CLUT heuristics into `v0`
|
||||
|
||||
## Immediate Follow-Up Options
|
||||
|
||||
After `v0` works, the next pass should choose one of these:
|
||||
|
||||
1. Replace provisional art binding with a loader-backed type/resource lookup.
|
||||
2. Parse the late `DAT_800758d8` bank directly from the large late graphics area instead of relying on slot order.
|
||||
3. Add executable-backed CLUT reconstruction once the palette path is pinned tightly enough.
|
||||
4. Recover stage-1 graph ordering when sprite placement is stable enough to make sort differences meaningful.
|
||||
265
psx-map-exporter/docs/spec.md
Normal file
265
psx-map-exporter/docs/spec.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
# PSX Map Exporter Spec
|
||||
|
||||
## Goal
|
||||
|
||||
`psx-map-exporter` is a standalone Node.js probe for Crusader PSX map extraction.
|
||||
|
||||
It exists to prove a fresh end-to-end path from raw `LSET*.WDL` input to:
|
||||
|
||||
- extracted intermediate sprite assets under `.cache`
|
||||
- a rendered map PNG under `.output`
|
||||
|
||||
This project does not reuse `Crusader-Map-Viewer` code, scene caches, donor mappings, or sidecar summaries as binding inputs. It only consumes raw PSX assets plus the documented executable-backed findings from `docs/psx` and the live Ghidra session.
|
||||
|
||||
## Scope
|
||||
|
||||
Version `v0` is intentionally narrow.
|
||||
|
||||
It will:
|
||||
|
||||
- read one PSX `LSET*.WDL` file
|
||||
- parse the documented `0x38`-byte top-level header
|
||||
- carve the post-audio map/art regions from header-derived boundaries
|
||||
- parse the loader-sized post-audio sections as a second, higher-value view of the file layout
|
||||
- extract the dense constructor-placement family from `post_audio_section_00`
|
||||
- keep the smaller root-dispatch family available as a comparison probe
|
||||
- render a layered authored probe that can combine constructor placements with the smaller root-dispatch lane
|
||||
- scan `post_audio_region_04` for type-4/type-5 sprite bundles
|
||||
- decode bundle frames directly from the raw WDL
|
||||
- write extracted frame PNGs to `.cache`
|
||||
- compose a probe map PNG to `.output`
|
||||
|
||||
It will not claim full runtime parity yet.
|
||||
|
||||
Known non-goals for `v0`:
|
||||
|
||||
- exact `DAT_800758d8/d0/cc/d4` parity
|
||||
- exact CLUT reproduction
|
||||
- full stage-1 dependency-graph ordering
|
||||
- exact type-to-resource binding for unresolved families
|
||||
- full `post_audio_region_01` / `post_audio_region_02` semantic decode
|
||||
|
||||
## Evidence Constraints
|
||||
|
||||
The implementation is grounded in these current facts from the docs and Ghidra:
|
||||
|
||||
- `LSET*.WDL` uses a fixed `0x38`-byte top-level header.
|
||||
- The second dword is the audio/SPU blob size.
|
||||
- The old region-only carve is not sufficient on its own for visible-object recovery; loader-sized `post_audio_section_00` contains both the small root-dispatch rows and the dense constructor-placement rows.
|
||||
- The file contains a post-audio area with four high-confidence absolute boundaries that split:
|
||||
- `post_audio_region_00`
|
||||
- `post_audio_region_01`
|
||||
- `post_audio_region_02`
|
||||
- `post_audio_region_03`
|
||||
- `post_audio_region_04`
|
||||
- The small count-prefixed section-0 root-dispatch rows are real, but they are not the whole map object set.
|
||||
- The dense constructor-placement records recovered from loader-sized `post_audio_section_00` are currently the best standalone live-object seed source, not a proven final visible-map layer.
|
||||
- Current strongest standalone layout read: the constructor-placement lane is a count-prefixed `12`-byte substream inside the loader-sized section-0 span rather than a whole-section `24`-byte row grid. For `LSET1/L0.WDL`, the best current candidate has a section-relative header at `0x38`, a record start at `0x3c`, and a reported count of `1182` records.
|
||||
- The constructor-placement stream can extend slightly past the nominal `post_audio_section_00` slice, so standalone parsing must follow the detected stream count from the section-0 base instead of truncating strictly at the section object boundary.
|
||||
- `post_audio_region_04` is the strongest current graphics bank candidate.
|
||||
- The direct `typeWord -> bundle slot` scan-order binding is disproven as a final art rule and is retained only as a diagnostic bundle-family probe.
|
||||
- The real art/template lane is `DAT_800758d8`, but the executable now shows two distinct late art feeds per WDL pass rather than one monolithic bank:
|
||||
- an earlier art-install blob that builds resources and temporarily mirrors them into `DAT_800758d8`
|
||||
- a later `8`-byte header-only override blob that restores raw active-header pointers into `DAT_800758d8`
|
||||
- The later header-only override is the safer standalone parser target: constructors branch on first dword `0x58` and then reuse `DAT_800758c8[type]`, so the final post-load `DAT_800758d8` state is a raw-header lane, not a permanently built-resource lane.
|
||||
- Type-4/type-5 drawable bundles expose width, height, palette mode/index, frame count, frame table offset, and data offset in the raw bundle header.
|
||||
- Bundle frame entries use a `20`-byte row with size, relative data offset, width, height, origin x/y, and flags.
|
||||
- `sprite_rle_decode_rows` uses row-local control bytes:
|
||||
- positive: repeat next byte N times
|
||||
- negative: copy next `abs(N)` literal bytes
|
||||
- zero: end row
|
||||
- The executable projection basis is:
|
||||
|
||||
$$
|
||||
screen_x = y - x
|
||||
$$
|
||||
|
||||
$$
|
||||
screen_y = 2z - \frac{x + y}{2}
|
||||
$$
|
||||
|
||||
## Input Model
|
||||
|
||||
The exporter accepts either:
|
||||
|
||||
- a direct `--wdl` path
|
||||
- or a `--source` path relative to a PSX disc root
|
||||
|
||||
Default disc root for local workspace runs:
|
||||
|
||||
- `d:/Ghidra/Crusader-Map-Viewer/map_renderer/STATIC_PSX`
|
||||
|
||||
Expected source examples:
|
||||
|
||||
- `LSET1/L0.WDL`
|
||||
- `LSET4/L37.WDL`
|
||||
|
||||
## Output Layout
|
||||
|
||||
### `.cache`
|
||||
|
||||
Per-run cache path:
|
||||
|
||||
- `.cache/<map-stem>/`
|
||||
|
||||
Contents:
|
||||
|
||||
- `wdl-summary.json`
|
||||
- `records.json`
|
||||
- `bundles.json`
|
||||
- `frame-manifest.json`
|
||||
- `active-header-overrides.json`
|
||||
- `sprites/<bundle-offset>/frame_<n>.png`
|
||||
|
||||
The cache is disposable. It exists to preserve intermediate evidence and make re-runs inspectable.
|
||||
|
||||
`records.json` now also records constructor-stream detection metadata when available: stream header offset, record start offset, reported count, and the initial structured-prefix run.
|
||||
|
||||
The cache also records candidate late `DAT_800758d8` header-only override blobs as a standalone diagnostic. Those candidates are not used as final art binding yet.
|
||||
|
||||
`wdl-summary.json` now also emits `sceneInterpretation`, which is an explicit warning-bearing classification of what the current export most likely represents. For constructor-placement exports this should currently read as a constructor-fed live-object seed lane rather than a final visible-world reconstruction.
|
||||
|
||||
### `.output`
|
||||
|
||||
Per-run final outputs:
|
||||
|
||||
- `.output/<map-stem>.png`
|
||||
- `.output/<map-stem>.json`
|
||||
- `.output/<map-stem>_<layer>.png` for each rendered authored layer when layered mode is active
|
||||
|
||||
The JSON stores the final probe scene manifest used to draw the PNG.
|
||||
|
||||
The `.output` folder is reset at the start of each export so evaluation only sees artifacts from the current run.
|
||||
|
||||
The `.output/<map-stem>.json` manifest inherits `sceneInterpretation` from `wdl-summary.json` so consumers do not need to infer that warning from prose docs alone.
|
||||
|
||||
## Record Extraction Rules
|
||||
|
||||
`v0` now uses the loader-sized `post_audio_section_00` extraction paths as the primary scene source.
|
||||
|
||||
Current interpretation constraint:
|
||||
|
||||
- `section0_constructor_placements` should currently be treated as constructor-fed world-object seed records.
|
||||
- They preserve meaningful layout and projection structure, but current evidence does not support treating them as the complete visible map or static architecture layer.
|
||||
- If a render shows coherent room layout with globally wrong or repeated art, the exporter is currently visualizing one runtime object lane without the downstream per-type bind/state path and without the separate static-world substrate.
|
||||
|
||||
Record extraction rule:
|
||||
|
||||
- `auto` / `combined` / `layered` mode merges both authored section-0 families into one layered probe:
|
||||
- constructor placements provide the dense live-object seed lane
|
||||
- root-dispatch rows provide the smaller comparison and auxiliary authored lane
|
||||
- `constructors` / `region01` mode first searches the section-0 span for a count-prefixed `12`-byte constructor stream and, when found, treats each record as six little-endian `u16` words:
|
||||
- `typeWord`
|
||||
- `xWord`
|
||||
- `yWord`
|
||||
- `zWord`
|
||||
- `selectorWord`
|
||||
- `laneWord`
|
||||
- If a count-prefixed constructor stream is not found, the exporter falls back to the older whole-section `24`-byte paired-record scan as a compatibility probe.
|
||||
- `roots` / `region00` mode keeps the small count-prefixed root-dispatch probe for comparison and negative-evidence checks
|
||||
|
||||
Plausibility filter:
|
||||
|
||||
- `typeWord` in a conservative visible-family range
|
||||
- not all coordinate words are zero
|
||||
- `laneWord` is non-zero and within the current conservative control-word range
|
||||
|
||||
This is explicitly a probe schema, not a final loader-faithful schema.
|
||||
|
||||
Current negative result:
|
||||
|
||||
- Correcting the constructor stream start/count for `LSET1/L0.WDL` only changes the standalone constructor probe slightly (`1130 -> 1135` records, `1090 -> 1095` rendered items) and does not materially change the repeated wrong-art output. Current evidence therefore points to unresolved art/runtime binding as the primary blocker, not a missed constructor-tail decode.
|
||||
|
||||
## Art Binding Rule
|
||||
|
||||
`v0` uses one explicit diagnostic binding rule:
|
||||
|
||||
- `typeWord -> bundle slot index`
|
||||
|
||||
That means the sorted bundle list from `post_audio_region_04` is indexed directly by `typeWord` when the slot exists.
|
||||
|
||||
This rule is explicitly not claimed as final executable truth. Current docs and Ghidra evidence show the final art path goes through the late `DAT_800758d8` art bank plus downstream state-script/runtime selection. The slot rule remains useful only as a clean standalone negative-evidence probe.
|
||||
|
||||
For the generic family band now dominating `LSET1/L0` failures (`0x003e`, `0x0042`, `0x0044`, `0x0045`, `0x004f`, `0x0059`, `0x005b`), repeated wrong art is now understood as both a binding failure and a semantic-layer failure: the exporter is currently visualizing constructor-fed runtime object seeds as though they were the final visible world.
|
||||
|
||||
The chosen bundle and clamped frame index, plus binding-diversity metrics, are preserved in output metadata so failures stay auditable.
|
||||
|
||||
There is now one opt-in experimental binding mode for current map-0 research:
|
||||
|
||||
- `runtime-map0-masked-proxy`
|
||||
|
||||
That mode reads `.cache/runtime-map0-correlation.json`, takes the live `headerWord11` field from the current map-0 type rows, masks it to `0x0fffff`, and remaps a type only when that masked value lands within a small tolerance of a scanned raw bundle offset with matching kind/mode. All non-matching types still fall back to the raw slot rule.
|
||||
|
||||
This is still a probe rule, not claimed final executable truth. It exists to turn the new RAM-backed map-0 correlation into a small, auditable extraction improvement without pretending the full late `DAT_800758d8` bank parse is solved.
|
||||
|
||||
When debug labels are enabled for a map render, labels now identify unique rendered resources rather than per-instance placements. The stable label key is currently `bundle offset + clamped frame + resolved palette`. Validation atlas sheets still use progressive cell indices.
|
||||
|
||||
## Rendering Rule
|
||||
|
||||
For each record:
|
||||
|
||||
- compute `screenX` and `screenY` from the documented projection basis
|
||||
- select frame index from `selectorWord`, clamped to available frames
|
||||
- place sprite top-left at:
|
||||
- `screenX - originX`
|
||||
- `screenY - originY`
|
||||
|
||||
Current draw order is conservative:
|
||||
|
||||
- main-visible before special-visible
|
||||
- then ascending `screenY`
|
||||
- then ascending `screenX`
|
||||
|
||||
This is a probe approximation. The later graph-based stage-1 ordering still belongs to a future pass.
|
||||
|
||||
The rendered PNG uses a neutral opaque background by default so probe silhouettes are legible without relying on transparency.
|
||||
|
||||
## Color Rule
|
||||
|
||||
`v0` emits grayscale art from raw pixel indices.
|
||||
|
||||
Reason:
|
||||
|
||||
- bundle frame decode is already well constrained
|
||||
- full CLUT parity is not
|
||||
- grayscale preserves shape/variant evidence without pretending the palette problem is solved
|
||||
|
||||
Transparent index `0` stays transparent.
|
||||
|
||||
## CLI
|
||||
|
||||
Primary command:
|
||||
|
||||
```powershell
|
||||
node src/cli.js --source LSET1/L0.WDL
|
||||
```
|
||||
|
||||
Supported options:
|
||||
|
||||
- `--source <relative-path>`
|
||||
- `--wdl <absolute-or-relative-file>`
|
||||
- `--disc-root <path>`
|
||||
- `--binding-mode <raw|runtime-map0-masked-proxy>`
|
||||
- `--map-source <auto|combined|layered|constructors|roots|region01|region00>`
|
||||
- `--out-name <stem>`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
`v0` is successful if it can:
|
||||
|
||||
- parse a raw `LSET*.WDL`
|
||||
- recover the loader-sized section view alongside the region carve
|
||||
- scan bundles directly from `post_audio_region_04`
|
||||
- decode at least one frame from raw data
|
||||
- extract a stable constructor-placement record set from `post_audio_section_00`
|
||||
- write extracted sprite PNGs into `.cache`
|
||||
- write a readable diagnostic probe PNG into `.output`
|
||||
|
||||
## Planned Follow-Ups
|
||||
|
||||
- replace diagnostic slot binding with a direct parser for the late header-only `DAT_800758d8` override stream and bundle match path
|
||||
- recover the exact raw on-disk encoding of the earlier built-resource art-install blob so the two late art feeds are modeled separately instead of flattened into one guessed bank
|
||||
- identify and parse the separate static-world or subordinate level substrate that complements the constructor-fed live-object lane, instead of treating section-0 constructor placements as the whole map
|
||||
- add palette/CLUT reconstruction
|
||||
- add stage-1 graph ordering recovery
|
||||
- compare the probe scene against fixed live samples such as `map 104` without reintroducing viewer-side donor assumptions
|
||||
22
psx-map-exporter/package-lock.json
generated
Normal file
22
psx-map-exporter/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "psx-map-exporter",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psx-map-exporter",
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
psx-map-exporter/package.json
Normal file
11
psx-map-exporter/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "psx-map-exporter",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"export": "node src/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
}
|
||||
475
psx-map-exporter/src/bundles.js
Normal file
475
psx-map-exporter/src/bundles.js
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import { PNG } from 'pngjs';
|
||||
|
||||
function readU32LE(buffer, offset) {
|
||||
return buffer.readUInt32LE(offset);
|
||||
}
|
||||
|
||||
function readU16LE(buffer, offset) {
|
||||
return buffer.readUInt16LE(offset);
|
||||
}
|
||||
|
||||
function rowByteWidth(width, mode) {
|
||||
return mode === 2 ? Math.ceil(width / 2) : width;
|
||||
}
|
||||
|
||||
function psx555ToRgba(color) {
|
||||
const red = (color & 0x1f) * 255 / 31;
|
||||
const green = ((color >> 5) & 0x1f) * 255 / 31;
|
||||
const blue = ((color >> 10) & 0x1f) * 255 / 31;
|
||||
const alpha = (color & 0x7fff) === 0 ? 0 : 255;
|
||||
return {
|
||||
red: Math.round(red),
|
||||
green: Math.round(green),
|
||||
blue: Math.round(blue),
|
||||
alpha,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractPaletteSets(buffer, headerWords) {
|
||||
if (!Array.isArray(headerWords) || headerWords.length < 4) {
|
||||
return { palettes16: [], palettes256: [] };
|
||||
}
|
||||
|
||||
const paletteOffset = headerWords[2];
|
||||
const paletteSize = headerWords[3];
|
||||
if (paletteSize !== 0x1000 || paletteOffset < 0 || paletteOffset + paletteSize > buffer.length) {
|
||||
return { palettes16: [], palettes256: [] };
|
||||
}
|
||||
|
||||
const blob = buffer.subarray(paletteOffset, paletteOffset + paletteSize);
|
||||
const palettes16 = [];
|
||||
const palettes256 = [];
|
||||
|
||||
for (let offset = 0; offset + 0x20 <= blob.length; offset += 0x20) {
|
||||
const palette = [];
|
||||
for (let entry = 0; entry < 16; entry += 1) {
|
||||
palette.push(readU16LE(blob, offset + entry * 2));
|
||||
}
|
||||
palettes16.push(palette);
|
||||
}
|
||||
|
||||
for (let offset = 0; offset + 0x200 <= blob.length; offset += 0x200) {
|
||||
const palette = [];
|
||||
for (let entry = 0; entry < 256; entry += 1) {
|
||||
palette.push(readU16LE(blob, offset + entry * 2));
|
||||
}
|
||||
palettes256.push(palette);
|
||||
}
|
||||
|
||||
return { palettes16, palettes256 };
|
||||
}
|
||||
|
||||
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
|
||||
if (!Array.isArray(palettes16) || palettes16.length < 16) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(startIndex) || startIndex < 0 || startIndex + 16 > palettes16.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = [];
|
||||
for (let paletteIndex = startIndex; paletteIndex < startIndex + 16; paletteIndex += 1) {
|
||||
const clut = palettes16[paletteIndex];
|
||||
if (!Array.isArray(clut) || clut.length < 16) {
|
||||
return null;
|
||||
}
|
||||
palette.push(...clut.slice(0, 16));
|
||||
}
|
||||
return palette.length === 256 ? palette : null;
|
||||
}
|
||||
|
||||
export function buildMode1RuntimePalette(palettes16) {
|
||||
return buildMode1RuntimePaletteForIndex(palettes16, 0);
|
||||
}
|
||||
|
||||
export function extractMode1PaletteFromGpuRamDump(buffer, row = 0xf0, startX = 0) {
|
||||
const vramWidthWords = 1024;
|
||||
const vramHeight = 512;
|
||||
const expectedSize = vramWidthWords * vramHeight * 2;
|
||||
|
||||
if (!buffer || buffer.length < expectedSize) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(row) || row < 0 || row >= vramHeight) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(startX) || startX < 0 || startX + 256 > vramWidthWords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = [];
|
||||
const rowStart = (row * vramWidthWords * 2) + (startX * 2);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
palette.push(readU16LE(buffer, rowStart + index * 2));
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
export function buildMode1PaletteBank(palettes16) {
|
||||
if (!Array.isArray(palettes16) || palettes16.length < 16) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paletteBank = [];
|
||||
for (let startIndex = 0; startIndex < palettes16.length; startIndex += 1) {
|
||||
const palette = buildMode1RuntimePaletteForIndex(palettes16, startIndex);
|
||||
if (palette?.length === 256) {
|
||||
paletteBank[startIndex] = palette;
|
||||
}
|
||||
}
|
||||
return paletteBank;
|
||||
}
|
||||
|
||||
export function choosePalette(palettes16, frames, mode) {
|
||||
if (mode !== 2 || !Array.isArray(palettes16) || palettes16.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedIndices = new Set();
|
||||
for (const frame of frames) {
|
||||
const rawPixels = frame.rawPixels;
|
||||
if (!rawPixels) {
|
||||
continue;
|
||||
}
|
||||
for (const value of rawPixels) {
|
||||
usedIndices.add(value & 0x0f);
|
||||
usedIndices.add((value >> 4) & 0x0f);
|
||||
}
|
||||
}
|
||||
|
||||
usedIndices.delete(0);
|
||||
if (usedIndices.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let bestIndex = null;
|
||||
let bestScore = -1;
|
||||
for (let paletteIndex = 0; paletteIndex < palettes16.length; paletteIndex += 1) {
|
||||
const palette = palettes16[paletteIndex];
|
||||
const distinct = new Set();
|
||||
for (const index of usedIndices) {
|
||||
distinct.add((palette[index] ?? 0) & 0x7fff);
|
||||
}
|
||||
|
||||
let channelSpread = 0;
|
||||
let nonZeroCount = 0;
|
||||
for (const value of distinct) {
|
||||
if (value === 0) {
|
||||
continue;
|
||||
}
|
||||
nonZeroCount += 1;
|
||||
const rgba = psx555ToRgba(value);
|
||||
channelSpread += rgba.red + rgba.green + rgba.blue;
|
||||
}
|
||||
|
||||
if (nonZeroCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = nonZeroCount * 100000 + channelSpread;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = paletteIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function isValidBundleHeader(buffer, offset) {
|
||||
if (offset + 0x34 > buffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const kind = readU32LE(buffer, offset + 0x00);
|
||||
const width = readU32LE(buffer, offset + 0x08);
|
||||
const height = readU32LE(buffer, offset + 0x0c);
|
||||
const mode = readU32LE(buffer, offset + 0x10);
|
||||
const dataOffset = readU32LE(buffer, offset + 0x1c);
|
||||
const frameCount = readU32LE(buffer, offset + 0x20);
|
||||
const frameTableOffset = readU32LE(buffer, offset + 0x24);
|
||||
|
||||
if (kind !== 4 && kind !== 5) {
|
||||
return false;
|
||||
}
|
||||
if (width === 0 || height === 0 || width > 512 || height > 512) {
|
||||
return false;
|
||||
}
|
||||
if (mode !== 1 && mode !== 2) {
|
||||
return false;
|
||||
}
|
||||
if (frameCount === 0 || frameCount > 256) {
|
||||
return false;
|
||||
}
|
||||
if (offset + dataOffset > buffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recordTableSize = frameCount * 20;
|
||||
if (dataOffset < 0x34 + recordTableSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frameTableOffset !== 0x34) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scanSpriteBundles(region) {
|
||||
const bundles = [];
|
||||
const seenRanges = [];
|
||||
|
||||
for (let offset = 0; offset + 0x34 <= region.buffer.length; offset += 4) {
|
||||
if (!isValidBundleHeader(region.buffer, offset)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenRanges.some(([start, end]) => offset >= start && offset < end)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = readU32LE(region.buffer, offset + 0x00);
|
||||
const width = readU32LE(region.buffer, offset + 0x08);
|
||||
const height = readU32LE(region.buffer, offset + 0x0c);
|
||||
const mode = readU32LE(region.buffer, offset + 0x10);
|
||||
const paletteIndex = readU32LE(region.buffer, offset + 0x14);
|
||||
const dataOffset = readU32LE(region.buffer, offset + 0x1c);
|
||||
const frameCount = readU32LE(region.buffer, offset + 0x20);
|
||||
const frameTableOffset = 0x34;
|
||||
|
||||
if (paletteIndex > 127) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const frames = [];
|
||||
let valid = true;
|
||||
|
||||
for (let index = 0; index < frameCount; index += 1) {
|
||||
const entryOffset = offset + frameTableOffset + (index * 20);
|
||||
const flags = readU32LE(region.buffer, entryOffset + 0x00);
|
||||
const relativeDataOffset = readU32LE(region.buffer, entryOffset + 0x08);
|
||||
const frameWidth = readU16LE(region.buffer, entryOffset + 0x0c);
|
||||
const frameHeight = readU16LE(region.buffer, entryOffset + 0x0e);
|
||||
const originX = readU16LE(region.buffer, entryOffset + 0x10);
|
||||
const originY = readU16LE(region.buffer, entryOffset + 0x12);
|
||||
|
||||
const dataStart = offset + dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
|
||||
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
|
||||
if (
|
||||
frameWidth === 0 ||
|
||||
frameHeight === 0 ||
|
||||
frameWidth > 512 ||
|
||||
frameHeight > 512 ||
|
||||
dataStart >= region.buffer.length ||
|
||||
(((flags & 1) === 0) && (dataStart + rawSize > region.buffer.length))
|
||||
) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let consumed;
|
||||
if ((flags & 1) === 1) {
|
||||
const decoded = decodeRleRows(region.buffer, dataStart, frameWidth, frameHeight, mode);
|
||||
if (!decoded) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
consumed = decoded.consumed;
|
||||
} else {
|
||||
consumed = rawSize;
|
||||
}
|
||||
|
||||
frames.push({
|
||||
index,
|
||||
consumed,
|
||||
relativeDataOffset,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
originX,
|
||||
originY,
|
||||
flags,
|
||||
dataStart,
|
||||
absoluteDataStart: region.offset + dataStart,
|
||||
});
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRanges.push([offset, offset + dataOffset]);
|
||||
|
||||
bundles.push({
|
||||
slot: bundles.length,
|
||||
offsetInRegion: offset,
|
||||
absoluteOffset: region.offset + offset,
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
paletteIndex,
|
||||
dataOffset,
|
||||
frameCount,
|
||||
frameTableOffset,
|
||||
frames,
|
||||
});
|
||||
}
|
||||
|
||||
return bundles;
|
||||
}
|
||||
|
||||
function decodeRleRows(buffer, start, width, height, mode) {
|
||||
const expectedSize = rowByteWidth(width, mode) * height;
|
||||
const output = [];
|
||||
let cursor = start;
|
||||
let rows = 0;
|
||||
|
||||
while (rows < height) {
|
||||
if (cursor >= buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controlByte = buffer[cursor];
|
||||
cursor += 1;
|
||||
const signedControl = controlByte < 0x80 ? controlByte : controlByte - 0x100;
|
||||
|
||||
if (signedControl === 0) {
|
||||
rows += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signedControl < 0) {
|
||||
const count = controlByte & 0x7f;
|
||||
if (cursor + count > buffer.length) {
|
||||
return null;
|
||||
}
|
||||
output.push(...buffer.subarray(cursor, cursor + count));
|
||||
cursor += count;
|
||||
} else {
|
||||
if (cursor >= buffer.length) {
|
||||
return null;
|
||||
}
|
||||
const value = buffer[cursor];
|
||||
cursor += 1;
|
||||
for (let repeat = 0; repeat < signedControl; repeat += 1) {
|
||||
output.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (output.length > expectedSize) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (output.length !== expectedSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
rawPixels: Buffer.from(output),
|
||||
consumed: cursor - start,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeIndexedPixels(rawPixels, width, height, mode) {
|
||||
if (mode === 2) {
|
||||
const indexed = Buffer.alloc(width * height, 0);
|
||||
let source = 0;
|
||||
let target = 0;
|
||||
const rowBytes = rowByteWidth(width, mode);
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const rowEnd = Math.min(source + rowBytes, rawPixels.length);
|
||||
while (source < rowEnd && target < indexed.length) {
|
||||
const value = rawPixels[source];
|
||||
source += 1;
|
||||
|
||||
indexed[target] = value & 0x0f;
|
||||
target += 1;
|
||||
if (target < indexed.length) {
|
||||
indexed[target] = (value >> 4) & 0x0f;
|
||||
target += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return indexed;
|
||||
}
|
||||
|
||||
return Buffer.from(rawPixels.subarray(0, width * height));
|
||||
}
|
||||
|
||||
function indexedToGrayscaleRgba(pixels, mode) {
|
||||
const rgba = Buffer.alloc(pixels.length * 4, 0);
|
||||
for (let index = 0; index < pixels.length; index += 1) {
|
||||
const sourceValue = pixels[index];
|
||||
const value = mode === 2 ? sourceValue * 17 : sourceValue;
|
||||
const out = index * 4;
|
||||
rgba[out + 0] = value;
|
||||
rgba[out + 1] = value;
|
||||
rgba[out + 2] = value;
|
||||
rgba[out + 3] = value === 0 ? 0 : 255;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
function indexedToColorRgba(pixels, palette) {
|
||||
const rgba = Buffer.alloc(pixels.length * 4, 0);
|
||||
for (let index = 0; index < pixels.length; index += 1) {
|
||||
const paletteIndex = pixels[index];
|
||||
const color = palette[paletteIndex] ?? 0;
|
||||
const converted = psx555ToRgba(color);
|
||||
const out = index * 4;
|
||||
rgba[out + 0] = converted.red;
|
||||
rgba[out + 1] = converted.green;
|
||||
rgba[out + 2] = converted.blue;
|
||||
rgba[out + 3] = paletteIndex === 0 ? 0 : converted.alpha;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
export function decodeBundleFrame(region, bundle, frameIndex, palette = null) {
|
||||
const frame = bundle.frames[Math.max(0, Math.min(frameIndex, bundle.frames.length - 1))];
|
||||
const rawSize = rowByteWidth(frame.width, bundle.mode) * frame.height;
|
||||
|
||||
let rawPixels;
|
||||
let consumed;
|
||||
if ((frame.flags & 1) === 1) {
|
||||
const decoded = decodeRleRows(region.buffer, frame.dataStart, frame.width, frame.height, bundle.mode);
|
||||
if (!decoded) {
|
||||
throw new Error(`Failed to decode RLE frame at 0x${frame.absoluteDataStart.toString(16)}`);
|
||||
}
|
||||
rawPixels = decoded.rawPixels;
|
||||
consumed = decoded.consumed;
|
||||
} else {
|
||||
if (frame.dataStart + rawSize > region.buffer.length) {
|
||||
throw new Error(`Frame overruns bundle region at 0x${frame.absoluteDataStart.toString(16)}`);
|
||||
}
|
||||
rawPixels = region.buffer.subarray(frame.dataStart, frame.dataStart + rawSize);
|
||||
consumed = rawSize;
|
||||
}
|
||||
|
||||
const indexedPixels = decodeIndexedPixels(rawPixels, frame.width, frame.height, bundle.mode);
|
||||
const rgba = Array.isArray(palette)
|
||||
? indexedToColorRgba(indexedPixels, palette)
|
||||
: indexedToGrayscaleRgba(indexedPixels, bundle.mode);
|
||||
|
||||
return {
|
||||
...frame,
|
||||
consumed,
|
||||
rawPixels: Buffer.from(rawPixels),
|
||||
indexedPixels,
|
||||
requestedFrameIndex: frameIndex,
|
||||
clampedFrameIndex: frame.index,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodePng(rgba, width, height) {
|
||||
const png = new PNG({ width, height });
|
||||
png.data = Buffer.from(rgba);
|
||||
return PNG.sync.write(png);
|
||||
}
|
||||
168
psx-map-exporter/src/cli.js
Normal file
168
psx-map-exporter/src/cli.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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',
|
||||
bindingMode: 'raw',
|
||||
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 === '--binding-mode') {
|
||||
options.bindingMode = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--scene-scope') {
|
||||
options.sceneScope = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--gpu-ram-dump') {
|
||||
options.gpuRamDump = path.resolve(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--validation-bundles') {
|
||||
options.validationBundles = String(next)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--out-name') {
|
||||
options.outName = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--debug-labels') {
|
||||
options.debugLabels = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage: node src/cli.js (--source LSET1/L0.WDL | --wdl <file>) [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --source <relative path> WDL path relative to the PSX disc root',
|
||||
' --wdl <file> Direct WDL path',
|
||||
' --disc-root <path> PSX asset root, defaults to STATIC_PSX in the sibling workspace',
|
||||
' --binding-mode <raw|runtime-map0-masked-proxy> Raw slot binding by default; optional map-0 runtime proxy uses .cache/runtime-map0-correlation.json when present',
|
||||
' --scene-scope <probe|full> Probe is supported; full is intentionally disabled until raw floor and full-map decode is recovered',
|
||||
' --gpu-ram-dump <path> PSX GPU RAM dump used for live mode-1 palette extraction',
|
||||
' --validation-bundles <csv> Comma-separated bundle absolute offsets (hex or decimal) for bundle palette-sweep validation sheets',
|
||||
' --map-source <auto|combined|layered|constructors|roots|region01|region00>',
|
||||
' --out-name <stem> Override the output stem',
|
||||
' --debug-labels Write an additional labeled scene PNG for item identification',
|
||||
'',
|
||||
'Notes:',
|
||||
' auto now prefers a layered probe that combines constructor placements with root-dispatch rows.',
|
||||
' combined/layered explicitly renders both authored section-0 lanes together.',
|
||||
' roots/region00 keeps the smaller section-0 root-dispatch probe for comparison.',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.source && !options.wdl) {
|
||||
printHelp();
|
||||
throw new Error('Either --source or --wdl is required.');
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const wdlPath = options.wdl
|
||||
? path.resolve(options.wdl)
|
||||
: path.resolve(options.discRoot, options.source);
|
||||
|
||||
const result = await exportMap({
|
||||
projectRoot,
|
||||
wdlPath,
|
||||
sourceRelPath: options.source,
|
||||
mapSource: options.mapSource,
|
||||
bindingMode: options.bindingMode,
|
||||
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;
|
||||
});
|
||||
1220
psx-map-exporter/src/export-map.js
Normal file
1220
psx-map-exporter/src/export-map.js
Normal file
File diff suppressed because it is too large
Load diff
169
psx-map-exporter/src/render.js
Normal file
169
psx-map-exporter/src/render.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { encodePng } from './bundles.js';
|
||||
|
||||
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
|
||||
|
||||
const GLYPHS = {
|
||||
'0': ['111', '101', '101', '101', '111'],
|
||||
'1': ['010', '110', '010', '010', '111'],
|
||||
'2': ['111', '001', '111', '100', '111'],
|
||||
'3': ['111', '001', '111', '001', '111'],
|
||||
'4': ['101', '101', '111', '001', '001'],
|
||||
'5': ['111', '100', '111', '001', '111'],
|
||||
'6': ['111', '100', '111', '101', '111'],
|
||||
'7': ['111', '001', '001', '001', '001'],
|
||||
'8': ['111', '101', '111', '101', '111'],
|
||||
'9': ['111', '101', '111', '001', '111'],
|
||||
};
|
||||
|
||||
function clearCanvas(width, height, background = null) {
|
||||
const canvas = Buffer.alloc(width * height * 4, 0);
|
||||
if (!background) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
fillRect(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
background.red ?? 0,
|
||||
background.green ?? 0,
|
||||
background.blue ?? 0,
|
||||
background.alpha ?? 255,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function fillRect(canvas, canvasWidth, canvasHeight, x, y, width, height, red, green, blue, alpha) {
|
||||
const startX = Math.max(0, x);
|
||||
const startY = Math.max(0, y);
|
||||
const endX = Math.min(canvasWidth, x + width);
|
||||
const endY = Math.min(canvasHeight, y + height);
|
||||
|
||||
for (let drawY = startY; drawY < endY; drawY += 1) {
|
||||
for (let drawX = startX; drawX < endX; drawX += 1) {
|
||||
const target = ((drawY * canvasWidth) + drawX) * 4;
|
||||
canvas[target + 0] = red;
|
||||
canvas[target + 1] = green;
|
||||
canvas[target + 2] = blue;
|
||||
canvas[target + 3] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawGlyph(canvas, canvasWidth, canvasHeight, glyph, x, y, red, green, blue, alpha) {
|
||||
const rows = GLYPHS[glyph];
|
||||
if (!rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
const row = rows[rowIndex];
|
||||
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
||||
if (row[columnIndex] !== '1') {
|
||||
continue;
|
||||
}
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x + columnIndex, y + rowIndex, 1, 1, red, green, blue, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawLabel(canvas, canvasWidth, canvasHeight, text, x, y) {
|
||||
const label = String(text);
|
||||
const glyphWidth = 3;
|
||||
const glyphHeight = 5;
|
||||
const spacing = 1;
|
||||
const boxWidth = (label.length * (glyphWidth + spacing)) - spacing + 2;
|
||||
const boxHeight = glyphHeight + 2;
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x, y, boxWidth, boxHeight, 0, 0, 0, 220);
|
||||
|
||||
let cursorX = x + 1;
|
||||
for (const glyph of label) {
|
||||
drawGlyph(canvas, canvasWidth, canvasHeight, glyph, cursorX, y + 1, 255, 255, 0, 255);
|
||||
cursorX += glyphWidth + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
|
||||
for (let y = 0; y < sprite.height; y += 1) {
|
||||
const canvasY = dstY + y;
|
||||
if (canvasY < 0 || canvasY >= canvasHeight) {
|
||||
continue;
|
||||
}
|
||||
for (let x = 0; x < sprite.width; x += 1) {
|
||||
const canvasX = dstX + x;
|
||||
if (canvasX < 0 || canvasX >= canvasWidth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceX = flipped ? (sprite.width - 1 - x) : x;
|
||||
const source = ((y * sprite.width) + sourceX) * 4;
|
||||
const alpha = sprite.rgba[source + 3];
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = ((canvasY * canvasWidth) + canvasX) * 4;
|
||||
canvas[target + 0] = sprite.rgba[source + 0];
|
||||
canvas[target + 1] = sprite.rgba[source + 1];
|
||||
canvas[target + 2] = sprite.rgba[source + 2];
|
||||
canvas[target + 3] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMap(items, options = {}) {
|
||||
if (items.length === 0) {
|
||||
throw new Error('No renderable scene items were produced.');
|
||||
}
|
||||
|
||||
const bounds = items.reduce(
|
||||
(state, item) => ({
|
||||
minX: Math.min(state.minX, item.drawX),
|
||||
minY: Math.min(state.minY, item.drawY),
|
||||
maxX: Math.max(state.maxX, item.drawX + item.width),
|
||||
maxY: Math.max(state.maxY, item.drawY + item.height),
|
||||
}),
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
||||
);
|
||||
|
||||
const padding = 16;
|
||||
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
|
||||
const height = Math.max(1, (bounds.maxY - bounds.minY) + (padding * 2));
|
||||
const canvas = clearCanvas(width, height, options.background ?? DEFAULT_BACKGROUND);
|
||||
|
||||
for (const item of items) {
|
||||
blitRgba(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
item.sprite,
|
||||
item.drawX - bounds.minX + padding,
|
||||
item.drawY - bounds.minY + padding,
|
||||
Boolean(item.flipped)
|
||||
);
|
||||
}
|
||||
|
||||
if (options.drawLabels) {
|
||||
for (const item of items) {
|
||||
drawLabel(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
item.labelId ?? item.id,
|
||||
item.drawX - bounds.minX + padding,
|
||||
item.drawY - bounds.minY + padding
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bounds,
|
||||
png: encodePng(canvas, width, height),
|
||||
};
|
||||
}
|
||||
449
psx-map-exporter/src/wdl.js
Normal file
449
psx-map-exporter/src/wdl.js
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import path from 'node:path';
|
||||
|
||||
function readU32LE(buffer, offset) {
|
||||
return buffer.readUInt32LE(offset);
|
||||
}
|
||||
|
||||
function readU16LE(buffer, offset) {
|
||||
return buffer.readUInt16LE(offset);
|
||||
}
|
||||
|
||||
const ALLOWED_LANE_WORDS = new Set([0x20, 0x22, 0x30]);
|
||||
const PSX_SCREEN_SCALE = 2;
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export function parseLsetWdl(buffer, filePath) {
|
||||
if (buffer.length < 0x34) {
|
||||
throw new Error(`File too small for LSET header: ${filePath}`);
|
||||
}
|
||||
|
||||
const headerSize = readU32LE(buffer, 0);
|
||||
if (headerSize < 0x34 || headerSize % 4 !== 0 || headerSize > buffer.length) {
|
||||
throw new Error(`Unexpected header size 0x${headerSize.toString(16)} in ${filePath}`);
|
||||
}
|
||||
|
||||
const headerWords = [];
|
||||
for (let offset = 0; offset < headerSize; offset += 4) {
|
||||
headerWords.push(readU32LE(buffer, offset));
|
||||
}
|
||||
|
||||
const audioSize = readU32LE(buffer, 4);
|
||||
const postAudioStart = headerSize + audioSize;
|
||||
const sectionSizes = [];
|
||||
for (let offset = 0x08; offset < 0x38 && offset + 4 <= buffer.length; offset += 4) {
|
||||
sectionSizes.push(readU32LE(buffer, offset));
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
let sectionCursor = postAudioStart;
|
||||
for (let index = 0; index < sectionSizes.length; index += 1) {
|
||||
const size = sectionSizes[index];
|
||||
if (size <= 0 || sectionCursor + size > buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
sections.push({
|
||||
name: `post_audio_section_${String(index).padStart(2, '0')}`,
|
||||
offset: sectionCursor,
|
||||
size,
|
||||
buffer: buffer.subarray(sectionCursor, sectionCursor + size),
|
||||
});
|
||||
sectionCursor += size;
|
||||
}
|
||||
|
||||
const boundaryCandidates = uniqueSorted(
|
||||
headerWords
|
||||
.slice(2)
|
||||
.filter((value) => value > postAudioStart && value < buffer.length)
|
||||
);
|
||||
|
||||
if (boundaryCandidates.length < 4) {
|
||||
throw new Error(
|
||||
`Expected at least 4 post-audio boundaries, found ${boundaryCandidates.length} in ${filePath}`
|
||||
);
|
||||
}
|
||||
|
||||
const selectedBoundaries = boundaryCandidates.slice(0, 4);
|
||||
const regions = [];
|
||||
const regionStarts = [postAudioStart, ...selectedBoundaries];
|
||||
const regionEnds = [...selectedBoundaries, buffer.length];
|
||||
|
||||
regions.push({
|
||||
name: 'audio_or_spu_blob',
|
||||
offset: headerSize,
|
||||
size: audioSize,
|
||||
buffer: buffer.subarray(headerSize, postAudioStart),
|
||||
});
|
||||
|
||||
for (let index = 0; index < regionStarts.length; index += 1) {
|
||||
const offset = regionStarts[index];
|
||||
const end = regionEnds[index];
|
||||
regions.push({
|
||||
name: `post_audio_region_${String(index).padStart(2, '0')}`,
|
||||
offset,
|
||||
size: end - offset,
|
||||
buffer: buffer.subarray(offset, end),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileName: path.basename(filePath),
|
||||
buffer,
|
||||
headerSize,
|
||||
audioSize,
|
||||
postAudioStart,
|
||||
headerWords,
|
||||
sectionSizes,
|
||||
sections,
|
||||
boundaryCandidates,
|
||||
regions,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlausibleRecord(words) {
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
if (typeWord < 0x20 || typeWord > 0x1ff) {
|
||||
return false;
|
||||
}
|
||||
if ((xWord | yWord | zWord) === 0) {
|
||||
return false;
|
||||
}
|
||||
if (laneWord === 0 || laneWord > 0x1fff) {
|
||||
return false;
|
||||
}
|
||||
if (selectorWord > 0x03ff) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isStructuredCandidate(words) {
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
if (typeWord >= 0x200) {
|
||||
return false;
|
||||
}
|
||||
if (xWord === 0 && yWord === 0) {
|
||||
return false;
|
||||
}
|
||||
if (xWord >= 0x4000 || yWord >= 0x4000) {
|
||||
return false;
|
||||
}
|
||||
if (zWord > 0x20 || selectorWord > 0x04) {
|
||||
return false;
|
||||
}
|
||||
if (!ALLOWED_LANE_WORDS.has(laneWord)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecord(words, source, offset, rawWords = words) {
|
||||
if (!isPlausibleRecord(words)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
const screenX = (yWord - xWord) * PSX_SCREEN_SCALE;
|
||||
const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE;
|
||||
|
||||
return {
|
||||
index: -1,
|
||||
source,
|
||||
offset,
|
||||
words,
|
||||
rawWords,
|
||||
typeWord,
|
||||
xWord,
|
||||
yWord,
|
||||
zWord,
|
||||
selectorWord,
|
||||
laneWord,
|
||||
screenX,
|
||||
screenY,
|
||||
sourceFamily: null,
|
||||
sourceRole: null,
|
||||
recordSide: null,
|
||||
rowIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRecord(buffer, offset, source) {
|
||||
const words = [];
|
||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
||||
words.push(readU16LE(buffer, offset + cursor));
|
||||
}
|
||||
|
||||
return buildRecord(words, source, offset, words);
|
||||
}
|
||||
|
||||
function makeAsciiPreview(buffer, length = 64) {
|
||||
const slice = buffer.subarray(0, Math.min(length, buffer.length));
|
||||
let text = '';
|
||||
for (const value of slice) {
|
||||
text += value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : '.';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function scanOffsetTableCandidates(buffer, maxBase = 0x200) {
|
||||
const candidates = [];
|
||||
const limit = Math.min(maxBase, Math.max(0, buffer.length - 8));
|
||||
|
||||
for (let base = 0; base <= limit; base += 2) {
|
||||
const count = readU16LE(buffer, base);
|
||||
if (count <= 0 || count >= 0x200) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableEnd = base + 2 + count * 2;
|
||||
if (tableEnd > buffer.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let previous = -1;
|
||||
let monotonic = true;
|
||||
const firstOffsets = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = readU16LE(buffer, base + 2 + index * 2);
|
||||
if (index < 8) {
|
||||
firstOffsets.push(offset);
|
||||
}
|
||||
if (offset < previous || offset >= buffer.length) {
|
||||
monotonic = false;
|
||||
break;
|
||||
}
|
||||
previous = offset;
|
||||
}
|
||||
|
||||
if (monotonic) {
|
||||
candidates.push({ base, count, firstOffsets });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function scanPlausible12ByteRecordStarts(buffer, maxBase = 0x200) {
|
||||
const starts = [];
|
||||
const limit = Math.min(maxBase, Math.max(0, buffer.length - 12));
|
||||
|
||||
for (let base = 0; base <= limit; base += 2) {
|
||||
const words = [];
|
||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
||||
words.push(readU16LE(buffer, base + cursor));
|
||||
}
|
||||
if (isPlausibleRecord(words)) {
|
||||
starts.push({ base, words });
|
||||
}
|
||||
}
|
||||
|
||||
return starts;
|
||||
}
|
||||
|
||||
function buildPreviewRows(buffer, rowWordWidth = 8, rowCount = 24) {
|
||||
const rows = [];
|
||||
const maxRows = Math.min(rowCount, Math.floor(buffer.length / (rowWordWidth * 2)));
|
||||
|
||||
for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
|
||||
const offset = rowIndex * rowWordWidth * 2;
|
||||
const words = [];
|
||||
for (let wordIndex = 0; wordIndex < rowWordWidth; wordIndex += 1) {
|
||||
words.push(readU16LE(buffer, offset + wordIndex * 2));
|
||||
}
|
||||
|
||||
const bytes = buffer.subarray(offset, offset + rowWordWidth * 2);
|
||||
rows.push({
|
||||
rowIndex,
|
||||
offset,
|
||||
words,
|
||||
ascii: makeAsciiPreview(bytes, bytes.length),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function summarizeRegion02(region) {
|
||||
const firstU32 = [];
|
||||
const firstU16 = [];
|
||||
|
||||
for (let offset = 0; offset + 4 <= region.buffer.length && firstU32.length < 8; offset += 4) {
|
||||
firstU32.push(readU32LE(region.buffer, offset));
|
||||
}
|
||||
for (let offset = 0; offset + 2 <= region.buffer.length && firstU16.length < 16; offset += 2) {
|
||||
firstU16.push(readU16LE(region.buffer, offset));
|
||||
}
|
||||
|
||||
const offsetTableCandidates = scanOffsetTableCandidates(region.buffer);
|
||||
const plausible12ByteRecordStarts = scanPlausible12ByteRecordStarts(region.buffer);
|
||||
|
||||
return {
|
||||
offset: region.offset,
|
||||
size: region.size,
|
||||
firstU32,
|
||||
firstU16,
|
||||
asciiPreview: makeAsciiPreview(region.buffer, 96),
|
||||
previewRows: buildPreviewRows(region.buffer),
|
||||
offsetTableCandidates: offsetTableCandidates.slice(0, 16),
|
||||
plausible12ByteRecordStarts: plausible12ByteRecordStarts.slice(0, 16),
|
||||
note: offsetTableCandidates.length === 0 && plausible12ByteRecordStarts.length === 0
|
||||
? 'Leading region-02 bytes do not look like a count-prefixed offset table or direct 12-byte placement rows.'
|
||||
: 'Region-02 exposes candidate structure and should be correlated against live loader-installed subordinate slices.',
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRegion00Records(region) {
|
||||
const rowCount = region.buffer.length >= 4 ? readU32LE(region.buffer, 0) : 0;
|
||||
const records = [];
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
|
||||
const rowBase = 4 + rowIndex * 24;
|
||||
if (rowBase + 24 > region.buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const rowWords = [];
|
||||
for (let wordIndex = 0; wordIndex < 12; wordIndex += 1) {
|
||||
rowWords.push(readU16LE(region.buffer, rowBase + wordIndex * 2));
|
||||
}
|
||||
|
||||
const leftRawWords = rowWords.slice(0, 6);
|
||||
const rightRawWords = rowWords.slice(6, 12);
|
||||
const leftWords = [rowWords[4], rowWords[5], rowWords[0], rowWords[1], rowWords[2], rowWords[3]];
|
||||
const rightWords = [rowWords[10], rowWords[11], rowWords[6], rowWords[7], rowWords[8], rowWords[9]];
|
||||
|
||||
for (const [recordSide, wordSet, rawWordSet, sourceByteOffset] of [
|
||||
['left', leftWords, leftRawWords, 0],
|
||||
['right', rightWords, rightRawWords, 12],
|
||||
]) {
|
||||
const record = buildRecord(wordSet, 'region00', rowBase + sourceByteOffset, rawWordSet);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
||||
record.sourceFamily = 'section0_dispatch_roots';
|
||||
record.sourceRole = 'root-dispatch';
|
||||
record.rowIndex = rowIndex;
|
||||
record.recordSide = recordSide;
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
records.forEach((record, index) => {
|
||||
record.index = index;
|
||||
record.absoluteOffset = region.offset + record.offset;
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'region00',
|
||||
recordStartOffset: 4,
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function detectStructured12ByteStream(buffer) {
|
||||
let bestCandidate = null;
|
||||
|
||||
for (let headerOffset = 0; headerOffset + 16 <= buffer.length; headerOffset += 4) {
|
||||
const count = readU32LE(buffer, headerOffset);
|
||||
const recordStartOffset = headerOffset + 4;
|
||||
const maxPossibleCount = Math.floor((buffer.length - recordStartOffset) / 12);
|
||||
|
||||
if (count === 0 || count > maxPossibleCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prefixStructuredCount = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const recordOffset = recordStartOffset + index * 12;
|
||||
const words = [];
|
||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
||||
words.push(readU16LE(buffer, recordOffset + cursor));
|
||||
}
|
||||
if (!isStructuredCandidate(words)) {
|
||||
break;
|
||||
}
|
||||
prefixStructuredCount += 1;
|
||||
}
|
||||
|
||||
if (prefixStructuredCount < 16) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!bestCandidate ||
|
||||
prefixStructuredCount > bestCandidate.prefixStructuredCount ||
|
||||
(prefixStructuredCount === bestCandidate.prefixStructuredCount && headerOffset < bestCandidate.headerOffset)
|
||||
) {
|
||||
bestCandidate = {
|
||||
headerOffset,
|
||||
recordStartOffset,
|
||||
count,
|
||||
prefixStructuredCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate;
|
||||
}
|
||||
|
||||
export function parseRegion01Records(region) {
|
||||
const records = [];
|
||||
const stream = detectStructured12ByteStream(region.buffer);
|
||||
|
||||
if (stream) {
|
||||
for (let index = 0; index < stream.count; index += 1) {
|
||||
const recordOffset = stream.recordStartOffset + index * 12;
|
||||
if (recordOffset + 12 > region.buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const record = decodeRecord(region.buffer, recordOffset, 'region01');
|
||||
if (!record || !isStructuredCandidate(record.words)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
record.sourceFamily = 'section0_constructor_placements';
|
||||
record.sourceRole = 'constructor-placement';
|
||||
record.rowIndex = index;
|
||||
record.recordSide = null;
|
||||
records.push(record);
|
||||
}
|
||||
} else {
|
||||
for (let rowOffset = 0; rowOffset + 24 <= region.buffer.length; rowOffset += 24) {
|
||||
const left = decodeRecord(region.buffer, rowOffset, 'region01-left');
|
||||
const right = decodeRecord(region.buffer, rowOffset + 12, 'region01-right');
|
||||
if (left && isStructuredCandidate(left.words)) {
|
||||
left.sourceFamily = 'section0_constructor_placements';
|
||||
left.sourceRole = 'constructor-placement';
|
||||
left.rowIndex = Math.floor(rowOffset / 24);
|
||||
left.recordSide = 'left';
|
||||
records.push(left);
|
||||
}
|
||||
if (right && isStructuredCandidate(right.words)) {
|
||||
right.sourceFamily = 'section0_constructor_placements';
|
||||
right.sourceRole = 'constructor-placement';
|
||||
right.rowIndex = Math.floor(rowOffset / 24);
|
||||
right.recordSide = 'right';
|
||||
records.push(right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records.forEach((record, index) => {
|
||||
record.index = index;
|
||||
record.absoluteOffset = region.offset + record.offset;
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'region01',
|
||||
recordStartOffset: stream?.recordStartOffset ?? 0,
|
||||
streamHeaderOffset: stream?.headerOffset ?? null,
|
||||
streamRecordCount: stream?.count ?? null,
|
||||
streamStructuredPrefixCount: stream?.prefixStructuredCount ?? null,
|
||||
records,
|
||||
};
|
||||
}
|
||||
79
psx-map-exporter/tmp_correlate_runtime_map0.mjs
Normal file
79
psx-map-exporter/tmp_correlate_runtime_map0.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const projectRoot = path.resolve('..');
|
||||
const runtimePath = path.resolve(projectRoot, 'psx-map-exporter', '.cache', 'runtime-snapshot-report.json');
|
||||
const recordsPath = path.resolve(projectRoot, 'psx-map-exporter', '.cache', 'ctor_stream_probe', 'records.json');
|
||||
|
||||
const runtime = JSON.parse(fs.readFileSync(runtimePath, 'utf8'));
|
||||
const records = JSON.parse(fs.readFileSync(recordsPath, 'utf8'));
|
||||
|
||||
const visibleObjects = runtime.sampleVisibleObjects ?? [];
|
||||
const visibleTypeMap = new Map();
|
||||
for (const object of visibleObjects) {
|
||||
const key = object.typeId;
|
||||
const entry = visibleTypeMap.get(key) ?? {
|
||||
typeId: key,
|
||||
count: 0,
|
||||
routeWords: new Set(),
|
||||
selectorIndexes: new Set(),
|
||||
latchedTokens: new Set(),
|
||||
artResourcePtrs: new Set(),
|
||||
authoredRecordPtrs: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
entry.routeWords.add(object.routeWord);
|
||||
entry.selectorIndexes.add(object.selectorIndex);
|
||||
entry.latchedTokens.add(object.latchedToken);
|
||||
entry.artResourcePtrs.add(object.artResourcePtr);
|
||||
if (entry.authoredRecordPtrs.length < 8) {
|
||||
entry.authoredRecordPtrs.push(object.authoredRecordPtr);
|
||||
}
|
||||
visibleTypeMap.set(key, entry);
|
||||
}
|
||||
|
||||
const typeRows = new Map((runtime.sampleTypeRows ?? []).map((row) => [row.typeId, row]));
|
||||
const recordTypeCounts = new Map();
|
||||
const recordLaneByType = new Map();
|
||||
for (const record of records.records ?? []) {
|
||||
recordTypeCounts.set(record.typeWord, (recordTypeCounts.get(record.typeWord) ?? 0) + 1);
|
||||
const laneSet = recordLaneByType.get(record.typeWord) ?? new Set();
|
||||
laneSet.add(record.laneWord);
|
||||
recordLaneByType.set(record.typeWord, laneSet);
|
||||
}
|
||||
|
||||
const combined = [...new Set([...recordTypeCounts.keys(), ...visibleTypeMap.keys()])]
|
||||
.sort((left, right) => left - right)
|
||||
.map((typeId) => {
|
||||
const visible = visibleTypeMap.get(typeId);
|
||||
const row = typeRows.get(typeId) ?? null;
|
||||
return {
|
||||
typeId,
|
||||
rawRecordCount: recordTypeCounts.get(typeId) ?? 0,
|
||||
rawLaneWords: [...(recordLaneByType.get(typeId) ?? new Set())].sort((a, b) => a - b),
|
||||
visibleCount: visible?.count ?? 0,
|
||||
visibleRouteWords: visible ? [...visible.routeWords].sort((a, b) => a - b) : [],
|
||||
visibleSelectorIndexes: visible ? [...visible.selectorIndexes].sort((a, b) => a - b) : [],
|
||||
visibleLatchedTokens: visible ? [...visible.latchedTokens].sort((a, b) => a - b) : [],
|
||||
visibleArtResourcePtrs: visible ? [...visible.artResourcePtrs] : [],
|
||||
sampleAuthoredRecordPtrs: visible?.authoredRecordPtrs ?? [],
|
||||
activeHeader: row?.activeHeader ?? 0,
|
||||
builtResource: row?.builtResource ?? 0,
|
||||
headerKind: row?.headerWords?.[0] ?? null,
|
||||
headerWord3: row?.headerWords?.[3] ?? null,
|
||||
headerWord4: row?.headerWords?.[4] ?? null,
|
||||
headerWord8: row?.headerWords?.[8] ?? null,
|
||||
headerWord10: row?.headerWords?.[10] ?? null,
|
||||
headerWord11: row?.headerWords?.[11] ?? null,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.rawRecordCount !== 0 || entry.visibleCount !== 0);
|
||||
|
||||
const outputPath = path.resolve(projectRoot, 'psx-map-exporter', '.cache', 'runtime-map0-correlation.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify({
|
||||
currentMapId: runtime.currentMapId,
|
||||
visibleObjectCount: runtime.visibleObjectCount,
|
||||
combined,
|
||||
}, null, 2));
|
||||
|
||||
console.log(JSON.stringify({ outputPath }, null, 2));
|
||||
159
psx-map-exporter/tmp_dump_runtime_snapshot.mjs
Normal file
159
psx-map-exporter/tmp_dump_runtime_snapshot.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const RAM_BASE = 0x80000000;
|
||||
const TYPE_TABLE_BASE = 0x800758c4;
|
||||
const TYPE_ROW_STRIDE = 0x18;
|
||||
const MAIN_VISIBLE_LIST = 0x8006ad5c;
|
||||
const MAIN_VISIBLE_READ_INDEX = 0x80067690;
|
||||
const MAIN_VISIBLE_WRITE_INDEX = 0x800676bc;
|
||||
const CURRENT_MAP_ID = 0x80067728;
|
||||
const NEXT_MAP_ID = 0x800678d0;
|
||||
|
||||
const projectRoot = path.resolve('..');
|
||||
const ramDumpPath = path.resolve(projectRoot, 'binary', 'Crusader - No Remorse (USA) Main Memory Dump.bin');
|
||||
const ram = fs.readFileSync(ramDumpPath);
|
||||
const outputPath = path.resolve(projectRoot, 'psx-map-exporter', '.cache', 'runtime-snapshot-report.json');
|
||||
|
||||
function vaToOffset(address) {
|
||||
const offset = address - RAM_BASE;
|
||||
if (offset < 0 || offset >= ram.length) {
|
||||
throw new RangeError(`Address out of dump range: 0x${address.toString(16)}`);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function canRead(address, size = 4) {
|
||||
const offset = address - RAM_BASE;
|
||||
return offset >= 0 && offset + size <= ram.length;
|
||||
}
|
||||
|
||||
function readU32(address) {
|
||||
return ram.readUInt32LE(vaToOffset(address));
|
||||
}
|
||||
|
||||
function readS32(address) {
|
||||
return ram.readInt32LE(vaToOffset(address));
|
||||
}
|
||||
|
||||
function readU16(address) {
|
||||
return ram.readUInt16LE(vaToOffset(address));
|
||||
}
|
||||
|
||||
function readS16(address) {
|
||||
return ram.readInt16LE(vaToOffset(address));
|
||||
}
|
||||
|
||||
function readHeaderWords(address, wordCount = 10) {
|
||||
if (!canRead(address, wordCount * 4)) {
|
||||
return null;
|
||||
}
|
||||
return Array.from({ length: wordCount }, (_, index) => readU32(address + index * 4));
|
||||
}
|
||||
|
||||
function parseTypeRows(limit = 0x100) {
|
||||
const rows = [];
|
||||
for (let typeId = 0; typeId < limit; typeId += 1) {
|
||||
const rowBase = TYPE_TABLE_BASE + typeId * TYPE_ROW_STRIDE;
|
||||
const installCount = readU32(rowBase + 0x00);
|
||||
const builtResource = readU32(rowBase + 0x04);
|
||||
const stateScript = readU32(rowBase + 0x08);
|
||||
const simpleComponent = readU32(rowBase + 0x0c);
|
||||
const extents = readU32(rowBase + 0x10);
|
||||
const activeHeader = readU32(rowBase + 0x14);
|
||||
|
||||
if ((installCount | builtResource | stateScript | simpleComponent | extents | activeHeader) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headerWords = activeHeader !== 0 ? readHeaderWords(activeHeader, 12) : null;
|
||||
rows.push({
|
||||
typeId,
|
||||
rowBase,
|
||||
installCount,
|
||||
builtResource,
|
||||
stateScript,
|
||||
simpleComponent,
|
||||
extents,
|
||||
activeHeader,
|
||||
headerWords,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseVisibleObjects(limit = 256) {
|
||||
const readIndex = readU32(MAIN_VISIBLE_READ_INDEX);
|
||||
const writeIndex = readU32(MAIN_VISIBLE_WRITE_INDEX);
|
||||
const count = Math.max(0, Math.min(limit, writeIndex));
|
||||
const objects = [];
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const objectPtr = readU32(MAIN_VISIBLE_LIST + index * 4);
|
||||
if (!canRead(objectPtr, 0xa4)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
objects.push({
|
||||
index,
|
||||
objectPtr,
|
||||
typeId: readU16(objectPtr + 0x18),
|
||||
routeWord: readU16(objectPtr + 0x1c),
|
||||
stateFlags: readU16(objectPtr + 0x1e),
|
||||
screenLeft: readS16(objectPtr + 0x20),
|
||||
screenTop: readS16(objectPtr + 0x22),
|
||||
screenRight: readS16(objectPtr + 0x24),
|
||||
screenBottom: readS16(objectPtr + 0x26),
|
||||
worldX: readS32(objectPtr + 0x3c),
|
||||
worldY: readS32(objectPtr + 0x40),
|
||||
worldZ: readS32(objectPtr + 0x44),
|
||||
velocityX: readS32(objectPtr + 0x60),
|
||||
velocityY: readS32(objectPtr + 0x64),
|
||||
velocityZ: readS32(objectPtr + 0x68),
|
||||
programPtr: readU32(objectPtr + 0x08),
|
||||
artResourcePtr: readU32(objectPtr + 0x10),
|
||||
companionExtentsPtr: readU32(objectPtr + 0x84),
|
||||
stateScriptPtr: readU32(objectPtr + 0x88),
|
||||
scriptBasePtr: readU32(objectPtr + 0x8c),
|
||||
scriptReadPtr: readU32(objectPtr + 0x90),
|
||||
latchedToken: readU16(objectPtr + 0x94),
|
||||
scriptCountdown: readU16(objectPtr + 0x96),
|
||||
selectorIndex: readU16(objectPtr + 0x9e),
|
||||
authoredRecordPtr: readU32(objectPtr + 0xa0),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
readIndex,
|
||||
writeIndex,
|
||||
count,
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
const typeRows = parseTypeRows();
|
||||
const visible = parseVisibleObjects();
|
||||
const byType = new Map();
|
||||
for (const object of visible.objects) {
|
||||
byType.set(object.typeId, (byType.get(object.typeId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const summary = {
|
||||
ramDumpPath,
|
||||
ramSize: ram.length,
|
||||
currentMapId: readU32(CURRENT_MAP_ID),
|
||||
nextMapId: readU32(NEXT_MAP_ID),
|
||||
mainVisibleReadIndex: visible.readIndex,
|
||||
mainVisibleWriteIndex: visible.writeIndex,
|
||||
visibleObjectCount: visible.objects.length,
|
||||
nonZeroTypeRowCount: typeRows.length,
|
||||
visibleTypeCounts: [...byType.entries()]
|
||||
.map(([typeId, count]) => ({ typeId, count }))
|
||||
.sort((left, right) => right.count - left.count || left.typeId - right.typeId),
|
||||
sampleTypeRows: typeRows.slice(0, 64),
|
||||
sampleVisibleObjects: visible.objects.slice(0, 128),
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(summary, null, 2));
|
||||
console.log(JSON.stringify({ outputPath }, null, 2));
|
||||
91
psx-map-exporter/tmp_inspect_region00.mjs
Normal file
91
psx-map-exporter/tmp_inspect_region00.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseLsetWdl } from './src/wdl.js';
|
||||
|
||||
const wdlPath = path.resolve('..', '..', 'Crusader-Map-Viewer', 'map_renderer', 'STATIC_PSX', 'LSET1', 'L0.WDL');
|
||||
const buffer = fs.readFileSync(wdlPath);
|
||||
const wdl = parseLsetWdl(buffer, wdlPath);
|
||||
const section = wdl.sections.find((entry) => entry.name === 'post_audio_section_00');
|
||||
const region = wdl.regions.find((entry) => entry.name === 'post_audio_region_00');
|
||||
|
||||
function readWords(sourceBuffer, offset, wordCount = 6) {
|
||||
return Array.from({ length: wordCount }, (_, index) => sourceBuffer.readUInt16LE(offset + index * 2));
|
||||
}
|
||||
|
||||
function isStructuredCandidate(words) {
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
if (typeWord >= 0x200) {
|
||||
return false;
|
||||
}
|
||||
if (xWord === 0 && yWord === 0) {
|
||||
return false;
|
||||
}
|
||||
if (xWord >= 0x4000 || yWord >= 0x4000) {
|
||||
return false;
|
||||
}
|
||||
if (zWord > 0x20 || selectorWord > 0x04) {
|
||||
return false;
|
||||
}
|
||||
return laneWord === 0x20 || laneWord === 0x22 || laneWord === 0x30;
|
||||
}
|
||||
|
||||
function inspectCountPrefixed12ByteStreams(source) {
|
||||
const hits = [];
|
||||
|
||||
for (let offset = 0; offset + 4 + 12 <= source.buffer.length; offset += 4) {
|
||||
const count = source.buffer.readUInt32LE(offset);
|
||||
if (count === 0 || count > 0x2000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let good = 0;
|
||||
const preview = [];
|
||||
for (let index = 0; index < count && offset + 4 + (index + 1) * 12 <= source.buffer.length; index += 1) {
|
||||
const recordOffset = offset + 4 + index * 12;
|
||||
const words = readWords(source.buffer, recordOffset);
|
||||
const structured = isStructuredCandidate(words);
|
||||
if (index < 6) {
|
||||
preview.push({ index, recordOffset, words, structured });
|
||||
}
|
||||
if (!structured) {
|
||||
break;
|
||||
}
|
||||
good += 1;
|
||||
}
|
||||
|
||||
if (good >= 16) {
|
||||
hits.push({
|
||||
offset,
|
||||
absoluteOffset: source.offset + offset,
|
||||
count,
|
||||
good,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
const sectionHits = inspectCountPrefixed12ByteStreams(section);
|
||||
const regionHits = inspectCountPrefixed12ByteStreams(region);
|
||||
const preview = [];
|
||||
for (let offset = 0; offset < 0x90; offset += 12) {
|
||||
const words = readWords(section.buffer, offset);
|
||||
preview.push({
|
||||
offset,
|
||||
absoluteOffset: section.offset + offset,
|
||||
words,
|
||||
structured: isStructuredCandidate(words),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
sectionOffset: section.offset,
|
||||
sectionSize: section.size,
|
||||
regionOffset: region.offset,
|
||||
regionSize: region.size,
|
||||
sectionHits,
|
||||
regionHits,
|
||||
preview,
|
||||
}, null, 2));
|
||||
Loading…
Add table
Add a link
Reference in a new issue