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

38 KiB

PSX Art-Binding Recovery

Scope

  • Active target: retail PlayStation SLUS_002.68 feeding the renderer-local cache pipeline in Crusader-Map-Viewer/map_renderer.
  • Goal of this pass: stop treating the current unreadable PSX output as a renderer-only problem and measure whether placeholders are mainly caused by extraction-time art binding failures.
  • This note records the first pragmatic recovery pass that replaces large placeholder bands with real bundle-backed art while keeping the result auditable in exported scene metadata.

Root Cause Summary

  • The current unreadable output was primarily an extraction-side problem, not a front-end draw bug.
  • Before this pass, the built .cache/scene-cache/psx-remorse set carried 58,262 fallback placeholders versus only 1,714 bundle-mapped items.
  • The fallback concentration was overwhelmingly in section0_constructor_placements (94.5% of fallback items), not in the smaller section0_dispatch_roots family.
  • The hottest unresolved types were 0x0042, 0x0041, 0x0047, 0x0045, 0x003f, 0x004b, 0x0046, 0x0044, 0x0048, and 0x004a.
  • Those types already appeared in the exported DAT_800758d8 art-template layer in many maps, but almost all of their rows were blockSize = 0 and therefore had no direct payload dwords to match against bundle offsets.
  • The neighboring state banks were still populated: DAT_800758cc carried valid script tables for the same types, while DAT_800758d0 stayed empty for this family and DAT_800758d4 remained on the runtime-bounds side.
  • That combination strongly suggests inherited or aliased presentation for these constructor-placement families rather than a literal absence of art.

Implemented Recovery Rule

The cache builder now resolves PSX art in three stages instead of dropping directly from "no direct DAT_800758d8 payload" to a placeholder:

  1. Use the direct non-zero DAT_800758d8 row when a type has a real local template payload and bundle match.
  2. If the type lives in the unresolved zero-block constructor-placement family, look for a same-map donor type whose DAT_800758cc script blob is identical and whose DAT_800758d8 row resolves to a real bundle.
  3. If no exact script-signature donor exists, fall back to the nearest resolved same-map donor inside the current constructor-placement family band (0x003e..0x0064).

The exporter keeps this explicit instead of pretending the rule is fully solved:

  • mappingSource now records whether the art came from a direct template, a cc-signature-donor:xxxx, or a generic-family-donor:xxxx path.
  • templateTypeId and donorTypeId are preserved in exported mapSource rows.
  • The unresolved type still uses its own DAT_800758cc selector-to-frame map; only the bundle source is borrowed.

Measured Effect

Two measured rebuilds mattered in this pass.

First donor pass

  • Added donor reuse for the original generic family band.
  • Totals moved from 58,262 fallback / 1,714 bundle-mapped to 37,054 fallback / 22,922 bundle-mapped.

Representative maps:

  • map 0: 1078 / 111 -> 340 / 849
  • map 9: 664 / 111 -> 197 / 578
  • map 43: 707 / 97 -> 196 / 608
  • map 64: 1194 / 161 -> 736 / 619
  • map 104: 909 / 93 -> 894 / 108

Extended donor band

  • Extended the same heuristic through the next zero-block constructor-placement band up to 0x0064.
  • Final measured totals after rebuild: 25,038 fallback / 34,938 bundle-mapped.

Representative maps after the extended pass:

  • map 0: 66 fallback / 1123 bundle-mapped
  • map 9: 42 fallback / 733 bundle-mapped
  • map 43: 9 fallback / 795 bundle-mapped
  • map 64: 160 fallback / 1195 bundle-mapped
  • map 104: 866 fallback / 136 bundle-mapped

So the first practical conclusion is straightforward: the exporter was the main blocker for most maps, and a constrained donor heuristic can replace a large fraction of placeholders with real graphics without touching the viewer runtime.

No-Placeholder Cohort Pass

  • A second follow-up pass replaced synthetic fallback atlas generation with cohort-aware donor resolution keyed by authored family plus raw u5 lane before the older coarse type default is consulted.
  • The exporter still prefers direct local DAT_800758d8 payload matches first, but when a type bucket is mixed-role or otherwise unresolved it now resolves each cohort separately instead of collapsing the whole map:type bucket into one donor or one placeholder.
  • Focused validation on map 104 now rebuilds as 1002 real-art items with 0 fallback items, 1 atlas, and 136 shape definitions. Scene sourceCounts are now 52 section0_dispatch_roots-art plus 950 section0_constructor_placements-art.
  • The scene JSON also now preserves mappingSource and optional artCohort on each mapSource row, so provisional cohort and emergency donor matches stay auditable after placeholders are removed.
  • This is still not the final executable-proof binding rule. The focused cache is now placeholder-free, but many rows still resolve through cohort-* or emergency-global-donor:* provenance and should be treated as provisional PSX art recovery rather than as final family/resource closure.

Remaining Unresolved Mass

  • The cache is still not fully closed. 25,038 fallback items remain across 62 maps.
  • The remaining fallback distribution is still concentrated in section0_constructor_placements.
  • Current top fallback-heavy types after the donor pass are 0x0042, 0x005a, 0x005b, 0x005c, 0x0047, 0x0059, 0x005d, 0x005e, 0x0062, and 0x0063.
  • 0x0042 remains the single largest unresolved type even after the heuristic pass.
  • map 104 is the most obvious current outlier: it still sits at 866 fallback items versus only 136 bundle-mapped items, so the next recovery pass should treat it as the best stress case rather than relying only on the now-mostly-readable early maps.

Those counts are now historical rather than current for the focused map 104 export. They remain useful as the baseline that motivated the cohort pass, but the immediate blocker has shifted from “remove placeholders” to “replace provisional donor provenance with tighter executable-backed cohort/resource rules.”

Practical Interpretation

  • The donor heuristic is good enough to prove that many placeholders were caused by missing extraction-time inheritance logic.
  • It is not strong enough to count as the final executable-backed rule for the unresolved families.
  • The remaining gap still sits where the earlier Ghidra work already pointed: somewhere between the constructor-side art/resource creation lane and the live post-spawn state/resource/frame reselection path.

Palette Lock-In Rule

  • The PSX exporter must not treat mode 1 bundle header palette index +0x14 as the rendered 256-color selector.
  • The current dump-grounded rule is narrower and stronger: the known-good visible family decodes against one contiguous 256-entry lookup table equivalent to live VRAM row 0xF0, x=0.
  • In extractor terms, that behaves like the first 16 adjacent 16-color CLUTs flattened into one 256-entry palette. That is the rule now encoded in map_renderer/src/lib/psx-cache.js for mode 1 bundles.
  • The old bundle-header palette index is still preserved in exported scene metadata as defaultPaletteIndex, but it is diagnostic provenance, not the primary rendered selector.
  • mode 2 stays on the earlier per-bundle/per-usage palette-index path. This lock-in applies specifically to the current mode 1 family proven by the VRAM dump.
  • If a future pass proves a wider runtime CLUT-row formula, update this note and the exporter together in one change. Do not silently reintroduce mode 1 -> bundle header palette index as a fallback rule.

Static Export Follow-Up

  • The processed PSX catalog already carried 62 maps during this pass, so the "single map" symptom was not a cache-build enumeration failure.
  • 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).
  • Constructor-side bind is now explicit and consistent across this family: both psx_object_create_simple_record (0x800249f4) and psx_object_create_compound_record (0x80024eec) read the per-type active art header from DAT_800758d8[type] before any lane-orientation updates.
  • That means cross-family visual collapse is unlikely to be caused by a missing pre-constructor resource-bank split in this band; divergence happens after bind.
  • Post-bind state/frame path is also explicit: psx_object_select_state_script (0x800260e8) installs selector state (obj+0x9e and script cursor obj+0x90), then psx_object_advance_state_script (0x80025d68) latches live frame token obj+0x94 from the script stream.
  • Draw submission consumes that latched token, not raw authored selector: stage-1 geometry (0x80040d44) and stage-2 geometry (0x80040f78) both query frame geometry from obj+0x94, and submitters read the same token in psx_draw_main_visible_object (0x80041458) / psx_draw_special_visible_queue (0x80041144).
  • The stage-1/stage-2 route split matters materially: main-visible injects authored palette token only for type bands >=0x003e, while special-visible does not. If exporter cohorts collapse route outcome, families can appear as repeated art with wrong face/palette behavior.
  • Practical exporter implication: the strongest missing discriminator is runtime route+latch state (effective selector/latch outcome), not a new type-level resource bank key.

Conservative live-artifact updates applied in Ghidra for this pass:

  • 0x80046038: comment clarifying DAT_800758d8[type] install timing and post-bind divergence expectation.
  • 0x80026100: comment clarifying pre-latch selector install and risk of authored-u4-only grouping.
  • 0x80041554: comment clarifying main-visible-only authored palette-token lane for >=0x003e.
  • 0x80040f88: comment clarifying stage-2 queue still consumes obj+0x94 but has different route semantics.

Live MCP Follow-Up (2026-04-12): CLUT Override Routing Closure

  • Scope for this focused pass: world-object draw path and submitters at 0x80041458, 0x80041144, 0x80044bdc, and 0x80044e9c, with CLUT table symbols at 0x800a9f48 and 0x800a9f66.
  • 0x800a9f48 remains psx_clut_table_by_resource_bank and is both load-populated (level_palette_upload_cluts) and draw-consumed.
  • 0x800a9f66 remains psx_clut_override_table_by_palette_token and is draw-consumed by both submitters.
  • Main-visible (0x80041458) injects authored high-byte palette token into submit flags for type bands >=0x003e; special-visible (0x80041144) does not inject authored token and only forwards obj_flags & 0x0002.
  • Submitter override gate is explicit in both submitters: override is used only when (submit_flags & 0xfffffff0) != 0.
  • For this world-object path, token 0 behaves as "no override": with only low flag bits present, submitters fall through to default bank CLUT lookup.
  • psx_image_table_submit_frame uses token as a key into psx_clut_override_table_by_palette_token[token] when override is active.
  • psx_sprite_resource_submit_frame has two override lanes based on resource format field (resource+4):
    • format == 2: token keys psx_clut_override_table_by_palette_token[token].
    • format != 2: token is used as a row key into psx_clut_table_by_resource_bank (token << 4 halfword index).

Exporter-facing implication from executable evidence:

  • The current flattening bug matches a real semantic split in executable code: authored token injection is lane-dependent (main-visible only) and CLUT resolution is submitter/resource-format dependent.
  • A single flattened token->CLUT mapping in exporter code will miscolor mixed cohorts where route lane or submitter/resource format differs.

Conservative live-artifact updates applied in Ghidra for this pass:

  • 0x800415b0: comment clarifying main-visible token injection source and band split.
  • 0x800412d0: comment clarifying special-visible non-injection behavior.
  • 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.