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

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