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 { renderMap } from './render.js'; import { 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: [] }; if (mapSource === 'region00' || mapSource === 'roots') { return region00Records; } if (mapSource === 'region01' || mapSource === 'constructors') { return region01Records; } if (mapSource === 'combined' || mapSource === 'layered' || mapSource === 'auto') { const records = [ ...region01Records.records.map((record) => ({ ...record, authoredLayer: 'constructors' })), ...region00Records.records.map((record) => ({ ...record, authoredLayer: 'roots' })), ]; return { source: 'combined', recordStartOffset: 0, records, layers: { constructors: region01Records.records.length, roots: region00Records.records.length, }, }; } return region01Records.records.length >= 8 ? region01Records : region00Records; } function chooseBundleForType(bundles, typeWord) { if (typeWord >= 0 && typeWord < bundles.length) { return bundles[typeWord]; } return 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) { const rawWords = 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) { 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'; } } else if (bundle.mode === 1) { if (options.mode1RuntimePalette?.length === 256) { resolvedPaletteIndex = 0; palette = options.mode1RuntimePalette; } else if (mode1PaletteBank[0]?.length) { resolvedPaletteIndex = 0; palette = mode1PaletteBank[0]; } else { resolvedPaletteIndex = null; palette = mode1RuntimePalette; } if (palette) { paletteFormula = options.mode1RuntimePalette?.length === 256 ? 'mode1-live-gpu-ram-row-f0-x0' : 'mode1-runtime-clut-band-start-index-0'; } } 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]); const nonMapFacingBundleOffsets = new Set([0x000d84f4]); 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 rawTypeBundle = chooseBundleForType(bundles, record.typeWord); const bundle = rawTypeBundle; 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, 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: 'raw-typeword-bundle-slot-diagnostic', templateTypeId: null, donorTypeId: null, rawWords: record.rawWords ?? record.words, flipped: (record.laneWord & 0x0002) !== 0, width: sprite.width, height: sprite.height, originX: sprite.originX, originY: sprite.originY, drawX: record.screenX - sprite.originX, drawY: record.screenY - sprite.originY, stage: record.typeWord === 4 || (record.laneWord & 0x0400) !== 0 ? 1 : 0, resourceKey, paletteDiagnostics: derivePaletteDiagnostics(record, bundle), sprite, }); } items.sort((left, right) => { if (left.stage !== right.stage) { return left.stage - right.stage; } if (left.screenY !== right.screenY) { return left.screenY - right.screenY; } 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 = path.join(options.projectRoot, '.output'); await ensureDirectory(cacheBaseRoot); await resetDirectory(cacheRoot); await ensureDirectory(cacheRoot); await ensureDirectory(spriteRoot); 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 } ); const { items: sceneItems, skippedRecords } = await buildSceneItems(region04, recordSet.records, bundles, { paletteSets, mode1RuntimePalette, mode1PaletteBank, }); 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: 'raw-typeword-bundle-slot-diagnostic', 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.', '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, 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, 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, })), }; 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, }; }