13 KiB
PSX Art-Binding Recovery
Scope
- Active target: retail PlayStation
SLUS_002.68feeding the renderer-local cache pipeline inCrusader-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-remorseset carried58,262fallback placeholders versus only1,714bundle-mapped items. - The fallback concentration was overwhelmingly in
section0_constructor_placements(94.5%of fallback items), not in the smallersection0_dispatch_rootsfamily. - The hottest unresolved types were
0x0042,0x0041,0x0047,0x0045,0x003f,0x004b,0x0046,0x0044,0x0048, and0x004a. - Those types already appeared in the exported
DAT_800758d8art-template layer in many maps, but almost all of their rows wereblockSize = 0and therefore had no direct payload dwords to match against bundle offsets. - The neighboring state banks were still populated:
DAT_800758cccarried valid script tables for the same types, whileDAT_800758d0stayed empty for this family andDAT_800758d4remained 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:
- Use the direct non-zero
DAT_800758d8row when a type has a real local template payload and bundle match. - If the type lives in the unresolved zero-block constructor-placement family, look for a same-map donor type whose
DAT_800758ccscript blob is identical and whoseDAT_800758d8row resolves to a real bundle. - 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:
mappingSourcenow records whether the art came from a direct template, acc-signature-donor:xxxx, or ageneric-family-donor:xxxxpath.templateTypeIdanddonorTypeIdare preserved in exportedmapSourcerows.- The unresolved type still uses its own
DAT_800758ccselector-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,262fallback /1,714bundle-mapped to37,054fallback /22,922bundle-mapped.
Representative maps:
map 0:1078 / 111->340 / 849map 9:664 / 111->197 / 578map 43:707 / 97->196 / 608map 64:1194 / 161->736 / 619map 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,038fallback /34,938bundle-mapped.
Representative maps after the extended pass:
map 0:66fallback /1123bundle-mappedmap 9:42fallback /733bundle-mappedmap 43:9fallback /795bundle-mappedmap 64:160fallback /1195bundle-mappedmap 104:866fallback /136bundle-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
u5lane before the older coarse type default is consulted. - The exporter still prefers direct local
DAT_800758d8payload matches first, but when a type bucket is mixed-role or otherwise unresolved it now resolves each cohort separately instead of collapsing the wholemap:typebucket into one donor or one placeholder. - Focused validation on
map 104now rebuilds as1002real-art items with0fallback items,1atlas, and136shape definitions. ScenesourceCountsare now52section0_dispatch_roots-artplus950section0_constructor_placements-art. - The scene JSON also now preserves
mappingSourceand optionalartCohorton eachmapSourcerow, 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-*oremergency-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,038fallback items remain across62maps. - 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, and0x0063. 0x0042remains the single largest unresolved type even after the heuristic pass.map 104is the most obvious current outlier: it still sits at866fallback items versus only136bundle-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 1bundle header palette index+0x14as 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
16adjacent16-colorCLUTs flattened into one 256-entry palette. That is the rule now encoded inmap_renderer/src/lib/psx-cache.jsformode 1bundles. - 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 2stays on the earlier per-bundle/per-usage palette-index path. This lock-in applies specifically to the currentmode 1family 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 indexas a fallback rule.
Static Export Follow-Up
- The processed PSX catalog already carried
62maps during this pass, so the "single map" symptom was not a cache-build enumeration failure. - The immediate export-side blocker was config:
psx-remorsewas excluded from static export even though the prebuilt catalog/build-manager path already supports multi-map PSX scenes. - The renderer config now includes
psx-remorsein 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-12): Wall-Family Split Failure Mode
- Scope for this focused pass: live
SLUS_002.68around 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) andpsx_object_create_compound_record(0x80024eec) read the per-type active art header fromDAT_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+0x9eand script cursorobj+0x90), thenpsx_object_advance_state_script(0x80025d68) latches live frame tokenobj+0x94from 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 fromobj+0x94, and submitters read the same token inpsx_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 clarifyingDAT_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 consumesobj+0x94but 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, and0x80044e9c, with CLUT table symbols at0x800a9f48and0x800a9f66. 0x800a9f48remainspsx_clut_table_by_resource_bankand is both load-populated (level_palette_upload_cluts) and draw-consumed.0x800a9f66remainspsx_clut_override_table_by_palette_tokenand 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 forwardsobj_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
0behaves as "no override": with only low flag bits present, submitters fall through to default bank CLUT lookup. psx_image_table_submit_frameuses token as a key intopsx_clut_override_table_by_palette_token[token]when override is active.psx_sprite_resource_submit_framehas two override lanes based on resource format field (resource+4):- format
== 2: token keyspsx_clut_override_table_by_palette_token[token]. - format
!= 2: token is used as a row key intopsx_clut_table_by_resource_bank(token << 4halfword index).
- format
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.
Next Steps
- Trace the remaining high-volume band
0x0055..0x0063in Ghidra with the same question used for0x0042: why doesDAT_800758d8stay zero-sized while visible art still exists at runtime? - Use
map 104as the primary regression target and dump its remaining fallback type/state/lane distribution before doing any broader heuristic expansion. - 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.
- Keep the
DAT_800758d4work on the bounds side unless a family-specific caller proves otherwise; this pass did not reopen that conclusion.