2026-04-13 15:59:50 +02:00
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' ;
2026-04-16 23:52:41 +02:00
import { buildOverrideBundleBindings , parseOverrideBank } from './override-bank.js' ;
2026-04-13 15:59:50 +02:00
import { renderMap } from './render.js' ;
2026-04-16 23:52:41 +02:00
import { parseCtorPlacementsBlock , parseDispatchRootsBlock , parseLoaderLayout , parseLsetWdl , parseRegion00Records , parseRegion01Records , summarizeRegion02 } from './wdl.js' ;
2026-04-13 15:59:50 +02:00
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 : [ ] } ;
2026-04-16 23:52:41 +02:00
// 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 ;
2026-04-13 15:59:50 +02:00
if ( mapSource === 'region00' || mapSource === 'roots' ) {
2026-04-16 23:52:41 +02:00
return rootRecordSet ;
2026-04-13 15:59:50 +02:00
}
if ( mapSource === 'region01' || mapSource === 'constructors' ) {
2026-04-16 23:52:41 +02:00
return constructorRecordSet ;
2026-04-13 15:59:50 +02:00
}
if ( mapSource === 'combined' || mapSource === 'layered' || mapSource === 'auto' ) {
const records = [
2026-04-16 23:52:41 +02:00
... constructorRecordSet . records . map ( ( record ) => ( { ... record , authoredLayer : record . authoredLayer ? ? 'constructors' } ) ) ,
... rootRecordSet . records . map ( ( record ) => ( { ... record , authoredLayer : record . authoredLayer ? ? 'roots' } ) ) ,
2026-04-13 15:59:50 +02:00
] ;
return {
source : 'combined' ,
recordStartOffset : 0 ,
records ,
layers : {
2026-04-16 23:52:41 +02:00
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 ,
} ,
2026-04-13 15:59:50 +02:00
} ,
} ;
}
2026-04-16 23:52:41 +02:00
return constructorRecordSet . records . length >= 8 ? constructorRecordSet : rootRecordSet ;
2026-04-13 15:59:50 +02:00
}
function chooseBundleForType ( bundles , typeWord ) {
if ( typeWord >= 0 && typeWord < bundles . length ) {
return bundles [ typeWord ] ;
}
return null ;
}
2026-04-13 16:50:28 +02:00
async function loadJsonIfExists ( filePath ) {
try {
return JSON . parse ( await fs . readFile ( filePath , 'utf8' ) ) ;
} catch {
return null ;
}
}
2026-04-16 23:52:41 +02:00
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 ,
} ;
}
2026-04-13 16:50:28 +02:00
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 = { } ) {
2026-04-16 23:52:41 +02:00
// 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 ,
} ;
}
2026-04-13 16:50:28 +02:00
if ( options . bindingMode === 'runtime-map0-masked-proxy' ) {
const runtimeBinding = options . runtimeMap0Bindings ? . get ( record . typeWord ) ? ? null ;
if ( runtimeBinding ? . bundle ) {
return runtimeBinding ;
}
}
2026-04-16 23:52:41 +02:00
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 ;
}
2026-04-18 16:34:35 +02:00
// 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 } ;
}
2026-04-13 16:50:28 +02:00
const rawTypeBundle = chooseBundleForType ( bundles , record . typeWord ) ;
if ( ! rawTypeBundle ) {
return null ;
}
return {
bundle : rawTypeBundle ,
mappingSource : 'raw-typeword-bundle-slot-diagnostic' ,
runtimeBinding : null ,
} ;
}
2026-04-13 15:59:50 +02:00
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 ) {
2026-04-18 14:38:40 +02:00
// 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
: [ ] ;
2026-04-13 15:59:50 +02:00
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 ) {
2026-04-18 14:38:40 +02:00
// 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' ;
2026-04-13 15:59:50 +02:00
}
2026-04-18 14:38:40 +02:00
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' ;
}
2026-04-13 15:59:50 +02:00
}
} else if ( bundle . mode === 1 ) {
2026-04-18 14:38:40 +02:00
// 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 ) {
2026-04-13 15:59:50 +02:00
resolvedPaletteIndex = 0 ;
palette = options . mode1RuntimePalette ;
2026-04-18 14:38:40 +02:00
paletteFormula = 'mode1-live-gpu-ram-row-f0-x0' ;
2026-04-13 15:59:50 +02:00
} else if ( mode1PaletteBank [ 0 ] ? . length ) {
resolvedPaletteIndex = 0 ;
palette = mode1PaletteBank [ 0 ] ;
2026-04-18 14:38:40 +02:00
paletteFormula = 'mode1-palette16-bank-start-index-0-fallback' ;
2026-04-13 15:59:50 +02:00
} else {
resolvedPaletteIndex = null ;
palette = mode1RuntimePalette ;
2026-04-18 14:38:40 +02:00
paletteFormula = palette ? 'mode1-runtime-clut-band-start-index-0' : null ;
2026-04-13 15:59:50 +02:00
}
}
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 ] ) ;
2026-04-18 14:38:40 +02:00
// 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.
2026-04-18 16:34:35 +02:00
// 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 ] ) ;
2026-04-13 15:59:50 +02:00
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 ;
}
2026-04-13 16:50:28 +02:00
const binding = chooseBundleBinding ( record , bundles , options ) ;
const bundle = binding ? . bundle ? ? null ;
2026-04-18 16:34:35 +02:00
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 ) {
2026-04-13 15:59:50 +02:00
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 ,
2026-04-18 14:38:40 +02:00
worldX : record . xWord ? ? null ,
worldY : record . yWord ? ? null ,
worldZ : record . zWord ? ? null ,
2026-04-13 15:59:50 +02:00
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 ,
2026-04-13 16:50:28 +02:00
mappingSource : binding . mappingSource ,
2026-04-13 15:59:50 +02:00
templateTypeId : null ,
donorTypeId : null ,
2026-04-13 16:50:28 +02:00
runtimeBindingMaskedAbsoluteOffset : binding . runtimeBinding ? . maskedAbsoluteOffset ? ? null ,
runtimeBindingOffsetDelta : binding . runtimeBinding ? . absoluteOffsetDelta ? ? null ,
runtimeBindingVisibleCount : binding . runtimeBinding ? . visibleCount ? ? null ,
runtimeBindingRawRecordCount : binding . runtimeBinding ? . rawRecordCount ? ? null ,
2026-04-13 15:59:50 +02:00
rawWords : record . rawWords ? ? record . words ,
flipped : ( record . laneWord & 0x0002 ) !== 0 ,
width : sprite . width ,
height : sprite . height ,
originX : sprite . originX ,
originY : sprite . originY ,
2026-04-18 16:34:35 +02:00
// 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 ,
2026-04-13 15:59:50 +02:00
drawY : record . screenY - sprite . originY ,
stage : record . typeWord === 4 || ( record . laneWord & 0x0400 ) !== 0 ? 1 : 0 ,
2026-04-18 16:34:35 +02:00
// 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 ,
2026-04-13 15:59:50 +02:00
resourceKey ,
paletteDiagnostics : derivePaletteDiagnostics ( record , bundle ) ,
sprite ,
} ) ;
}
2026-04-18 14:38:40 +02:00
// 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.
2026-04-18 16:34:35 +02:00
// 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 ) => {
2026-04-18 14:38:40 +02:00
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 ;
} ;
2026-04-13 15:59:50 +02:00
items . sort ( ( left , right ) => {
if ( left . stage !== right . stage ) {
return left . stage - right . stage ;
}
2026-04-18 16:34:35 +02:00
// 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 ;
2026-04-18 14:38:40 +02:00
}
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 ;
2026-04-13 15:59:50 +02:00
}
2026-04-18 16:34:35 +02:00
const leftLayer = layerTieBreak ( left ) ;
const rightLayer = layerTieBreak ( right ) ;
if ( leftLayer !== rightLayer ) {
return leftLayer - rightLayer ;
}
2026-04-13 15:59:50 +02:00
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' ) ;
2026-04-18 16:34:35 +02:00
const outputRoot = options . outputRoot
? path . resolve ( options . outputRoot )
: path . join ( options . projectRoot , '.output' ) ;
2026-04-13 15:59:50 +02:00
await ensureDirectory ( cacheBaseRoot ) ;
await resetDirectory ( cacheRoot ) ;
await ensureDirectory ( cacheRoot ) ;
await ensureDirectory ( spriteRoot ) ;
2026-04-18 16:34:35 +02:00
if ( options . resetOutputRoot === false ) {
await ensureDirectory ( outputRoot ) ;
} else {
await resetDirectory ( outputRoot ) ;
}
2026-04-13 15:59:50 +02:00
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 }
) ;
2026-04-16 23:52:41 +02:00
// 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 ] ;
2026-04-13 16:50:28 +02:00
const runtimeMap0Correlation = options . bindingMode === 'runtime-map0-masked-proxy'
? await loadJsonIfExists ( path . join ( cacheBaseRoot , 'runtime-map0-correlation.json' ) )
: null ;
const runtimeMap0BindingResult = buildRuntimeMap0MaskedBindings ( runtimeMap0Correlation , bundles ) ;
2026-04-13 15:59:50 +02:00
const { items : sceneItems , skippedRecords } = await buildSceneItems ( region04 , recordSet . records , bundles , {
paletteSets ,
2026-04-13 16:50:28 +02:00
bindingMode : options . bindingMode ,
2026-04-13 15:59:50 +02:00
mode1RuntimePalette ,
mode1PaletteBank ,
2026-04-13 16:50:28 +02:00
runtimeMap0Bindings : runtimeMap0BindingResult . byType ,
2026-04-16 23:52:41 +02:00
overrideBundlesByType : overrideBankResult . byType ,
2026-04-13 15:59:50 +02:00
} ) ;
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 ,
2026-04-13 16:50:28 +02:00
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 ,
2026-04-13 15:59:50 +02:00
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.' ,
2026-04-13 16:50:28 +02:00
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.' ,
2026-04-13 15:59:50 +02:00
'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 ,
2026-04-18 16:34:35 +02:00
worldX : item . worldX ? ? null ,
worldY : item . worldY ? ? null ,
worldZ : item . worldZ ? ? null ,
2026-04-13 15:59:50 +02:00
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 ,
2026-04-13 16:50:28 +02:00
runtimeBindingMaskedAbsoluteOffset : item . runtimeBindingMaskedAbsoluteOffset ,
runtimeBindingOffsetDelta : item . runtimeBindingOffsetDelta ,
runtimeBindingVisibleCount : item . runtimeBindingVisibleCount ,
runtimeBindingRawRecordCount : item . runtimeBindingRawRecordCount ,
2026-04-13 15:59:50 +02:00
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 ,
2026-04-18 16:34:35 +02:00
placeholder : item . placeholder === true ,
2026-04-13 15:59:50 +02:00
} ) ) ,
} ;
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 ,
} ;
}