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, }; }