748 lines
24 KiB
JavaScript
748 lines
24 KiB
JavaScript
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,
|
|
};
|
|
}
|