Crusader_Decomp/psx-map-exporter/src/export-map.js
2026-04-18 16:34:35 +02:00

1631 lines
62 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import {
buildMode1PaletteBank,
buildMode1RuntimePalette,
choosePalette,
extractMode1PaletteFromGpuRamDump,
extractPaletteSets,
} from './bundles.js';
import { decodeBundleFrame, encodePng, scanSpriteBundles } from './bundles.js';
import { buildOverrideBundleBindings, parseOverrideBank } from './override-bank.js';
import { renderMap } from './render.js';
import { parseCtorPlacementsBlock, parseDispatchRootsBlock, parseLoaderLayout, parseLsetWdl, parseRegion00Records, parseRegion01Records, summarizeRegion02 } from './wdl.js';
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
function sanitizeStem(name) {
return name.replace(/[^a-z0-9._-]+/gi, '_');
}
async function ensureDirectory(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
async function resetDirectory(dirPath) {
await fs.rm(dirPath, { recursive: true, force: true });
await fs.mkdir(dirPath, { recursive: true });
}
async function loadGpuDumpMode1Palette(gpuRamDumpPath) {
if (!gpuRamDumpPath) {
return null;
}
try {
const buffer = await fs.readFile(gpuRamDumpPath);
return extractMode1PaletteFromGpuRamDump(buffer, 0xf0, 0x00);
} catch {
return null;
}
}
function parseBundleOffset(value) {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
return value;
}
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (/^0x/i.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 16);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
}
function chooseRecordSet(wdl, mapSource) {
const section00 = wdl.sections?.find((section) => section.name === 'post_audio_section_00') ?? null;
const region00 = wdl.regions.find((region) => region.name === 'post_audio_region_00');
const region01 = wdl.regions.find((region) => region.name === 'post_audio_region_01');
const constructorSource = section00
? {
...section00,
size: wdl.buffer.length - section00.offset,
buffer: wdl.buffer.subarray(section00.offset),
}
: region01;
const region00Source = section00 ?? region00;
const region01Source = constructorSource;
const region00Records = region00Source
? parseRegion00Records(region00Source)
: { source: 'region00', recordStartOffset: 0, records: [] };
const region01Records = region01Source
? parseRegion01Records(region01Source)
: { source: 'region01', recordStartOffset: 0, records: [] };
// Loader-faithful paths: pull ctorPlacements and dispatchRoots directly from
// the packSubranges in the 14-u32 loader header. When present these override
// the heuristic region scans because they match the executable's dispatch
// iterators 1:1.
const packSubranges = wdl.loaderLayout?.packSubranges ?? [];
const ctorBlock = packSubranges.find((block) => block.name === 'ctorPlacements');
const dispatchBlock = packSubranges.find((block) => block.name === 'dispatchRoots');
const ctorPlacementsLoader = ctorBlock
? parseCtorPlacementsBlock(ctorBlock)
: { source: 'ctorPlacements', records: [] };
const dispatchRootsLoader = dispatchBlock
? parseDispatchRootsBlock(dispatchBlock)
: { source: 'dispatchRoots', records: [] };
const constructorRecordSet = ctorPlacementsLoader.records.length > 0
? ctorPlacementsLoader
: region01Records;
const rootRecordSet = dispatchRootsLoader.records.length > 0
? dispatchRootsLoader
: region00Records;
if (mapSource === 'region00' || mapSource === 'roots') {
return rootRecordSet;
}
if (mapSource === 'region01' || mapSource === 'constructors') {
return constructorRecordSet;
}
if (mapSource === 'combined' || mapSource === 'layered' || mapSource === 'auto') {
const records = [
...constructorRecordSet.records.map((record) => ({ ...record, authoredLayer: record.authoredLayer ?? 'constructors' })),
...rootRecordSet.records.map((record) => ({ ...record, authoredLayer: record.authoredLayer ?? 'roots' })),
];
return {
source: 'combined',
recordStartOffset: 0,
records,
layers: {
constructors: constructorRecordSet.records.length,
roots: rootRecordSet.records.length,
},
loaderSources: {
ctorPlacements: {
used: ctorPlacementsLoader.records.length > 0,
count: ctorPlacementsLoader.records.length,
reportedCount: ctorPlacementsLoader.reportedCount ?? null,
},
dispatchRoots: {
used: dispatchRootsLoader.records.length > 0,
count: dispatchRootsLoader.records.length,
reportedCount: dispatchRootsLoader.reportedCount ?? null,
},
},
};
}
return constructorRecordSet.records.length >= 8 ? constructorRecordSet : rootRecordSet;
}
function chooseBundleForType(bundles, typeWord) {
if (typeWord >= 0 && typeWord < bundles.length) {
return bundles[typeWord];
}
return null;
}
async function loadJsonIfExists(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch {
return null;
}
}
async function loadOverrideBankBindingsForBuffer(buffer, layout, variant, paletteSets, options = {}) {
if (!layout) {
return {
bundles: [],
byType: new Map(),
overrideBank: null,
artInstallBank: null,
failedEntries: [],
};
}
const overrideBlock = layout.blocksByName.get('override');
const artInstallBlock = layout.blocksByName.get('artInstall');
const result = {
bundles: [],
byType: new Map(),
overrideBank: null,
artInstallBank: null,
failedEntries: [],
};
// Parse art-install first: it provides the initial per-type drawable bindings
// for both kind-4 and kind-5 resources. The later override pass then
// replaces some slots with raw-header pointers.
if (artInstallBlock && artInstallBlock.buffer) {
const bank = parseOverrideBank(artInstallBlock, {
variant,
payloadBase: 0x2718,
});
result.artInstallBank = bank;
if (bank.valid) {
const { bundles: raw, failedEntries } = buildOverrideBundleBindings(bank, { buffer });
const tagged = raw.map((bundle) => ({
...bundle,
bundleSource: variant === 'spec_a' ? 'art-install-spec-a' : 'art-install-lset',
}));
const resolved = resolveBundlePalettes(tagged, paletteSets, {
mode1RuntimePalette: options.mode1RuntimePalette,
});
for (const bundle of resolved) {
if (!bundle.sourceBuffer && Number.isInteger(bundle.absoluteOffset)) {
bundle.sourceBuffer = buffer.subarray(bundle.absoluteOffset);
}
result.byType.set(bundle.typeId, bundle);
result.bundles.push(bundle);
}
result.failedEntries.push(...failedEntries);
}
}
if (overrideBlock && overrideBlock.buffer) {
const bank = parseOverrideBank(overrideBlock, { variant, payloadBase: 8 });
result.overrideBank = bank;
if (bank.valid) {
const { bundles: raw, failedEntries } = buildOverrideBundleBindings(bank, { buffer });
const tagged = raw.map((bundle) => ({
...bundle,
bundleSource: variant === 'spec_a' ? 'override-bank-spec-a' : 'override-bank-lset',
}));
const resolved = resolveBundlePalettes(tagged, paletteSets, {
mode1RuntimePalette: options.mode1RuntimePalette,
});
for (const bundle of resolved) {
if (!bundle.sourceBuffer && Number.isInteger(bundle.absoluteOffset)) {
bundle.sourceBuffer = buffer.subarray(bundle.absoluteOffset);
}
// Late override wins over earlier art-install for the same type,
// matching the loader's bank-slot overwrite behaviour.
result.byType.set(bundle.typeId, bundle);
result.bundles.push(bundle);
}
result.failedEntries.push(...failedEntries);
}
}
return result;
}
async function loadSpecAOverrideBank(wdlPath, discRoot, paletteSets, options = {}) {
const candidatePaths = [];
if (discRoot) {
candidatePaths.push(path.join(discRoot, 'SPEC_A.WDL'));
}
const wdlDir = path.dirname(wdlPath);
candidatePaths.push(path.resolve(wdlDir, '..', 'SPEC_A.WDL'));
candidatePaths.push(path.resolve(wdlDir, 'SPEC_A.WDL'));
for (const candidate of candidatePaths) {
try {
const buffer = await fs.readFile(candidate);
const layout = parseLoaderLayout(buffer, { variant: 'spec_a' });
const result = await loadOverrideBankBindingsForBuffer(
buffer,
layout,
'spec_a',
paletteSets,
options,
);
result.sourcePath = candidate;
return result;
} catch {
// try next candidate
}
}
return { bundles: [], byType: new Map(), overrideBank: null, failedEntries: [], sourcePath: null };
}
async function loadOverrideBankBindings(lsetWdl, wdlPath, discRoot, paletteSets, options = {}) {
// SPEC_A.WDL provides the base (bundle A) override table; the map-local
// LSET*.WDL provides the override B table. The loader runs SPEC_A first
// then LSET, and for matching typeIds LSET wins because it overwrites the
// bank slot. We merge in the same order here.
const specAResult = await loadSpecAOverrideBank(wdlPath, discRoot, paletteSets, options);
const lsetLayout = lsetWdl.loaderLayout;
const lsetResult = await loadOverrideBankBindingsForBuffer(
lsetWdl.buffer,
lsetLayout,
'lset',
paletteSets,
options,
);
const bundles = [...specAResult.bundles, ...lsetResult.bundles];
const byType = new Map();
for (const bundle of specAResult.bundles) {
byType.set(bundle.typeId, bundle);
}
// LSET wins over SPEC_A for identical typeIds (matches loader behaviour).
for (const bundle of lsetResult.bundles) {
byType.set(bundle.typeId, bundle);
}
return {
bundles,
byType,
specA: specAResult,
lset: lsetResult,
};
}
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 = {}) {
// Primary path: per-type override-bank binding. The late header-only override
// block of each WDL pass (SPEC_A.WDL followed by the map-local LSET*.WDL)
// writes raw 0x58-byte active-header pointers into
// `psx_type_art_active_header_bank[type]`. Constructors and the render
// submitters read that bank at runtime, so an override-bank bundle IS the
// drawable resource bound to that type for this level.
const overrideBundle = options.overrideBundlesByType?.get(record.typeWord) ?? null;
if (overrideBundle) {
return {
bundle: overrideBundle,
mappingSource: overrideBundle.overrideVariant === 'spec_a'
? 'override-bank-spec-a'
: 'override-bank-lset',
runtimeBinding: null,
};
}
if (options.bindingMode === 'runtime-map0-masked-proxy') {
const runtimeBinding = options.runtimeMap0Bindings?.get(record.typeWord) ?? null;
if (runtimeBinding?.bundle) {
return runtimeBinding;
}
}
if (options.bindingMode === 'override-bank') {
// Strict mode: refuse to fall back to the diagnostic slot rule. Types with
// no override binding get dropped from the scene so the render reflects
// only executable-backed art.
return null;
}
// Mark high-typeWord dispatch_root entries (>= 0xAC) as non-renderable
// placeholders rather than guessing art for them. The PSX engine switches
// its palette-token sourcing at typeWord 0xAC: types below use the 16-byte
// ctor record (renderable world objects) and types at or above use the
// 24-byte dispatch-root record (spawners, triggers, NPCs, UI panels) and
// typically have no sprite art at all. Without this guard the
// `bundles[typeWord]` fallback below picks an arbitrary bundle by raw scan
// order and produces wildly wrong sprites (e.g. invisible spawners
// rendered as brick walls / turrets / teleporters). The viewer still gets
// a placeholder so these entities remain inspectable.
// Cross-ref `/memories/repo/psx-typeword-renderable-boundary-2026-04-19.md`.
if (record.sourceFamily === 'section0_dispatch_roots' && record.typeWord >= 0xAC) {
return { bundle: null, mappingSource: 'placeholder-dispatch-root-high-typeword', runtimeBinding: null, placeholder: true };
}
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';
}
if (recordSet.source === 'region01') {
return 'object-projection probe from the loader-sized section-0 constructor-placement records in post_audio_section_00';
}
if (recordSet.source === 'region00') {
return 'object-projection probe from the loader-sized section-0 root-dispatch records in post_audio_section_00';
}
return 'object-projection probe from unresolved authored record source';
}
function buildSceneInterpretation(recordSet, bindingDiversity) {
const interpretation = {
kind: 'unresolved-authored-probe',
confidence: 'medium',
warning: 'Current output is a standalone authored-record probe, not a loader-faithful visible-world reconstruction.',
evidence: [],
};
if (recordSet.source === 'combined') {
interpretation.kind = 'layered-authored-world-probe';
interpretation.warning = 'Current output combines section-0 constructor placements with the smaller root-dispatch lane, but it is still not a full visible-world reconstruction.';
interpretation.evidence.push(
'The export now draws both authored section-0 lanes that are statically recoverable offline: constructor placements and root-dispatch rows.',
'This adds a second renderable authored layer without claiming runtime-complete state, control, or dynamic effect parity.',
);
} else if (recordSet.source === 'region01') {
interpretation.kind = 'constructor-live-object-seed-lane';
interpretation.warning = 'Constructor-placement exports should currently be read as a constructor-fed live-object seed lane, not as the complete visible map or static architecture layer.';
interpretation.evidence.push(
'Source records come from loader-sized section-0 constructor placements.',
'Executable constructors only install type-indexed art/state setup at spawn; final visible resource selection continues through downstream state-script and variant logic.',
);
} else if (recordSet.source === 'region00') {
interpretation.kind = 'root-dispatch-probe-lane';
interpretation.warning = 'Root-dispatch exports are a smaller authored-record probe and are not the whole map object set.';
interpretation.evidence.push(
'Source records come from loader-sized section-0 root dispatch rows.',
);
}
if (
bindingDiversity.distinctBundleCount > 0 &&
bindingDiversity.distinctTypeCount > bindingDiversity.distinctBundleCount
) {
interpretation.evidence.push(
`Rendered type diversity (${bindingDiversity.distinctTypeCount}) exceeds bound bundle diversity (${bindingDiversity.distinctBundleCount}), which is consistent with unresolved runtime binding rather than final map-facing art.`
);
}
return interpretation;
}
function summarizeAuthoredLayers(records) {
const counts = new Map();
for (const record of records) {
const layerKey = record.authoredLayer ?? record.sourceRole ?? record.sourceFamily ?? record.source;
counts.set(layerKey, (counts.get(layerKey) ?? 0) + 1);
}
return [...counts.entries()]
.map(([layer, recordCount]) => ({ layer, recordCount }))
.sort((left, right) => right.recordCount - left.recordCount || left.layer.localeCompare(right.layer));
}
function summarizeRenderedLayers(items) {
const counts = new Map();
for (const item of items) {
const layerKey = item.authoredLayer ?? 'unknown';
counts.set(layerKey, (counts.get(layerKey) ?? 0) + 1);
}
return [...counts.entries()]
.map(([layer, renderableItemCount]) => ({ layer, renderableItemCount }))
.sort((left, right) => right.renderableItemCount - left.renderableItemCount || left.layer.localeCompare(right.layer));
}
function derivePaletteDiagnostics(record, bundle) {
// For dispatch-root records the meaningful per-record palette token lives
// in the full 24-byte raw row (12 u16 words), not the projected 6-word
// record we keep for shared scene math. Constructor placements only have
// the 12-byte form, so falling back to record.rawWords is correct there.
const rawWords = Array.isArray(record.dispatchRootRawWords)
? record.dispatchRootRawWords
: Array.isArray(record.rawWords)
? record.rawWords
: [];
const token06HighByte = rawWords.length >= 4 ? ((rawWords[3] >>> 8) & 0xff) : null;
const token0cHighByte = rawWords.length >= 7 ? ((rawWords[6] >>> 8) & 0xff) : null;
let expectedAssignmentPath = 'bundle-default';
let expectedPaletteToken = null;
if (record.typeWord >= 0x003e && record.typeWord <= 0x00ab) {
expectedAssignmentPath = 'main-visible-source-plus-0x06-high-byte-when-nonzero';
expectedPaletteToken = token06HighByte;
} else if (record.typeWord >= 0x00ac) {
expectedAssignmentPath = 'main-visible-source-plus-0x0c-high-byte-when-nonzero';
expectedPaletteToken = token0cHighByte;
}
if ((record.typeWord === 4) || ((record.laneWord & 0x0400) !== 0)) {
expectedAssignmentPath = 'special-visible-default-bank-clut-no-authored-token';
expectedPaletteToken = null;
}
if (bundle?.mode === 1 && expectedPaletteToken === 0) {
expectedAssignmentPath = 'default-bank-clut-or-bank-clut-proxy';
}
return {
rawWords,
token06HighByte,
token0cHighByte,
expectedPaletteToken,
expectedAssignmentPath,
};
}
function makeRecordLikeFromMapSourceItem(item) {
return {
rawWords: item?.rawWords ?? [],
typeWord: item?.typeId ?? item?.quality ?? 0,
laneWord: item?.lane ?? item?.mapNum ?? 0,
};
}
function summarizeBindingDiversity(items) {
const bundleCounts = new Map();
const distinctTypes = new Set();
const distinctBundleFrames = new Set();
for (const item of items) {
distinctTypes.add(item.typeWord);
bundleCounts.set(item.bundleAbsoluteOffset, (bundleCounts.get(item.bundleAbsoluteOffset) ?? 0) + 1);
distinctBundleFrames.add(`${item.bundleAbsoluteOffset}:${item.frameIndex}`);
}
const topBundleRepeats = [...bundleCounts.entries()]
.map(([bundleAbsoluteOffset, count]) => ({ bundleAbsoluteOffset, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 10);
return {
distinctTypeCount: distinctTypes.size,
distinctBundleCount: bundleCounts.size,
distinctBundleFrameCount: distinctBundleFrames.size,
topBundleRepeats,
};
}
function buildResourceKey(bundle, sprite) {
const paletteIndex = Number.isInteger(bundle.resolvedPaletteIndex) ? bundle.resolvedPaletteIndex : 'na';
return `${bundle.absoluteOffset.toString(16).padStart(8, '0')}:${sprite.clampedFrameIndex}:${paletteIndex}`;
}
function assignResourceLabelIds(items) {
const resourceKeys = [...new Set(items.map((item) => item.resourceKey))].sort((left, right) => left.localeCompare(right));
const resourceLabelIds = new Map(resourceKeys.map((resourceKey, index) => [resourceKey, index]));
return items.map((item) => ({
...item,
labelId: resourceLabelIds.get(item.resourceKey),
}));
}
function parseActiveHeaderOverrideCandidate(buffer, startOffset) {
if (startOffset + 8 > buffer.length) {
return null;
}
const count = buffer.readUInt32LE(startOffset + 0x00);
const directoryOffset = buffer.readUInt32LE(startOffset + 0x04);
if (count === 0 || count > 0x200) {
return null;
}
if (directoryOffset < 8 || directoryOffset + (count * 8) > buffer.length - startOffset) {
return null;
}
let payloadCursor = startOffset + 0x08;
const directoryBase = startOffset + directoryOffset;
let nonZeroCount = 0;
let clearCount = 0;
let size58Count = 0;
let payloadBytes = 0;
const rows = [];
for (let index = 0; index < count; index += 1) {
const rowOffset = directoryBase + (index * 8);
const activeHeaderSize = buffer.readUInt32LE(rowOffset + 0x00);
const typeId = buffer.readUInt32LE(rowOffset + 0x04);
if (typeId > 0x1ff || activeHeaderSize > 0x1000) {
return null;
}
if (activeHeaderSize === 0) {
clearCount += 1;
rows.push({ index, typeId, activeHeaderSize, payloadOffset: null });
continue;
}
if (payloadCursor + activeHeaderSize > directoryBase) {
return null;
}
nonZeroCount += 1;
payloadBytes += activeHeaderSize;
if (activeHeaderSize === 0x58) {
size58Count += 1;
}
rows.push({
index,
typeId,
activeHeaderSize,
payloadOffset: payloadCursor - startOffset,
});
payloadCursor += activeHeaderSize;
}
if (nonZeroCount < 4 || size58Count < 2) {
return null;
}
return {
startOffset,
count,
directoryOffset,
payloadBytes,
nonZeroCount,
clearCount,
size58Count,
firstNonZeroTypeIds: rows.filter((row) => row.activeHeaderSize !== 0).slice(0, 16).map((row) => row.typeId),
rows,
};
}
function scanActiveHeaderOverrideCandidates(wdl) {
const candidates = [];
const seenAbsoluteOffsets = new Set();
const sources = [
...(wdl.sections ?? []).map((section) => ({
kind: 'section',
name: section.name,
offset: section.offset,
buffer: section.buffer,
})),
...(wdl.regions ?? [])
.filter((region) => region.name !== 'audio_or_spu_blob')
.map((region) => ({
kind: 'region',
name: region.name,
offset: region.offset,
buffer: region.buffer,
})),
];
for (const source of sources) {
if (!source?.buffer || source.buffer.length < 0x80) {
continue;
}
for (let startOffset = 0; startOffset + 8 <= source.buffer.length; startOffset += 4) {
const candidate = parseActiveHeaderOverrideCandidate(source.buffer, startOffset);
if (!candidate) {
continue;
}
const absoluteOffset = source.offset + startOffset;
if (seenAbsoluteOffsets.has(absoluteOffset)) {
continue;
}
seenAbsoluteOffsets.add(absoluteOffset);
candidates.push({
sourceKind: source.kind,
sectionName: source.name,
sectionOffset: source.offset,
absoluteOffset,
...candidate,
});
}
}
candidates.sort((left, right) => {
if (left.size58Count !== right.size58Count) {
return right.size58Count - left.size58Count;
}
if (left.nonZeroCount !== right.nonZeroCount) {
return right.nonZeroCount - left.nonZeroCount;
}
return left.absoluteOffset - right.absoluteOffset;
});
return candidates;
}
function buildMode2PaletteSweepCandidates(bundle, paletteSets) {
const candidates = [];
const seen = new Set();
const priorityIndexes = [bundle.defaultPaletteIndex, bundle.resolvedPaletteIndex, bundle.paletteIndex, 0];
for (const paletteIndex of priorityIndexes) {
if (!Number.isInteger(paletteIndex) || paletteIndex < 0 || paletteIndex >= paletteSets.palettes16.length || seen.has(paletteIndex)) {
continue;
}
seen.add(paletteIndex);
candidates.push({
label: `pal${paletteIndex}`,
paletteIndex,
palette: paletteSets.palettes16[paletteIndex],
paletteFormula: paletteIndex === bundle.defaultPaletteIndex ? 'bundle-default' : 'palette-sweep',
});
}
for (let paletteIndex = 0; paletteIndex < Math.min(32, paletteSets.palettes16.length); paletteIndex += 1) {
if (seen.has(paletteIndex)) {
continue;
}
seen.add(paletteIndex);
candidates.push({
label: `pal${paletteIndex}`,
paletteIndex,
palette: paletteSets.palettes16[paletteIndex],
paletteFormula: 'palette-sweep',
});
}
return candidates;
}
function buildMode1PaletteSweepCandidates(bundle, paletteSets, options = {}) {
const candidates = [];
const seen = new Set();
const mode1PaletteBank = options.mode1PaletteBank ?? buildMode1PaletteBank(paletteSets.palettes16);
if (options.mode1RuntimePalette?.length === 256) {
seen.add('gpu-row-f0');
candidates.push({
label: 'gpu-row-f0',
paletteIndex: 0,
palette: options.mode1RuntimePalette,
paletteFormula: 'mode1-live-gpu-ram-row-f0-x0',
});
}
const priorityIndexes = [bundle.defaultPaletteIndex, bundle.resolvedPaletteIndex, bundle.paletteIndex, 0];
for (const paletteIndex of priorityIndexes) {
if (!Number.isInteger(paletteIndex) || seen.has(`bank-${paletteIndex}`)) {
continue;
}
const palette = mode1PaletteBank[paletteIndex] ?? null;
if (!palette) {
continue;
}
seen.add(`bank-${paletteIndex}`);
candidates.push({
label: `bank${paletteIndex}`,
paletteIndex,
palette,
paletteFormula: paletteIndex === bundle.defaultPaletteIndex ? 'mode1-runtime-clut-bank-default' : 'mode1-runtime-clut-bank-sweep',
});
}
for (let paletteIndex = 0; paletteIndex < Math.min(8, mode1PaletteBank.length); paletteIndex += 1) {
if (seen.has(`bank-${paletteIndex}`) || !mode1PaletteBank[paletteIndex]) {
continue;
}
seen.add(`bank-${paletteIndex}`);
candidates.push({
label: `bank${paletteIndex}`,
paletteIndex,
palette: mode1PaletteBank[paletteIndex],
paletteFormula: 'mode1-runtime-clut-bank-sweep',
});
}
return candidates;
}
function buildBundleValidationCells(region04, bundle, paletteSets, options = {}) {
const candidates = bundle.mode === 2
? buildMode2PaletteSweepCandidates(bundle, paletteSets)
: buildMode1PaletteSweepCandidates(bundle, paletteSets, options);
const frameIndexes = bundle.frames.slice(0, Math.min(bundle.frames.length, 4)).map((frame) => frame.index);
const items = [];
let maxWidth = 0;
let maxHeight = 0;
for (const candidate of candidates) {
for (const frameIndex of frameIndexes) {
const previewBundle = {
...bundle,
resolvedPaletteIndex: candidate.paletteIndex,
paletteFormula: candidate.paletteFormula,
palette: candidate.palette,
};
const sprite = decodeBundleFrame(region04, previewBundle, frameIndex, candidate.palette ?? null);
if (!sprite) {
continue;
}
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
items.push({ candidate, frameIndex, sprite });
}
}
const columns = Math.max(1, frameIndexes.length);
const gutter = 24;
const renderItems = items.map((entry, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
return {
id: String(index),
sheetIndex: index,
recordIndex: entry.frameIndex,
typeWord: bundle.slot,
laneWord: entry.candidate.paletteIndex ?? 0,
requestedFrameIndex: entry.frameIndex,
frameIndex: entry.frameIndex,
defaultPaletteIndex: bundle.defaultPaletteIndex ?? bundle.paletteIndex ?? null,
resolvedPaletteIndex: entry.candidate.paletteIndex,
paletteFormula: entry.candidate.paletteFormula,
mappingSource: 'bundle-validation-sweep',
donorTypeId: null,
templateTypeId: null,
bundleAbsoluteOffset: bundle.absoluteOffset,
width: entry.sprite.width,
height: entry.sprite.height,
originX: 0,
originY: 0,
drawX: column * (maxWidth + gutter),
drawY: row * (maxHeight + gutter),
flipped: false,
sprite: entry.sprite,
};
});
const metadata = items.map((entry, index) => ({
sheetIndex: index,
frameIndex: entry.frameIndex,
paletteIndex: entry.candidate.paletteIndex,
label: entry.candidate.label,
paletteFormula: entry.candidate.paletteFormula,
width: entry.sprite.width,
height: entry.sprite.height,
}));
return { renderItems, metadata };
}
async function writeValidationOutputs(outputRoot, mapStem, region04, bundles, paletteSets, options = {}, bundleOffsets = []) {
const outputs = [];
const bundlesByOffset = new Map(bundles.map((bundle) => [bundle.absoluteOffset, bundle]));
for (const bundleOffset of bundleOffsets) {
const bundle = bundlesByOffset.get(bundleOffset);
if (!bundle) {
continue;
}
const { renderItems, metadata } = buildBundleValidationCells(region04, bundle, paletteSets, options);
if (renderItems.length === 0) {
continue;
}
const render = renderMap(renderItems, {
drawLabels: true,
background: { red: 18, green: 18, blue: 18, alpha: 255 },
});
const stem = `${mapStem}_bundle_${bundleOffset.toString(16).padStart(8, '0')}_sheet`;
const pngPath = path.join(outputRoot, `${stem}.png`);
const jsonPath = path.join(outputRoot, `${stem}.json`);
await fs.writeFile(pngPath, render.png);
await fs.writeFile(jsonPath, JSON.stringify({
bundleOffset,
mode: bundle.mode,
frameCount: bundle.frames.length,
candidateCount: metadata.length,
items: metadata,
}, null, 2));
outputs.push({ bundleOffset, pngPath, jsonPath });
}
return outputs;
}
function colorizeU16Value(value) {
return {
red: value & 0x1f,
green: (value >> 5) & 0x1f,
blue: (value >> 10) & 0x1f,
};
}
function buildRegion02ExamplePng(region, options = {}) {
const columns = options.columns ?? 128;
const cellSize = options.cellSize ?? 4;
const sampleWordCount = Math.min(options.sampleWordCount ?? 2048, Math.floor(region.buffer.length / 2));
const rows = Math.max(1, Math.ceil(sampleWordCount / columns));
const width = columns * cellSize;
const height = rows * cellSize;
const rgba = Buffer.alloc(width * height * 4, 0);
for (let index = 0; index < sampleWordCount; index += 1) {
const value = region.buffer.readUInt16LE(index * 2);
const color = colorizeU16Value(value);
const column = index % columns;
const row = Math.floor(index / columns);
const baseX = column * cellSize;
const baseY = row * cellSize;
const red = Math.round((color.red / 31) * 255);
const green = Math.round((color.green / 31) * 255);
const blue = Math.round((color.blue / 31) * 255);
for (let y = 0; y < cellSize; y += 1) {
for (let x = 0; x < cellSize; x += 1) {
const pixel = ((baseY + y) * width + (baseX + x)) * 4;
rgba[pixel + 0] = red;
rgba[pixel + 1] = green;
rgba[pixel + 2] = blue;
rgba[pixel + 3] = 255;
}
}
}
return {
width,
height,
sampleWordCount,
png: encodePng(rgba, width, height),
};
}
async function writeRegion02Example(outputRoot, cacheRoot, mapStem, region, summary) {
if (!region || !summary) {
return null;
}
const example = buildRegion02ExamplePng(region);
const pngPath = path.join(outputRoot, `${mapStem}_region02_example.png`);
const jsonPath = path.join(outputRoot, `${mapStem}_region02_example.json`);
const cacheJsonPath = path.join(cacheRoot, 'region02-analysis.json');
await fs.writeFile(pngPath, example.png);
await fs.writeFile(jsonPath, JSON.stringify({
...summary,
exampleWidth: example.width,
exampleHeight: example.height,
sampleWordCount: example.sampleWordCount,
description: 'False-color grid of the first region-02 u16 words plus structured preview rows in the JSON. This is a raw structure preview, not a decoded floor/map layer.',
}, null, 2));
return { pngPath, jsonPath, cacheJsonPath };
}
function resolveBundlePalettes(bundles, paletteSets, options = {}) {
const mode1PaletteBank = buildMode1PaletteBank(paletteSets.palettes16);
const mode1RuntimePalette = options.mode1RuntimePalette
?? buildMode1RuntimePalette(paletteSets.palettes16)
?? paletteSets.palettes256[0]
?? null;
return bundles.map((bundle) => {
const defaultPaletteIndex = bundle.paletteIndex;
let resolvedPaletteIndex = bundle.paletteIndex;
let palette = null;
let paletteFormula = null;
if (bundle.mode === 2) {
// Mode 2 (4bpp) descriptor binder at psx_resource_bind_single_image_vram_slot
// (0x800444e4) takes the bundle's `paletteIndex` from descriptor+0x14
// and ADDS 0x10 (= 16) before storing it as the resource's CLUT bank
// index at resource+0x08. The submitters then read
// psx_clut_table_by_resource_bank[resource+8] which is identical to
// palettes16[paletteIndex + 16]. The exporter therefore offsets the
// bundle index by +16 for mode-2 art.
const baseIndex = Number.isInteger(bundle.paletteIndex) ? bundle.paletteIndex : null;
const adjustedIndex = baseIndex !== null ? baseIndex + 16 : null;
if (adjustedIndex !== null && adjustedIndex >= 0 && adjustedIndex < paletteSets.palettes16.length) {
resolvedPaletteIndex = adjustedIndex;
palette = paletteSets.palettes16[adjustedIndex];
paletteFormula = 'mode2-bundle-index-plus-16';
}
if (!palette) {
if (!Number.isInteger(resolvedPaletteIndex) || resolvedPaletteIndex < 0 || resolvedPaletteIndex >= paletteSets.palettes16.length) {
resolvedPaletteIndex = choosePalette(paletteSets.palettes16, bundle.frames, bundle.mode);
}
if (Number.isInteger(resolvedPaletteIndex) && resolvedPaletteIndex >= 0 && resolvedPaletteIndex < paletteSets.palettes16.length) {
palette = paletteSets.palettes16[resolvedPaletteIndex];
paletteFormula = 'mode2-bundle-or-usage-index-fallback';
}
}
} else if (bundle.mode === 1) {
// Mode 1 is an 8bpp image with a 256-entry CLUT. In this engine the
// 256-color CLUT is NOT a dedicated palette-256 block — it is the
// concatenation of 16 consecutive 16-entry CLUTs taken from the
// `palettes16` bank. The bundle header's `paletteIndex` is the starting
// CLUT index into `palettes16`. Falling back to `0` matches the legacy
// behavior but is almost always wrong for mode-1 art; the per-bundle
// index is the real engine-equivalent rule.
const bankStart = Number.isInteger(bundle.paletteIndex) && bundle.paletteIndex >= 0
? bundle.paletteIndex
: 0;
const fromBundleIndex = mode1PaletteBank[bankStart];
if (fromBundleIndex?.length === 256) {
resolvedPaletteIndex = bankStart;
palette = fromBundleIndex;
paletteFormula = 'mode1-palette16-bank-start-index-bundle';
} else if (options.mode1RuntimePalette?.length === 256) {
resolvedPaletteIndex = 0;
palette = options.mode1RuntimePalette;
paletteFormula = 'mode1-live-gpu-ram-row-f0-x0';
} else if (mode1PaletteBank[0]?.length) {
resolvedPaletteIndex = 0;
palette = mode1PaletteBank[0];
paletteFormula = 'mode1-palette16-bank-start-index-0-fallback';
} else {
resolvedPaletteIndex = null;
palette = mode1RuntimePalette;
paletteFormula = palette ? 'mode1-runtime-clut-band-start-index-0' : null;
}
}
return {
...bundle,
defaultPaletteIndex,
resolvedPaletteIndex,
paletteFormula,
palette,
};
});
}
async function buildSceneItems(region04, records, bundles, options = {}) {
const items = [];
const spriteCache = new Map();
const skippedRecords = [];
const nonMapFacingRootTypes = new Set([0x42, 0x49]);
// Bundles that bind via dispatch_roots but are HUD/UI artwork, not authored
// map placements. They surface in the scene because they share the dispatch
// table format with world objects, but rendering them produces a stray UI
// panel floating in the middle of an empty area.
// 0x000d84f4 - portrait/talk panel (already known)
// 0x00074f44 - type 0xAC (172) full-screen UI panel, 216x126, kind-4
// mode-2; user-confirmed as the lone "TAC:b4f44" leak.
// 0x00086810 - "ceiling" tile (T0x43, kind-5 mode-2, 128x65). Engine uses
// it at runtime to temporarily obscure rooms the player has
// not entered yet; for static map rendering it just hides
// the geometry the user wants to see, so it is suppressed.
const nonMapFacingBundleOffsets = new Set([0x000d84f4, 0x00074f44, 0x00086810]);
for (const record of records) {
if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) {
skippedRecords.push({
recordIndex: record.index,
typeWord: record.typeWord,
sourceFamily: record.sourceFamily,
reason: 'non-map-facing portrait/talk root type',
});
continue;
}
const binding = chooseBundleBinding(record, bundles, options);
const bundle = binding?.bundle ?? null;
if (!binding) {
continue;
}
if (binding.placeholder) {
// Non-renderable entity (spawner, trigger, NPC, UI marker). Emit a small
// square footprint so the viewer can still display and inspect it.
const PLACEHOLDER_SIZE = 12;
items.push({
id: record.index,
instanceId: record.index,
recordIndex: record.index,
recordSource: record.source,
sourceFamily: record.sourceFamily,
authoredLayer: record.authoredLayer ?? record.sourceRole ?? null,
recordSide: record.recordSide,
rowIndex: record.rowIndex,
typeWord: record.typeWord,
laneWord: record.laneWord,
worldX: record.xWord ?? null,
worldY: record.yWord ?? null,
worldZ: record.zWord ?? null,
screenX: record.screenX,
screenY: record.screenY,
bundleSlot: null,
bundleAbsoluteOffset: null,
bundleSource: null,
requestedFrameIndex: record.selectorWord,
frameIndex: null,
defaultPaletteIndex: null,
resolvedPaletteIndex: null,
paletteFormula: null,
mappingSource: binding.mappingSource,
templateTypeId: null,
donorTypeId: null,
runtimeBindingMaskedAbsoluteOffset: null,
runtimeBindingOffsetDelta: null,
runtimeBindingVisibleCount: null,
runtimeBindingRawRecordCount: null,
rawWords: record.rawWords ?? record.words,
flipped: false,
width: PLACEHOLDER_SIZE,
height: PLACEHOLDER_SIZE,
originX: PLACEHOLDER_SIZE / 2,
originY: PLACEHOLDER_SIZE / 2,
drawX: record.screenX - PLACEHOLDER_SIZE / 2,
drawY: record.screenY - PLACEHOLDER_SIZE / 2,
stage: 1, // overlays so placeholders sit on top of geometry
isFlat: false,
resourceKey: 'placeholder',
paletteDiagnostics: null,
sprite: null,
placeholder: true,
});
continue;
}
if (!bundle) {
continue;
}
if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) {
skippedRecords.push({
recordIndex: record.index,
typeWord: record.typeWord,
sourceFamily: record.sourceFamily,
bundleAbsoluteOffset: bundle.absoluteOffset,
reason: 'known non-map-facing portrait/talk bundle',
});
continue;
}
const requestedFrameIndex = record.selectorWord;
const clampedFrameIndex = Math.max(0, Math.min(requestedFrameIndex, bundle.frames.length - 1));
const spriteKey = `${bundle.absoluteOffset}:${clampedFrameIndex}`;
let sprite = spriteCache.get(spriteKey);
if (!sprite) {
sprite = decodeBundleFrame(region04, bundle, requestedFrameIndex, bundle.palette ?? null);
if (!sprite) {
continue;
}
spriteCache.set(spriteKey, sprite);
}
const resourceKey = buildResourceKey(bundle, sprite);
items.push({
id: record.index,
instanceId: record.index,
recordIndex: record.index,
recordSource: record.source,
sourceFamily: record.sourceFamily,
authoredLayer: record.authoredLayer ?? record.sourceRole ?? null,
recordSide: record.recordSide,
rowIndex: record.rowIndex,
typeWord: record.typeWord,
laneWord: record.laneWord,
worldX: record.xWord ?? null,
worldY: record.yWord ?? null,
worldZ: record.zWord ?? null,
screenX: record.screenX,
screenY: record.screenY,
bundleSlot: bundle.slot,
bundleAbsoluteOffset: bundle.absoluteOffset,
bundleSource: bundle.bundleSource ?? 'raw-scan',
requestedFrameIndex,
frameIndex: sprite.clampedFrameIndex,
defaultPaletteIndex: bundle.defaultPaletteIndex ?? null,
resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null,
paletteFormula: bundle.paletteFormula ?? null,
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,
height: sprite.height,
originX: sprite.originX,
originY: sprite.originY,
// Engine-accurate anchor selection per psx_project_object_main_visible
// (0x80040d44 / 0x80040ddc): when laneWord bit 0x0002 is set the engine
// mirrors the horizontal anchor from origin_x to (frame_w - origin_x).
// The blit step then flips the sprite. Applying both keeps the visible
// anchor on the same world point, fixing flipped walls that previously
// landed (frame_w - 2*origin_x) px too far left.
drawX: ((record.laneWord & 0x0002) !== 0)
? record.screenX - (sprite.width - sprite.originX)
: record.screenX - sprite.originX,
drawY: record.screenY - sprite.originY,
stage: record.typeWord === 4 || (record.laneWord & 0x0400) !== 0 ? 1 : 0,
// Heuristic: flat floor/ceiling decals are tile-sized sprites whose
// anchor sits at the bottom edge AND whose silhouette is wider than
// tall (i.e. matches the engine's 2:1 isometric ground footprint).
// Upright sprites (walls, crates, terminals, props) extend well above
// the anchor and have height >= width.
isFlat:
sprite.width >= 48 &&
sprite.height > 0 &&
sprite.height / sprite.width <= 0.6 &&
sprite.originY >= sprite.height - 4,
resourceKey,
paletteDiagnostics: derivePaletteDiagnostics(record, bundle),
sprite,
});
}
// Painter's order for an isometric top-down full-map render.
// 1. `stage`: runtime sub-stage flag (0 = default, 1 = overlays flagged via
// typeWord===4 or laneWord bit 0x0400). Overlays always on top.
// 2. Isometric depth: back-to-front by world `X + Y` (ground depth along the
// engine's isometric axis). Constructors (static geometry: walls, floors,
// architecture) and roots (dynamic props: crates, terminals, doors) are
// interleaved by depth so a crate placed in front of a wall does not get
// painted over by a wall placed further back. Falls back to screenY when
// world coords are unavailable.
// 3. `isFlat`: at the SAME depth, flat ground decals (floor/ceiling tiles)
// must draw BEFORE upright sprites (walls, crates, props) so the props
// standing on the tile sit visually on top of it. Without this bias,
// floors painted on the same world tile as a prop will overpaint the
// prop whenever screenX happens to sort the floor last.
// 4. `worldZ` ascending: lower objects at the same ground cell draw before
// taller ones at that cell.
// 5. `authoredLayer`: when world coords + flatness tie, draw constructors
// (geometry) before roots (props) so props sit on top of their floor.
// 6. `screenX` ascending: stable tie-breaker left-to-right.
const layerTieBreak = (item) => {
if (item.authoredLayer === 'constructors') return 0;
if (item.authoredLayer === 'roots') return 1;
return 2;
};
const depthKey = (item) => {
if (Number.isFinite(item.worldX) && Number.isFinite(item.worldY)) {
return item.worldX + item.worldY;
}
return item.screenY;
};
items.sort((left, right) => {
if (left.stage !== right.stage) {
return left.stage - right.stage;
}
// Two-pass painter: ALL flat ground decals first (back-to-front by depth),
// then ALL upright sprites (back-to-front by depth). This avoids the
// floor-anchor problem where a flat tile's authored anchor sits at its
// FRONT tip (largest world X+Y in the footprint), which a single-pass
// depth sort would draw AFTER walls/props that visually stand on it.
if (left.isFlat !== right.isFlat) {
return left.isFlat ? -1 : 1;
}
const leftDepth = depthKey(left);
const rightDepth = depthKey(right);
if (leftDepth !== rightDepth) {
return leftDepth - rightDepth;
}
const leftZ = Number.isFinite(left.worldZ) ? left.worldZ : 0;
const rightZ = Number.isFinite(right.worldZ) ? right.worldZ : 0;
if (leftZ !== rightZ) {
return leftZ - rightZ;
}
const leftLayer = layerTieBreak(left);
const rightLayer = layerTieBreak(right);
if (leftLayer !== rightLayer) {
return leftLayer - rightLayer;
}
return left.screenX - right.screenX;
});
return {
items: assignResourceLabelIds(items),
skippedRecords,
};
}
async function writeBundleCache(spriteRoot, region04, bundles) {
const manifest = [];
for (const bundle of bundles) {
const bundleDir = path.join(spriteRoot, `bundle_${bundle.absoluteOffset.toString(16).padStart(8, '0')}`);
await ensureDirectory(bundleDir);
const frames = [];
for (const frame of bundle.frames) {
const decoded = decodeBundleFrame(region04, bundle, frame.index, bundle.palette ?? null);
const fileName = `frame_${String(frame.index).padStart(3, '0')}.png`;
await fs.writeFile(path.join(bundleDir, fileName), encodePng(decoded.rgba, decoded.width, decoded.height));
frames.push({
index: frame.index,
width: decoded.width,
height: decoded.height,
originX: decoded.originX,
originY: decoded.originY,
fileName,
});
}
manifest.push({
slot: bundle.slot,
absoluteOffset: bundle.absoluteOffset,
offsetInRegion: bundle.offsetInRegion,
kind: bundle.kind,
mode: bundle.mode,
paletteIndex: bundle.paletteIndex,
defaultPaletteIndex: bundle.defaultPaletteIndex ?? null,
resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null,
paletteFormula: bundle.paletteFormula ?? null,
bundleSource: bundle.bundleSource,
frameCount: bundle.frameCount,
frameTableOffset: bundle.frameTableOffset,
dataOffset: bundle.dataOffset,
frames,
});
}
return manifest;
}
export async function exportMap(options) {
const buffer = await fs.readFile(options.wdlPath);
const wdl = parseLsetWdl(buffer, options.wdlPath);
const mapStem = sanitizeStem(options.outName ?? path.parse(options.wdlPath).name);
const cacheBaseRoot = path.join(options.projectRoot, '.cache');
const cacheRoot = path.join(options.projectRoot, '.cache', mapStem);
const spriteRoot = path.join(cacheRoot, 'sprites');
const outputRoot = options.outputRoot
? path.resolve(options.outputRoot)
: path.join(options.projectRoot, '.output');
await ensureDirectory(cacheBaseRoot);
await resetDirectory(cacheRoot);
await ensureDirectory(cacheRoot);
await ensureDirectory(spriteRoot);
if (options.resetOutputRoot === false) {
await ensureDirectory(outputRoot);
} else {
await resetDirectory(outputRoot);
}
const recordSet = chooseRecordSet(wdl, options.mapSource);
const region04 = wdl.regions.find((region) => region.name === 'post_audio_region_04');
const region02 = wdl.regions.find((region) => region.name === 'post_audio_region_02');
if (!region04) {
throw new Error('The WDL did not expose post_audio_region_04.');
}
const sceneScope = options.sceneScope === 'full' ? 'full' : 'probe';
if (sceneScope === 'full') {
throw new Error('Full-scene export is disabled. Current research does not support a trustworthy raw floor or full-map reconstruction yet.');
}
const paletteSets = extractPaletteSets(wdl.buffer, wdl.headerWords);
const region02Summary = region02 ? summarizeRegion02(region02) : null;
const activeHeaderOverrideCandidates = scanActiveHeaderOverrideCandidates(wdl);
let bundles = scanSpriteBundles(region04).map((bundle) => ({ ...bundle, bundleSource: 'raw-scan' }));
const mode1RuntimePalette = await loadGpuDumpMode1Palette(options.gpuRamDumpPath);
const mode1PaletteBank = buildMode1PaletteBank(paletteSets.palettes16);
bundles = resolveBundlePalettes(
bundles,
paletteSets,
{ mode1RuntimePalette }
);
// Override-bank bindings: per-type drawable-header pointers installed by the
// late override pass in wdl_resource_bundle_load_by_index. This is the same
// runtime state constructors and render submitters read when resolving art,
// so it gives executable-backed typeWord -> bundle truth for the bundle A
// (SPEC_A.WDL) and bundle B (map-local LSET WDL) passes combined.
const overrideBankResult = await loadOverrideBankBindings(
wdl,
options.wdlPath,
options.discRoot ?? null,
paletteSets,
{ mode1RuntimePalette },
);
bundles = [...bundles, ...overrideBankResult.bundles];
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,
overrideBundlesByType: overrideBankResult.byType,
});
const bindingDiversity = summarizeBindingDiversity(sceneItems);
const authoredLayerSummary = summarizeAuthoredLayers(recordSet.records);
const renderedLayerSummary = summarizeRenderedLayers(sceneItems);
const sceneInterpretation = buildSceneInterpretation(recordSet, bindingDiversity);
const renderOptions = { background: DEFAULT_BACKGROUND };
const render = renderMap(sceneItems, renderOptions);
const debugRender = options.debugLabels ? renderMap(sceneItems, { ...renderOptions, drawLabels: true }) : null;
const layerRenders = renderedLayerSummary.map(({ layer }) => {
const layerItems = sceneItems.filter((item) => item.authoredLayer === layer);
return {
layer,
itemCount: layerItems.length,
render: renderMap(layerItems, renderOptions),
debugRender: options.debugLabels ? renderMap(layerItems, { ...renderOptions, drawLabels: true }) : null,
};
});
const bundleManifest = await writeBundleCache(spriteRoot, region04, bundles);
const validationBundleOffsets = (options.validationBundles ?? [])
.map((value) => parseBundleOffset(value))
.filter((value) => Number.isInteger(value));
const validationOutputs = await writeValidationOutputs(outputRoot, mapStem, region04, bundles, paletteSets, {
mode1RuntimePalette,
mode1PaletteBank,
}, validationBundleOffsets);
const summary = {
sourceFile: options.wdlPath,
mapSource: recordSet.source,
mapScope: describeMapScope(recordSet),
recordStartOffset: recordSet.recordStartOffset,
streamHeaderOffset: recordSet.streamHeaderOffset ?? null,
streamRecordCount: recordSet.streamRecordCount ?? null,
streamStructuredPrefixCount: recordSet.streamStructuredPrefixCount ?? null,
recordCount: recordSet.records.length,
renderableItemCount: sceneItems.length,
skippedProbeRecordCount: skippedRecords.length,
authoredLayerSummary,
renderedLayerSummary,
bundleCount: bundles.length,
bundleSource: bundles[0]?.bundleSource ?? 'none',
gpuRamDumpPath: options.gpuRamDumpPath ?? null,
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]
? {
sectionName: activeHeaderOverrideCandidates[0].sectionName,
absoluteOffset: activeHeaderOverrideCandidates[0].absoluteOffset,
count: activeHeaderOverrideCandidates[0].count,
directoryOffset: activeHeaderOverrideCandidates[0].directoryOffset,
nonZeroCount: activeHeaderOverrideCandidates[0].nonZeroCount,
clearCount: activeHeaderOverrideCandidates[0].clearCount,
size58Count: activeHeaderOverrideCandidates[0].size58Count,
}
: null,
bindingDiversity,
sceneInterpretation,
region02Note: region02Summary?.note ?? null,
limitations: [
...(sceneScope === 'full'
? ['Full-scene export is intentionally disabled until raw floor and subordinate-state decode is recovered from current research.']
: [
'Exports only standalone object-placement probes from the loader-sized section-0 root or constructor-placement families.',
'Auto mode now emits a layered probe that combines section-0 constructor placements with the smaller root-dispatch lane when both are available.',
'Constructor-placement output should currently be interpreted as a constructor-fed live-object seed lane rather than the final visible map.',
'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.',
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.',
],
debugLabels: Boolean(options.debugLabels),
headerSize: wdl.headerSize,
audioSize: wdl.audioSize,
boundaryCandidates: wdl.boundaryCandidates,
regions: wdl.regions.map((region) => ({
name: region.name,
offset: region.offset,
size: region.size,
})),
};
const sceneManifest = {
...summary,
renderWidth: render.width,
renderHeight: render.height,
bounds: render.bounds,
skippedRecords,
items: sceneItems.map((item) => ({
recordIndex: item.recordIndex,
recordSource: item.recordSource,
sourceFamily: item.sourceFamily,
authoredLayer: item.authoredLayer,
recordSide: item.recordSide,
rowIndex: item.rowIndex,
rawWords: item.rawWords,
typeWord: item.typeWord,
laneWord: item.laneWord,
worldX: item.worldX ?? null,
worldY: item.worldY ?? null,
worldZ: item.worldZ ?? null,
screenX: item.screenX,
screenY: item.screenY,
bundleSlot: item.bundleSlot,
bundleAbsoluteOffset: item.bundleAbsoluteOffset,
bundleSource: item.bundleSource,
requestedFrameIndex: item.requestedFrameIndex,
frameIndex: item.frameIndex,
instanceId: item.instanceId,
resourceKey: item.resourceKey,
resourceLabelId: item.labelId,
defaultPaletteIndex: item.defaultPaletteIndex,
resolvedPaletteIndex: item.resolvedPaletteIndex,
paletteFormula: item.paletteFormula,
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,
expectedAssignmentPath: item.paletteDiagnostics?.expectedAssignmentPath ?? null,
flipped: item.flipped,
drawX: item.drawX,
drawY: item.drawY,
width: item.width,
height: item.height,
originX: item.originX,
originY: item.originY,
stage: item.stage,
placeholder: item.placeholder === true,
})),
};
await fs.writeFile(path.join(cacheRoot, 'wdl-summary.json'), JSON.stringify(summary, null, 2));
await fs.writeFile(path.join(cacheRoot, 'records.json'), JSON.stringify(recordSet, null, 2));
await fs.writeFile(path.join(cacheRoot, 'bundles.json'), JSON.stringify(bundleManifest, null, 2));
await fs.writeFile(path.join(cacheRoot, 'frame-manifest.json'), JSON.stringify(bundleManifest, null, 2));
await fs.writeFile(path.join(cacheRoot, 'active-header-overrides.json'), JSON.stringify(activeHeaderOverrideCandidates, null, 2));
if (region02Summary) {
await fs.writeFile(path.join(cacheRoot, 'region02-analysis.json'), JSON.stringify(region02Summary, null, 2));
}
const region02Example = await writeRegion02Example(outputRoot, cacheRoot, mapStem, region02, region02Summary);
await fs.writeFile(path.join(outputRoot, `${mapStem}.png`), render.png);
if (debugRender) {
await fs.writeFile(path.join(outputRoot, `${mapStem}_labels.png`), debugRender.png);
}
for (const layerRender of layerRenders) {
await fs.writeFile(path.join(outputRoot, `${mapStem}_${layerRender.layer}.png`), layerRender.render.png);
if (layerRender.debugRender) {
await fs.writeFile(path.join(outputRoot, `${mapStem}_${layerRender.layer}_labels.png`), layerRender.debugRender.png);
}
}
await fs.writeFile(path.join(outputRoot, `${mapStem}.json`), JSON.stringify(sceneManifest, null, 2));
return {
mapStem,
cacheRoot,
outputRoot,
summary,
outputPngPath: path.join(outputRoot, `${mapStem}.png`),
debugPngPath: debugRender ? path.join(outputRoot, `${mapStem}_labels.png`) : null,
outputJsonPath: path.join(outputRoot, `${mapStem}.json`),
validationOutputs,
region02Example,
};
}