import path from 'node:path'; function readU32LE(buffer, offset) { return buffer.readUInt32LE(offset); } function readU16LE(buffer, offset) { return buffer.readUInt16LE(offset); } const ALLOWED_LANE_WORDS = new Set([0x20, 0x22, 0x30]); // Loader-faithful projection: per `psx_project_object_main_visible` the // executable computes `screenX = Y - X` and `screenY = 2*Z - (X+Y)/2` in pixel // units with no extra scale (the view-cull box is +/-0x140 = +/-320 pixels, // matching PSX screen width). Keeping an explicit constant at 1 documents the // 1:1 world-to-screen mapping and leaves room for experimentation. const PSX_SCREEN_SCALE = 1; function uniqueSorted(values) { return [...new Set(values)].sort((left, right) => left - right); } // Loader-faithful layout per wdl_resource_bundle_load_by_index @ 0x80039444. // The 0x38-byte header at the start of an LSET*.WDL file contains 14 u32 size // words that parameterize every downstream read. Names follow the decompiled // local variable order (local_c8 is read first into file offset 0x00). const LOADER_SIZE_FIELDS = [ 'packPreamble', // +0x00 local_c8 - bytes consumed before dispatch roots inside the section pack 'dispatchRootsSize', // +0x04 local_c4 - section-0 root dispatch rows 'ctorPlacementsSize', // +0x08 local_c0 - section-0 constructor placement records (12-byte stride) 'packTailRewindSize', // +0x0c local_bc - trailing bytes in pack that are heap-rewound after load 'ctorPlacementSection',// +0x10 local_b8 - psx_ctor_placement_section_ptr block 'sectionPackBaseSize', // +0x14 local_b4 - psx_level_section_pack_base to psx_type_policy_table_ptr 'policyTableSize', // +0x18 local_b0 - psx_type_policy_table_ptr block (per-type policy bits) 'table8006754cSize', // +0x1c local_ac - auxiliary table between policy and opcode streams 'opcodeStreamsSize', // +0x20 local_a8 - psx_control_opcode_stream_table to psx_level_clut_table_ptr 'detachedBlobSize', // +0x24 local_a4 - psx_level_detached_blob (audio runtime stream) 'artInstallSize', // +0x28 local_a0 - bundle B: per-type active-header + built-resource install 'stateBankSize', // +0x2c local_9c - state-script/component/extents bank install 'overrideSize', // +0x30 local_98 - late header-only per-type active-header override stream 'stateBank2Size', // +0x34 local_94 - second state-script/component/extents bank install ]; // Layout variant for SPEC_A.WDL: the first 0x3520 bytes are a VRAM image pair // uploaded on first boot; the 0x38-byte loader header starts at offset 0x3520. // Only the last five size words (a4/a0/9c/98/94) matter — the c8..a8 slots are // leftover/undefined bytes in the SPEC_A header because it skips the section // pack entirely and goes straight to bundle A. const SPEC_A_VRAM_PRELOAD_SIZE = 0x3520; export function parseLoaderLayout(buffer, options = {}) { const variant = options.variant ?? 'lset'; const headerOffset = variant === 'spec_a' ? SPEC_A_VRAM_PRELOAD_SIZE : 0; if (buffer.length < headerOffset + 0x38) { throw new Error(`Buffer too small for loader header at 0x${headerOffset.toString(16)}`); } const sizes = {}; LOADER_SIZE_FIELDS.forEach((name, index) => { sizes[name] = readU32LE(buffer, headerOffset + index * 4); }); const blocks = []; let cursor = headerOffset + 0x38; const addBlock = (name, size) => { const entry = { name, offset: cursor, size, end: cursor + size, buffer: size > 0 && cursor + size <= buffer.length ? buffer.subarray(cursor, cursor + size) : null, }; blocks.push(entry); cursor += size; return entry; }; if (variant !== 'spec_a') { // Section pack = sum of c8..a8 read as a single contiguous CD read, then the // loader subdivides it using the same size words. const packSize = sizes.packPreamble + sizes.dispatchRootsSize + sizes.ctorPlacementsSize + sizes.packTailRewindSize + sizes.ctorPlacementSection + sizes.sectionPackBaseSize + sizes.policyTableSize + sizes.table8006754cSize + sizes.opcodeStreamsSize; addBlock('sectionPack', packSize); addBlock('detachedBlob', sizes.detachedBlobSize); } // SPEC_A skips both the section pack and the detached blob: after the // 0x38-byte header it jumps straight to bundle A. LSET reads the section // pack first, then the detached blob, then bundle B. Past this point both // variants share the same art-install / state / override / state2 sequence. addBlock('artInstall', sizes.artInstallSize); addBlock('stateBank', sizes.stateBankSize); addBlock('override', sizes.overrideSize); addBlock('stateBank2', sizes.stateBank2Size); const blocksByName = new Map(blocks.map((block) => [block.name, block])); // Subranges inside the section pack follow the loader pointer chain: // dispatch_roots = pack + packPreamble // ctor_placements = dispatch_roots + dispatchRootsSize // ctor_placement_section = ctor_placements + ctorPlacementsSize // section_pack_base = ctor_placement_section + ctorPlacementSection // policy_table = section_pack_base + sectionPackBaseSize // table_8006754c = policy_table + policyTableSize // opcode_streams = table_8006754c + table8006754cSize // (pack tail of packTailRewindSize bytes is heap-rewound) let packSubranges = []; const pack = blocksByName.get('sectionPack'); if (pack) { let sub = pack.offset; const push = (name, size) => { packSubranges.push({ name, offset: sub, size, end: sub + size, buffer: size > 0 && sub + size <= buffer.length ? buffer.subarray(sub, sub + size) : null, }); sub += size; }; push('packPreamble', sizes.packPreamble); push('dispatchRoots', sizes.dispatchRootsSize); push('ctorPlacements', sizes.ctorPlacementsSize); push('ctorPlacementSection', sizes.ctorPlacementSection); push('sectionPackBase', sizes.sectionPackBaseSize); push('policyTable', sizes.policyTableSize); push('table_8006754c', sizes.table8006754cSize); push('opcodeStreams', sizes.opcodeStreamsSize); push('packTailRewind', sizes.packTailRewindSize); } const remaining = buffer.length - cursor; return { variant, headerOffset, sizes, blocks, blocksByName, packSubranges, trailingBytes: remaining, totalConsumed: cursor, }; } export function parseLsetWdl(buffer, filePath) { if (buffer.length < 0x34) { throw new Error(`File too small for LSET header: ${filePath}`); } const headerSize = readU32LE(buffer, 0); if (headerSize < 0x34 || headerSize % 4 !== 0 || headerSize > buffer.length) { throw new Error(`Unexpected header size 0x${headerSize.toString(16)} in ${filePath}`); } const headerWords = []; for (let offset = 0; offset < headerSize; offset += 4) { headerWords.push(readU32LE(buffer, offset)); } const audioSize = readU32LE(buffer, 4); const postAudioStart = headerSize + audioSize; const sectionSizes = []; for (let offset = 0x08; offset < 0x38 && offset + 4 <= buffer.length; offset += 4) { sectionSizes.push(readU32LE(buffer, offset)); } const sections = []; let sectionCursor = postAudioStart; for (let index = 0; index < sectionSizes.length; index += 1) { const size = sectionSizes[index]; if (size <= 0 || sectionCursor + size > buffer.length) { break; } sections.push({ name: `post_audio_section_${String(index).padStart(2, '0')}`, offset: sectionCursor, size, buffer: buffer.subarray(sectionCursor, sectionCursor + size), }); sectionCursor += size; } const boundaryCandidates = uniqueSorted( headerWords .slice(2) .filter((value) => value > postAudioStart && value < buffer.length) ); if (boundaryCandidates.length < 4) { throw new Error( `Expected at least 4 post-audio boundaries, found ${boundaryCandidates.length} in ${filePath}` ); } const selectedBoundaries = boundaryCandidates.slice(0, 4); const regions = []; const regionStarts = [postAudioStart, ...selectedBoundaries]; const regionEnds = [...selectedBoundaries, buffer.length]; regions.push({ name: 'audio_or_spu_blob', offset: headerSize, size: audioSize, buffer: buffer.subarray(headerSize, postAudioStart), }); for (let index = 0; index < regionStarts.length; index += 1) { const offset = regionStarts[index]; const end = regionEnds[index]; regions.push({ name: `post_audio_region_${String(index).padStart(2, '0')}`, offset, size: end - offset, buffer: buffer.subarray(offset, end), }); } return { filePath, fileName: path.basename(filePath), buffer, headerSize, audioSize, postAudioStart, headerWords, sectionSizes, sections, boundaryCandidates, regions, loaderLayout: (() => { try { return parseLoaderLayout(buffer, { variant: 'lset' }); } catch { return null; } })(), }; } function isPlausibleRecord(words) { const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words; if (typeWord < 0x20 || typeWord > 0x1ff) { return false; } if ((xWord | yWord | zWord) === 0) { return false; } if (laneWord === 0 || laneWord > 0x1fff) { return false; } if (selectorWord > 0x03ff) { return false; } return true; } function isStructuredCandidate(words) { const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words; if (typeWord >= 0x200) { return false; } if (xWord === 0 && yWord === 0) { return false; } if (xWord >= 0x4000 || yWord >= 0x4000) { return false; } if (zWord > 0x20 || selectorWord > 0x04) { return false; } if (!ALLOWED_LANE_WORDS.has(laneWord)) { return false; } return true; } function buildRecord(words, source, offset, rawWords = words) { if (!isPlausibleRecord(words)) { return null; } const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words; const screenX = (yWord - xWord) * PSX_SCREEN_SCALE; const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE; return { index: -1, source, offset, words, rawWords, typeWord, xWord, yWord, zWord, selectorWord, laneWord, screenX, screenY, sourceFamily: null, sourceRole: null, recordSide: null, rowIndex: -1, }; } function decodeRecord(buffer, offset, source) { const words = []; for (let cursor = 0; cursor < 12; cursor += 2) { words.push(readU16LE(buffer, offset + cursor)); } return buildRecord(words, source, offset, words); } function makeAsciiPreview(buffer, length = 64) { const slice = buffer.subarray(0, Math.min(length, buffer.length)); let text = ''; for (const value of slice) { text += value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : '.'; } return text; } function scanOffsetTableCandidates(buffer, maxBase = 0x200) { const candidates = []; const limit = Math.min(maxBase, Math.max(0, buffer.length - 8)); for (let base = 0; base <= limit; base += 2) { const count = readU16LE(buffer, base); if (count <= 0 || count >= 0x200) { continue; } const tableEnd = base + 2 + count * 2; if (tableEnd > buffer.length) { continue; } let previous = -1; let monotonic = true; const firstOffsets = []; for (let index = 0; index < count; index += 1) { const offset = readU16LE(buffer, base + 2 + index * 2); if (index < 8) { firstOffsets.push(offset); } if (offset < previous || offset >= buffer.length) { monotonic = false; break; } previous = offset; } if (monotonic) { candidates.push({ base, count, firstOffsets }); } } return candidates; } function scanPlausible12ByteRecordStarts(buffer, maxBase = 0x200) { const starts = []; const limit = Math.min(maxBase, Math.max(0, buffer.length - 12)); for (let base = 0; base <= limit; base += 2) { const words = []; for (let cursor = 0; cursor < 12; cursor += 2) { words.push(readU16LE(buffer, base + cursor)); } if (isPlausibleRecord(words)) { starts.push({ base, words }); } } return starts; } function buildPreviewRows(buffer, rowWordWidth = 8, rowCount = 24) { const rows = []; const maxRows = Math.min(rowCount, Math.floor(buffer.length / (rowWordWidth * 2))); for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) { const offset = rowIndex * rowWordWidth * 2; const words = []; for (let wordIndex = 0; wordIndex < rowWordWidth; wordIndex += 1) { words.push(readU16LE(buffer, offset + wordIndex * 2)); } const bytes = buffer.subarray(offset, offset + rowWordWidth * 2); rows.push({ rowIndex, offset, words, ascii: makeAsciiPreview(bytes, bytes.length), }); } return rows; } export function summarizeRegion02(region) { const firstU32 = []; const firstU16 = []; for (let offset = 0; offset + 4 <= region.buffer.length && firstU32.length < 8; offset += 4) { firstU32.push(readU32LE(region.buffer, offset)); } for (let offset = 0; offset + 2 <= region.buffer.length && firstU16.length < 16; offset += 2) { firstU16.push(readU16LE(region.buffer, offset)); } const offsetTableCandidates = scanOffsetTableCandidates(region.buffer); const plausible12ByteRecordStarts = scanPlausible12ByteRecordStarts(region.buffer); return { offset: region.offset, size: region.size, firstU32, firstU16, asciiPreview: makeAsciiPreview(region.buffer, 96), previewRows: buildPreviewRows(region.buffer), offsetTableCandidates: offsetTableCandidates.slice(0, 16), plausible12ByteRecordStarts: plausible12ByteRecordStarts.slice(0, 16), note: offsetTableCandidates.length === 0 && plausible12ByteRecordStarts.length === 0 ? 'Leading region-02 bytes do not look like a count-prefixed offset table or direct 12-byte placement rows.' : 'Region-02 exposes candidate structure and should be correlated against live loader-installed subordinate slices.', }; } export function parseRegion00Records(region) { const rowCount = region.buffer.length >= 4 ? readU32LE(region.buffer, 0) : 0; const records = []; for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { const rowBase = 4 + rowIndex * 24; if (rowBase + 24 > region.buffer.length) { break; } const rowWords = []; for (let wordIndex = 0; wordIndex < 12; wordIndex += 1) { rowWords.push(readU16LE(region.buffer, rowBase + wordIndex * 2)); } const leftRawWords = rowWords.slice(0, 6); const rightRawWords = rowWords.slice(6, 12); const leftWords = [rowWords[4], rowWords[5], rowWords[0], rowWords[1], rowWords[2], rowWords[3]]; const rightWords = [rowWords[10], rowWords[11], rowWords[6], rowWords[7], rowWords[8], rowWords[9]]; for (const [recordSide, wordSet, rawWordSet, sourceByteOffset] of [ ['left', leftWords, leftRawWords, 0], ['right', rightWords, rightRawWords, 12], ]) { const record = buildRecord(wordSet, 'region00', rowBase + sourceByteOffset, rawWordSet); if (!record) { continue; } record.sourceFamily = 'section0_dispatch_roots'; record.sourceRole = 'root-dispatch'; record.rowIndex = rowIndex; record.recordSide = recordSide; records.push(record); } } records.forEach((record, index) => { record.index = index; record.absoluteOffset = region.offset + record.offset; }); return { source: 'region00', recordStartOffset: 4, records, }; } function detectStructured12ByteStream(buffer) { let bestCandidate = null; for (let headerOffset = 0; headerOffset + 16 <= buffer.length; headerOffset += 4) { const count = readU32LE(buffer, headerOffset); const recordStartOffset = headerOffset + 4; const maxPossibleCount = Math.floor((buffer.length - recordStartOffset) / 12); if (count === 0 || count > maxPossibleCount) { continue; } let prefixStructuredCount = 0; for (let index = 0; index < count; index += 1) { const recordOffset = recordStartOffset + index * 12; const words = []; for (let cursor = 0; cursor < 12; cursor += 2) { words.push(readU16LE(buffer, recordOffset + cursor)); } if (!isStructuredCandidate(words)) { break; } prefixStructuredCount += 1; } if (prefixStructuredCount < 16) { continue; } if ( !bestCandidate || prefixStructuredCount > bestCandidate.prefixStructuredCount || (prefixStructuredCount === bestCandidate.prefixStructuredCount && headerOffset < bestCandidate.headerOffset) ) { bestCandidate = { headerOffset, recordStartOffset, count, prefixStructuredCount, }; } } return bestCandidate; } export function parseRegion01Records(region) { const records = []; const stream = detectStructured12ByteStream(region.buffer); if (stream) { for (let index = 0; index < stream.count; index += 1) { const recordOffset = stream.recordStartOffset + index * 12; if (recordOffset + 12 > region.buffer.length) { break; } const record = decodeRecord(region.buffer, recordOffset, 'region01'); if (!record || !isStructuredCandidate(record.words)) { continue; } record.sourceFamily = 'section0_constructor_placements'; record.sourceRole = 'constructor-placement'; record.rowIndex = index; record.recordSide = null; records.push(record); } } else { for (let rowOffset = 0; rowOffset + 24 <= region.buffer.length; rowOffset += 24) { const left = decodeRecord(region.buffer, rowOffset, 'region01-left'); const right = decodeRecord(region.buffer, rowOffset + 12, 'region01-right'); if (left && isStructuredCandidate(left.words)) { left.sourceFamily = 'section0_constructor_placements'; left.sourceRole = 'constructor-placement'; left.rowIndex = Math.floor(rowOffset / 24); left.recordSide = 'left'; records.push(left); } if (right && isStructuredCandidate(right.words)) { right.sourceFamily = 'section0_constructor_placements'; right.sourceRole = 'constructor-placement'; right.rowIndex = Math.floor(rowOffset / 24); right.recordSide = 'right'; records.push(right); } } } records.forEach((record, index) => { record.index = index; record.absoluteOffset = region.offset + record.offset; }); return { source: 'region01', recordStartOffset: stream?.recordStartOffset ?? 0, streamHeaderOffset: stream?.headerOffset ?? null, streamRecordCount: stream?.count ?? null, streamStructuredPrefixCount: stream?.prefixStructuredCount ?? null, records, }; } // Loader-faithful 12-byte constructor-placement records straight out of the // `ctorPlacements` subrange of the section pack. Layout per // `psx_dispatch_section0_constructor_placements @ 0x800258cc`: // [u32 count][count * { u16 typeWord; u16 X; u16 Y; u16 Z; u16 selector; // u16 flags }] // Each record is called as `descriptor_table[typeWord].slot0(record, 0)` and // `psx_object_create_compound_record` then reads exactly those 6 u16 fields. export function parseCtorPlacementsBlock(block, variant = 'lset') { if (!block || !block.buffer || block.size < 4) { return { source: 'ctorPlacements', records: [], count: 0 }; } const count = readU32LE(block.buffer, 0); const records = []; if (count === 0 || count > 0x2000) { return { source: 'ctorPlacements', records, count }; } for (let index = 0; index < count; index += 1) { const recordOffset = 4 + index * 12; if (recordOffset + 12 > block.buffer.length) { break; } const words = []; for (let cursor = 0; cursor < 12; cursor += 2) { words.push(readU16LE(block.buffer, recordOffset + cursor)); } const record = buildRecord(words, 'ctorPlacements', recordOffset, words); if (!record) { continue; } record.sourceFamily = 'section0_constructor_placements'; record.sourceRole = 'constructor-placement'; record.rowIndex = index; record.authoredLayer = 'constructors'; record.authoredVariant = variant; records.push(record); } records.forEach((record, index) => { record.index = index; record.absoluteOffset = block.offset + record.offset; }); return { source: 'ctorPlacements', recordStartOffset: 4, reportedCount: count, records, }; } // Loader-faithful 24-byte dispatch-root records from the `dispatchRoots` // subrange of the section pack. Layout per // `psx_dispatch_section0_dispatch_roots @ 0x800256b0`: // [u32 count][count * 24 bytes] // Within each record the dispatcher reads: // +4 u16 typeId (argument to descriptor_table[typeId]) // +8 u16 screenX (for +/-0x140 cull) // +10 u16 screenY (for +/-0x140 cull) // +16 u16 flags (bit 3 = skip this record) // Z, selector and lane are not universally used by the dispatcher. The empirical // best mapping that matches constructor-placement convention is: // +6 u16 zeta (often 0) // +12 u16 selector/rotation // +14 u16 lane/flags-lo // We keep them in rawWords so downstream consumers can probe further, but // buildRecord uses the cull-verified X/Y/typeId for positioning. export function parseDispatchRootsBlock(block, variant = 'lset') { if (!block || !block.buffer || block.size < 4) { return { source: 'dispatchRoots', records: [], count: 0 }; } const count = readU32LE(block.buffer, 0); const records = []; if (count === 0 || count > 0x2000) { return { source: 'dispatchRoots', records, count }; } for (let index = 0; index < count; index += 1) { const recordOffset = 4 + index * 24; if (recordOffset + 24 > block.buffer.length) { break; } const rawWords = []; for (let cursor = 0; cursor < 24; cursor += 2) { rawWords.push(readU16LE(block.buffer, recordOffset + cursor)); } // Project into buildRecord's 6-word ctor-placement shape using the fields // the live dispatcher reads. rawWords indices: // [0..1] (+0..+3) prefix // [2] (+4) typeId (dispatch) // [3] (+6) zeta / z-ish // [4] (+8) X (cull) // [5] (+10) Y (cull) // [6] (+12) selector-ish // [7] (+14) lane-ish // [8] (+16) flags (bit 3 skip) // [9..11] trailing const flags = rawWords[8]; if ((flags & 0x8) !== 0) { continue; } const typeWord = rawWords[2]; const xWord = rawWords[4]; const yWord = rawWords[5]; const zWord = rawWords[3] & 0xff; const selectorWord = rawWords[6]; const laneWord = rawWords[7]; // Relaxed plausibility: dispatch-root records can have lane==0 or large // selectors (e.g. behavior opcodes) because the dispatcher only reads // typeId, X, Y, and the +0x10 skip flag. We only require typeId in the // scene-relevant range and X/Y not both zero. This is looser than // `isPlausibleRecord` on purpose. if (typeWord < 0x20 || typeWord > 0x1ff) { continue; } if ((xWord | yWord) === 0) { continue; } const words = [typeWord, xWord, yWord, zWord, selectorWord, laneWord]; const screenX = (yWord - xWord) * PSX_SCREEN_SCALE; const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE; const record = { index: -1, source: 'dispatchRoots', offset: recordOffset, words, rawWords: words, typeWord, xWord, yWord, zWord, selectorWord, laneWord, screenX, screenY, sourceFamily: 'section0_dispatch_roots', sourceRole: 'root-dispatch', recordSide: null, rowIndex: index, authoredLayer: 'roots', authoredVariant: variant, dispatchRootRawWords: rawWords, dispatchRootFlags: flags, }; records.push(record); } records.forEach((record, index) => { record.index = index; record.absoluteOffset = block.offset + record.offset; }); return { source: 'dispatchRoots', recordStartOffset: 4, reportedCount: count, records, }; }