449 lines
12 KiB
JavaScript
449 lines
12 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]);
|
||
|
|
const PSX_SCREEN_SCALE = 2;
|
||
|
|
|
||
|
|
function uniqueSorted(values) {
|
||
|
|
return [...new Set(values)].sort((left, right) => left - right);
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|