PSX Research

This commit is contained in:
Marco 2026-04-13 16:50:28 +02:00
commit 8d34c85c22
13 changed files with 1720 additions and 8 deletions

View 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.

File diff suppressed because it is too large Load diff

View file

@ -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`.

View file

@ -184,6 +184,14 @@ For the generic family band now dominating `LSET1/L0` failures (`0x003e`, `0x004
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
@ -231,6 +239,7 @@ 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>`

View file

@ -24,6 +24,7 @@ function parseArgs(argv) {
'Crusader - No Remorse (USA) GPU RAM 2.bin'
),
mapSource: 'auto',
bindingMode: 'raw',
sceneScope: 'probe',
validationBundles: [],
};
@ -52,6 +53,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (arg === '--binding-mode') {
options.bindingMode = next;
index += 1;
continue;
}
if (arg === '--scene-scope') {
options.sceneScope = next;
index += 1;
@ -97,6 +103,7 @@ function printHelp() {
' --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',
@ -133,6 +140,7 @@ async function main() {
wdlPath,
sourceRelPath: options.source,
mapSource: options.mapSource,
bindingMode: options.bindingMode,
sceneScope: options.sceneScope,
gpuRamDumpPath: options.gpuRamDump,
validationBundles: options.validationBundles,

View file

@ -119,6 +119,132 @@ function chooseBundleForType(bundles, typeWord) {
return null;
}
async function loadJsonIfExists(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch {
return null;
}
}
function buildRuntimeMap0MaskedBindings(correlation, bundles, tolerance = 0x200) {
const byType = new Map();
const diagnostics = [];
const provisionalBindings = [];
if (!correlation || correlation.currentMapId !== 0 || !Array.isArray(correlation.combined)) {
return { byType, diagnostics };
}
for (const row of correlation.combined) {
if (!row || !Number.isInteger(row.typeId) || !Number.isInteger(row.rawRecordCount) || row.rawRecordCount <= 0) {
continue;
}
if ((row.headerKind !== 4 && row.headerKind !== 5) || (row.headerWord3 !== 1 && row.headerWord3 !== 2)) {
continue;
}
if (!Number.isInteger(row.headerWord11) || row.headerWord11 === 0) {
continue;
}
const maskedAbsoluteOffset = row.headerWord11 & 0x0fffff;
let bestMatch = null;
for (const bundle of bundles) {
if (bundle.kind !== row.headerKind || bundle.mode !== row.headerWord3) {
continue;
}
const absoluteOffsetDelta = Math.abs(bundle.absoluteOffset - maskedAbsoluteOffset);
if (!bestMatch || absoluteOffsetDelta < bestMatch.absoluteOffsetDelta) {
bestMatch = {
bundle,
absoluteOffsetDelta,
};
}
}
if (!bestMatch || bestMatch.absoluteOffsetDelta > tolerance) {
continue;
}
const binding = {
typeId: row.typeId,
bundle: bestMatch.bundle,
mappingSource: 'runtime-map0-masked-header-offset-proxy',
runtimeBinding: {
maskedAbsoluteOffset,
absoluteOffsetDelta: bestMatch.absoluteOffsetDelta,
headerKind: row.headerKind,
headerMode: row.headerWord3,
headerWord4: row.headerWord4,
headerWord8: row.headerWord8,
headerWord10: row.headerWord10,
visibleCount: row.visibleCount ?? 0,
rawRecordCount: row.rawRecordCount,
},
};
provisionalBindings.push(binding);
}
const bundleBuckets = new Map();
for (const binding of provisionalBindings) {
const bucket = bundleBuckets.get(binding.bundle.absoluteOffset) ?? [];
bucket.push(binding);
bundleBuckets.set(binding.bundle.absoluteOffset, bucket);
}
for (const binding of provisionalBindings) {
const bucket = bundleBuckets.get(binding.bundle.absoluteOffset) ?? [];
const isCrowdedLargeSingleFrameBundle =
bucket.length >= 4 &&
binding.bundle.frameCount === 1 &&
binding.bundle.width >= 96 &&
binding.bundle.height >= 48;
if (isCrowdedLargeSingleFrameBundle) {
continue;
}
byType.set(binding.typeId, binding);
diagnostics.push({
typeId: binding.typeId,
bundleSlot: binding.bundle.slot,
bundleAbsoluteOffset: binding.bundle.absoluteOffset,
maskedAbsoluteOffset: binding.runtimeBinding.maskedAbsoluteOffset,
absoluteOffsetDelta: binding.runtimeBinding.absoluteOffsetDelta,
headerKind: binding.runtimeBinding.headerKind,
headerMode: binding.runtimeBinding.headerMode,
visibleCount: binding.runtimeBinding.visibleCount,
rawRecordCount: binding.runtimeBinding.rawRecordCount,
crowdedBundleTypeCount: bucket.length,
});
}
diagnostics.sort((left, right) => left.typeId - right.typeId);
return { byType, diagnostics };
}
function chooseBundleBinding(record, bundles, options = {}) {
if (options.bindingMode === 'runtime-map0-masked-proxy') {
const runtimeBinding = options.runtimeMap0Bindings?.get(record.typeWord) ?? null;
if (runtimeBinding?.bundle) {
return runtimeBinding;
}
}
const rawTypeBundle = chooseBundleForType(bundles, record.typeWord);
if (!rawTypeBundle) {
return null;
}
return {
bundle: rawTypeBundle,
mappingSource: 'raw-typeword-bundle-slot-diagnostic',
runtimeBinding: null,
};
}
function describeMapScope(recordSet) {
if (recordSet.source === 'combined') {
return 'layered object-projection probe from both loader-sized section-0 constructor-placement and root-dispatch records in post_audio_section_00';
@ -738,9 +864,9 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
continue;
}
const rawTypeBundle = chooseBundleForType(bundles, record.typeWord);
const bundle = rawTypeBundle;
if (!bundle) {
const binding = chooseBundleBinding(record, bundles, options);
const bundle = binding?.bundle ?? null;
if (!bundle || !binding) {
continue;
}
if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) {
@ -788,9 +914,13 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
defaultPaletteIndex: bundle.defaultPaletteIndex ?? null,
resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null,
paletteFormula: bundle.paletteFormula ?? null,
mappingSource: 'raw-typeword-bundle-slot-diagnostic',
mappingSource: binding.mappingSource,
templateTypeId: null,
donorTypeId: null,
runtimeBindingMaskedAbsoluteOffset: binding.runtimeBinding?.maskedAbsoluteOffset ?? null,
runtimeBindingOffsetDelta: binding.runtimeBinding?.absoluteOffsetDelta ?? null,
runtimeBindingVisibleCount: binding.runtimeBinding?.visibleCount ?? null,
runtimeBindingRawRecordCount: binding.runtimeBinding?.rawRecordCount ?? null,
rawWords: record.rawWords ?? record.words,
flipped: (record.laneWord & 0x0002) !== 0,
width: sprite.width,
@ -903,10 +1033,17 @@ export async function exportMap(options) {
{ mode1RuntimePalette }
);
const runtimeMap0Correlation = options.bindingMode === 'runtime-map0-masked-proxy'
? await loadJsonIfExists(path.join(cacheBaseRoot, 'runtime-map0-correlation.json'))
: null;
const runtimeMap0BindingResult = buildRuntimeMap0MaskedBindings(runtimeMap0Correlation, bundles);
const { items: sceneItems, skippedRecords } = await buildSceneItems(region04, recordSet.records, bundles, {
paletteSets,
bindingMode: options.bindingMode,
mode1RuntimePalette,
mode1PaletteBank,
runtimeMap0Bindings: runtimeMap0BindingResult.byType,
});
const bindingDiversity = summarizeBindingDiversity(sceneItems);
const authoredLayerSummary = summarizeAuthoredLayers(recordSet.records);
@ -949,7 +1086,11 @@ export async function exportMap(options) {
bundleCount: bundles.length,
bundleSource: bundles[0]?.bundleSource ?? 'none',
gpuRamDumpPath: options.gpuRamDumpPath ?? null,
artBindingSource: 'raw-typeword-bundle-slot-diagnostic',
artBindingSource: options.bindingMode === 'runtime-map0-masked-proxy'
? 'runtime-map0-masked-header-offset-proxy-with-raw-fallback'
: 'raw-typeword-bundle-slot-diagnostic',
runtimeMap0BindingTypeCount: runtimeMap0BindingResult.diagnostics.length,
runtimeMap0BindingTypes: runtimeMap0BindingResult.diagnostics,
activeHeaderOverrideCandidateCount: activeHeaderOverrideCandidates.length,
bestActiveHeaderOverrideCandidate: activeHeaderOverrideCandidates[0]
? {
@ -975,7 +1116,9 @@ export async function exportMap(options) {
'The root-dispatch lane is now rendered as a second authored layer, but runtime-driven control mutations and dynamic effect spawns are still out of scope.',
'Known non-map-facing portrait/talk root types `0x0042` and `0x0049`, plus the known portrait bundle `0x000D84F4`, are excluded from probe rendering.',
'Viewer-derived sidecars, donor mappings, and cached scene references are intentionally disabled in this standalone exporter.',
'Current bundle selection is still diagnostic-only until the late DAT_800758d8 art bank is parsed directly.',
options.bindingMode === 'runtime-map0-masked-proxy'
? 'Current bundle selection uses an experimental map-0 runtime masked-offset proxy where live headerWord11 low bits land near a scanned raw bundle; all non-matching types still fall back to the raw slot diagnostic.'
: 'Current bundle selection is still diagnostic-only until the late DAT_800758d8 art bank is parsed directly.',
'No floor or tile layer is decoded directly yet; post_audio_region_02 and the decompressed level-state lane remain unresolved.',
]),
'Palette routing remains partly heuristic when authored token and default bank evidence are both absent.',
@ -1023,6 +1166,10 @@ export async function exportMap(options) {
mappingSource: item.mappingSource,
templateTypeId: item.templateTypeId,
donorTypeId: item.donorTypeId,
runtimeBindingMaskedAbsoluteOffset: item.runtimeBindingMaskedAbsoluteOffset,
runtimeBindingOffsetDelta: item.runtimeBindingOffsetDelta,
runtimeBindingVisibleCount: item.runtimeBindingVisibleCount,
runtimeBindingRawRecordCount: item.runtimeBindingRawRecordCount,
token06HighByte: item.paletteDiagnostics?.token06HighByte ?? null,
token0cHighByte: item.paletteDiagnostics?.token0cHighByte ?? null,
expectedPaletteToken: item.paletteDiagnostics?.expectedPaletteToken ?? null,

View 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));

View 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));