psx map standalone exporter

This commit is contained in:
Marco 2026-04-13 15:59:50 +02:00
commit 2f243976b6
16 changed files with 3254 additions and 5 deletions

View file

@ -0,0 +1,475 @@
import { PNG } from 'pngjs';
function readU32LE(buffer, offset) {
return buffer.readUInt32LE(offset);
}
function readU16LE(buffer, offset) {
return buffer.readUInt16LE(offset);
}
function rowByteWidth(width, mode) {
return mode === 2 ? Math.ceil(width / 2) : width;
}
function psx555ToRgba(color) {
const red = (color & 0x1f) * 255 / 31;
const green = ((color >> 5) & 0x1f) * 255 / 31;
const blue = ((color >> 10) & 0x1f) * 255 / 31;
const alpha = (color & 0x7fff) === 0 ? 0 : 255;
return {
red: Math.round(red),
green: Math.round(green),
blue: Math.round(blue),
alpha,
};
}
export function extractPaletteSets(buffer, headerWords) {
if (!Array.isArray(headerWords) || headerWords.length < 4) {
return { palettes16: [], palettes256: [] };
}
const paletteOffset = headerWords[2];
const paletteSize = headerWords[3];
if (paletteSize !== 0x1000 || paletteOffset < 0 || paletteOffset + paletteSize > buffer.length) {
return { palettes16: [], palettes256: [] };
}
const blob = buffer.subarray(paletteOffset, paletteOffset + paletteSize);
const palettes16 = [];
const palettes256 = [];
for (let offset = 0; offset + 0x20 <= blob.length; offset += 0x20) {
const palette = [];
for (let entry = 0; entry < 16; entry += 1) {
palette.push(readU16LE(blob, offset + entry * 2));
}
palettes16.push(palette);
}
for (let offset = 0; offset + 0x200 <= blob.length; offset += 0x200) {
const palette = [];
for (let entry = 0; entry < 256; entry += 1) {
palette.push(readU16LE(blob, offset + entry * 2));
}
palettes256.push(palette);
}
return { palettes16, palettes256 };
}
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
if (!Array.isArray(palettes16) || palettes16.length < 16) {
return null;
}
if (!Number.isInteger(startIndex) || startIndex < 0 || startIndex + 16 > palettes16.length) {
return null;
}
const palette = [];
for (let paletteIndex = startIndex; paletteIndex < startIndex + 16; paletteIndex += 1) {
const clut = palettes16[paletteIndex];
if (!Array.isArray(clut) || clut.length < 16) {
return null;
}
palette.push(...clut.slice(0, 16));
}
return palette.length === 256 ? palette : null;
}
export function buildMode1RuntimePalette(palettes16) {
return buildMode1RuntimePaletteForIndex(palettes16, 0);
}
export function extractMode1PaletteFromGpuRamDump(buffer, row = 0xf0, startX = 0) {
const vramWidthWords = 1024;
const vramHeight = 512;
const expectedSize = vramWidthWords * vramHeight * 2;
if (!buffer || buffer.length < expectedSize) {
return null;
}
if (!Number.isInteger(row) || row < 0 || row >= vramHeight) {
return null;
}
if (!Number.isInteger(startX) || startX < 0 || startX + 256 > vramWidthWords) {
return null;
}
const palette = [];
const rowStart = (row * vramWidthWords * 2) + (startX * 2);
for (let index = 0; index < 256; index += 1) {
palette.push(readU16LE(buffer, rowStart + index * 2));
}
return palette;
}
export function buildMode1PaletteBank(palettes16) {
if (!Array.isArray(palettes16) || palettes16.length < 16) {
return [];
}
const paletteBank = [];
for (let startIndex = 0; startIndex < palettes16.length; startIndex += 1) {
const palette = buildMode1RuntimePaletteForIndex(palettes16, startIndex);
if (palette?.length === 256) {
paletteBank[startIndex] = palette;
}
}
return paletteBank;
}
export function choosePalette(palettes16, frames, mode) {
if (mode !== 2 || !Array.isArray(palettes16) || palettes16.length === 0) {
return null;
}
const usedIndices = new Set();
for (const frame of frames) {
const rawPixels = frame.rawPixels;
if (!rawPixels) {
continue;
}
for (const value of rawPixels) {
usedIndices.add(value & 0x0f);
usedIndices.add((value >> 4) & 0x0f);
}
}
usedIndices.delete(0);
if (usedIndices.size === 0) {
return 0;
}
let bestIndex = null;
let bestScore = -1;
for (let paletteIndex = 0; paletteIndex < palettes16.length; paletteIndex += 1) {
const palette = palettes16[paletteIndex];
const distinct = new Set();
for (const index of usedIndices) {
distinct.add((palette[index] ?? 0) & 0x7fff);
}
let channelSpread = 0;
let nonZeroCount = 0;
for (const value of distinct) {
if (value === 0) {
continue;
}
nonZeroCount += 1;
const rgba = psx555ToRgba(value);
channelSpread += rgba.red + rgba.green + rgba.blue;
}
if (nonZeroCount === 0) {
continue;
}
const score = nonZeroCount * 100000 + channelSpread;
if (score > bestScore) {
bestScore = score;
bestIndex = paletteIndex;
}
}
return bestIndex;
}
function isValidBundleHeader(buffer, offset) {
if (offset + 0x34 > buffer.length) {
return false;
}
const kind = readU32LE(buffer, offset + 0x00);
const width = readU32LE(buffer, offset + 0x08);
const height = readU32LE(buffer, offset + 0x0c);
const mode = readU32LE(buffer, offset + 0x10);
const dataOffset = readU32LE(buffer, offset + 0x1c);
const frameCount = readU32LE(buffer, offset + 0x20);
const frameTableOffset = readU32LE(buffer, offset + 0x24);
if (kind !== 4 && kind !== 5) {
return false;
}
if (width === 0 || height === 0 || width > 512 || height > 512) {
return false;
}
if (mode !== 1 && mode !== 2) {
return false;
}
if (frameCount === 0 || frameCount > 256) {
return false;
}
if (offset + dataOffset > buffer.length) {
return false;
}
const recordTableSize = frameCount * 20;
if (dataOffset < 0x34 + recordTableSize) {
return false;
}
if (frameTableOffset !== 0x34) {
return false;
}
return true;
}
export function scanSpriteBundles(region) {
const bundles = [];
const seenRanges = [];
for (let offset = 0; offset + 0x34 <= region.buffer.length; offset += 4) {
if (!isValidBundleHeader(region.buffer, offset)) {
continue;
}
if (seenRanges.some(([start, end]) => offset >= start && offset < end)) {
continue;
}
const kind = readU32LE(region.buffer, offset + 0x00);
const width = readU32LE(region.buffer, offset + 0x08);
const height = readU32LE(region.buffer, offset + 0x0c);
const mode = readU32LE(region.buffer, offset + 0x10);
const paletteIndex = readU32LE(region.buffer, offset + 0x14);
const dataOffset = readU32LE(region.buffer, offset + 0x1c);
const frameCount = readU32LE(region.buffer, offset + 0x20);
const frameTableOffset = 0x34;
if (paletteIndex > 127) {
continue;
}
const frames = [];
let valid = true;
for (let index = 0; index < frameCount; index += 1) {
const entryOffset = offset + frameTableOffset + (index * 20);
const flags = readU32LE(region.buffer, entryOffset + 0x00);
const relativeDataOffset = readU32LE(region.buffer, entryOffset + 0x08);
const frameWidth = readU16LE(region.buffer, entryOffset + 0x0c);
const frameHeight = readU16LE(region.buffer, entryOffset + 0x0e);
const originX = readU16LE(region.buffer, entryOffset + 0x10);
const originY = readU16LE(region.buffer, entryOffset + 0x12);
const dataStart = offset + dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
if (
frameWidth === 0 ||
frameHeight === 0 ||
frameWidth > 512 ||
frameHeight > 512 ||
dataStart >= region.buffer.length ||
(((flags & 1) === 0) && (dataStart + rawSize > region.buffer.length))
) {
valid = false;
break;
}
let consumed;
if ((flags & 1) === 1) {
const decoded = decodeRleRows(region.buffer, dataStart, frameWidth, frameHeight, mode);
if (!decoded) {
valid = false;
break;
}
consumed = decoded.consumed;
} else {
consumed = rawSize;
}
frames.push({
index,
consumed,
relativeDataOffset,
width: frameWidth,
height: frameHeight,
originX,
originY,
flags,
dataStart,
absoluteDataStart: region.offset + dataStart,
});
}
if (!valid) {
continue;
}
seenRanges.push([offset, offset + dataOffset]);
bundles.push({
slot: bundles.length,
offsetInRegion: offset,
absoluteOffset: region.offset + offset,
kind,
width,
height,
mode,
paletteIndex,
dataOffset,
frameCount,
frameTableOffset,
frames,
});
}
return bundles;
}
function decodeRleRows(buffer, start, width, height, mode) {
const expectedSize = rowByteWidth(width, mode) * height;
const output = [];
let cursor = start;
let rows = 0;
while (rows < height) {
if (cursor >= buffer.length) {
return null;
}
const controlByte = buffer[cursor];
cursor += 1;
const signedControl = controlByte < 0x80 ? controlByte : controlByte - 0x100;
if (signedControl === 0) {
rows += 1;
continue;
}
if (signedControl < 0) {
const count = controlByte & 0x7f;
if (cursor + count > buffer.length) {
return null;
}
output.push(...buffer.subarray(cursor, cursor + count));
cursor += count;
} else {
if (cursor >= buffer.length) {
return null;
}
const value = buffer[cursor];
cursor += 1;
for (let repeat = 0; repeat < signedControl; repeat += 1) {
output.push(value);
}
}
if (output.length > expectedSize) {
return null;
}
}
if (output.length !== expectedSize) {
return null;
}
return {
rawPixels: Buffer.from(output),
consumed: cursor - start,
};
}
function decodeIndexedPixels(rawPixels, width, height, mode) {
if (mode === 2) {
const indexed = Buffer.alloc(width * height, 0);
let source = 0;
let target = 0;
const rowBytes = rowByteWidth(width, mode);
for (let row = 0; row < height; row += 1) {
const rowEnd = Math.min(source + rowBytes, rawPixels.length);
while (source < rowEnd && target < indexed.length) {
const value = rawPixels[source];
source += 1;
indexed[target] = value & 0x0f;
target += 1;
if (target < indexed.length) {
indexed[target] = (value >> 4) & 0x0f;
target += 1;
}
}
}
return indexed;
}
return Buffer.from(rawPixels.subarray(0, width * height));
}
function indexedToGrayscaleRgba(pixels, mode) {
const rgba = Buffer.alloc(pixels.length * 4, 0);
for (let index = 0; index < pixels.length; index += 1) {
const sourceValue = pixels[index];
const value = mode === 2 ? sourceValue * 17 : sourceValue;
const out = index * 4;
rgba[out + 0] = value;
rgba[out + 1] = value;
rgba[out + 2] = value;
rgba[out + 3] = value === 0 ? 0 : 255;
}
return rgba;
}
function indexedToColorRgba(pixels, palette) {
const rgba = Buffer.alloc(pixels.length * 4, 0);
for (let index = 0; index < pixels.length; index += 1) {
const paletteIndex = pixels[index];
const color = palette[paletteIndex] ?? 0;
const converted = psx555ToRgba(color);
const out = index * 4;
rgba[out + 0] = converted.red;
rgba[out + 1] = converted.green;
rgba[out + 2] = converted.blue;
rgba[out + 3] = paletteIndex === 0 ? 0 : converted.alpha;
}
return rgba;
}
export function decodeBundleFrame(region, bundle, frameIndex, palette = null) {
const frame = bundle.frames[Math.max(0, Math.min(frameIndex, bundle.frames.length - 1))];
const rawSize = rowByteWidth(frame.width, bundle.mode) * frame.height;
let rawPixels;
let consumed;
if ((frame.flags & 1) === 1) {
const decoded = decodeRleRows(region.buffer, frame.dataStart, frame.width, frame.height, bundle.mode);
if (!decoded) {
throw new Error(`Failed to decode RLE frame at 0x${frame.absoluteDataStart.toString(16)}`);
}
rawPixels = decoded.rawPixels;
consumed = decoded.consumed;
} else {
if (frame.dataStart + rawSize > region.buffer.length) {
throw new Error(`Frame overruns bundle region at 0x${frame.absoluteDataStart.toString(16)}`);
}
rawPixels = region.buffer.subarray(frame.dataStart, frame.dataStart + rawSize);
consumed = rawSize;
}
const indexedPixels = decodeIndexedPixels(rawPixels, frame.width, frame.height, bundle.mode);
const rgba = Array.isArray(palette)
? indexedToColorRgba(indexedPixels, palette)
: indexedToGrayscaleRgba(indexedPixels, bundle.mode);
return {
...frame,
consumed,
rawPixels: Buffer.from(rawPixels),
indexedPixels,
requestedFrameIndex: frameIndex,
clampedFrameIndex: frame.index,
rgba,
};
}
export function encodePng(rgba, width, height) {
const png = new PNG({ width, height });
png.data = Buffer.from(rgba);
return PNG.sync.write(png);
}

160
psx-map-exporter/src/cli.js Normal file
View file

@ -0,0 +1,160 @@
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { exportMap } from './export-map.js';
function parseArgs(argv) {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const options = {
discRoot: path.resolve(
moduleDir,
'..',
'..',
'..',
'Crusader-Map-Viewer',
'map_renderer',
'STATIC_PSX'
),
gpuRamDump: path.resolve(
moduleDir,
'..',
'..',
'binary',
'Crusader - No Remorse (USA) GPU RAM 2.bin'
),
mapSource: 'auto',
sceneScope: 'probe',
validationBundles: [],
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === '--source') {
options.source = next;
index += 1;
continue;
}
if (arg === '--wdl') {
options.wdl = next;
index += 1;
continue;
}
if (arg === '--disc-root') {
options.discRoot = path.resolve(next);
index += 1;
continue;
}
if (arg === '--map-source') {
options.mapSource = next;
index += 1;
continue;
}
if (arg === '--scene-scope') {
options.sceneScope = next;
index += 1;
continue;
}
if (arg === '--gpu-ram-dump') {
options.gpuRamDump = path.resolve(next);
index += 1;
continue;
}
if (arg === '--validation-bundles') {
options.validationBundles = String(next)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
index += 1;
continue;
}
if (arg === '--out-name') {
options.outName = next;
index += 1;
continue;
}
if (arg === '--debug-labels') {
options.debugLabels = true;
continue;
}
if (arg === '--help' || arg === '-h') {
options.help = true;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printHelp() {
console.log([
'Usage: node src/cli.js (--source LSET1/L0.WDL | --wdl <file>) [options]',
'',
'Options:',
' --source <relative path> WDL path relative to the PSX disc root',
' --wdl <file> Direct WDL path',
' --disc-root <path> PSX asset root, defaults to STATIC_PSX in the sibling workspace',
' --scene-scope <probe|full> Probe is supported; full is intentionally disabled until raw floor and full-map decode is recovered',
' --gpu-ram-dump <path> PSX GPU RAM dump used for live mode-1 palette extraction',
' --validation-bundles <csv> Comma-separated bundle absolute offsets (hex or decimal) for bundle palette-sweep validation sheets',
' --map-source <auto|combined|layered|constructors|roots|region01|region00>',
' --out-name <stem> Override the output stem',
' --debug-labels Write an additional labeled scene PNG for item identification',
'',
'Notes:',
' auto now prefers a layered probe that combines constructor placements with root-dispatch rows.',
' combined/layered explicitly renders both authored section-0 lanes together.',
' roots/region00 keeps the smaller section-0 root-dispatch probe for comparison.',
].join('\n'));
}
async function main() {
const options = parseArgs(process.argv);
if (options.help) {
printHelp();
return;
}
if (!options.source && !options.wdl) {
printHelp();
throw new Error('Either --source or --wdl is required.');
}
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const wdlPath = options.wdl
? path.resolve(options.wdl)
: path.resolve(options.discRoot, options.source);
const result = await exportMap({
projectRoot,
wdlPath,
sourceRelPath: options.source,
mapSource: options.mapSource,
sceneScope: options.sceneScope,
gpuRamDumpPath: options.gpuRamDump,
validationBundles: options.validationBundles,
outName: options.outName,
debugLabels: Boolean(options.debugLabels),
});
console.log(JSON.stringify({
sourceFile: wdlPath,
mapStem: result.mapStem,
recordCount: result.summary.recordCount,
renderableItemCount: result.summary.renderableItemCount,
bundleCount: result.summary.bundleCount,
outputPngPath: result.outputPngPath,
debugPngPath: result.debugPngPath,
outputJsonPath: result.outputJsonPath,
validationOutputs: result.validationOutputs,
region02Example: result.region02Example,
}, null, 2));
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,169 @@
import { encodePng } from './bundles.js';
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
const GLYPHS = {
'0': ['111', '101', '101', '101', '111'],
'1': ['010', '110', '010', '010', '111'],
'2': ['111', '001', '111', '100', '111'],
'3': ['111', '001', '111', '001', '111'],
'4': ['101', '101', '111', '001', '001'],
'5': ['111', '100', '111', '001', '111'],
'6': ['111', '100', '111', '101', '111'],
'7': ['111', '001', '001', '001', '001'],
'8': ['111', '101', '111', '101', '111'],
'9': ['111', '101', '111', '001', '111'],
};
function clearCanvas(width, height, background = null) {
const canvas = Buffer.alloc(width * height * 4, 0);
if (!background) {
return canvas;
}
fillRect(
canvas,
width,
height,
0,
0,
width,
height,
background.red ?? 0,
background.green ?? 0,
background.blue ?? 0,
background.alpha ?? 255,
);
return canvas;
}
function fillRect(canvas, canvasWidth, canvasHeight, x, y, width, height, red, green, blue, alpha) {
const startX = Math.max(0, x);
const startY = Math.max(0, y);
const endX = Math.min(canvasWidth, x + width);
const endY = Math.min(canvasHeight, y + height);
for (let drawY = startY; drawY < endY; drawY += 1) {
for (let drawX = startX; drawX < endX; drawX += 1) {
const target = ((drawY * canvasWidth) + drawX) * 4;
canvas[target + 0] = red;
canvas[target + 1] = green;
canvas[target + 2] = blue;
canvas[target + 3] = alpha;
}
}
}
function drawGlyph(canvas, canvasWidth, canvasHeight, glyph, x, y, red, green, blue, alpha) {
const rows = GLYPHS[glyph];
if (!rows) {
return;
}
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex];
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
if (row[columnIndex] !== '1') {
continue;
}
fillRect(canvas, canvasWidth, canvasHeight, x + columnIndex, y + rowIndex, 1, 1, red, green, blue, alpha);
}
}
}
function drawLabel(canvas, canvasWidth, canvasHeight, text, x, y) {
const label = String(text);
const glyphWidth = 3;
const glyphHeight = 5;
const spacing = 1;
const boxWidth = (label.length * (glyphWidth + spacing)) - spacing + 2;
const boxHeight = glyphHeight + 2;
fillRect(canvas, canvasWidth, canvasHeight, x, y, boxWidth, boxHeight, 0, 0, 0, 220);
let cursorX = x + 1;
for (const glyph of label) {
drawGlyph(canvas, canvasWidth, canvasHeight, glyph, cursorX, y + 1, 255, 255, 0, 255);
cursorX += glyphWidth + spacing;
}
}
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
for (let y = 0; y < sprite.height; y += 1) {
const canvasY = dstY + y;
if (canvasY < 0 || canvasY >= canvasHeight) {
continue;
}
for (let x = 0; x < sprite.width; x += 1) {
const canvasX = dstX + x;
if (canvasX < 0 || canvasX >= canvasWidth) {
continue;
}
const sourceX = flipped ? (sprite.width - 1 - x) : x;
const source = ((y * sprite.width) + sourceX) * 4;
const alpha = sprite.rgba[source + 3];
if (alpha === 0) {
continue;
}
const target = ((canvasY * canvasWidth) + canvasX) * 4;
canvas[target + 0] = sprite.rgba[source + 0];
canvas[target + 1] = sprite.rgba[source + 1];
canvas[target + 2] = sprite.rgba[source + 2];
canvas[target + 3] = alpha;
}
}
}
export function renderMap(items, options = {}) {
if (items.length === 0) {
throw new Error('No renderable scene items were produced.');
}
const bounds = items.reduce(
(state, item) => ({
minX: Math.min(state.minX, item.drawX),
minY: Math.min(state.minY, item.drawY),
maxX: Math.max(state.maxX, item.drawX + item.width),
maxY: Math.max(state.maxY, item.drawY + item.height),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
);
const padding = 16;
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
const height = Math.max(1, (bounds.maxY - bounds.minY) + (padding * 2));
const canvas = clearCanvas(width, height, options.background ?? DEFAULT_BACKGROUND);
for (const item of items) {
blitRgba(
canvas,
width,
height,
item.sprite,
item.drawX - bounds.minX + padding,
item.drawY - bounds.minY + padding,
Boolean(item.flipped)
);
}
if (options.drawLabels) {
for (const item of items) {
drawLabel(
canvas,
width,
height,
item.labelId ?? item.id,
item.drawX - bounds.minX + padding,
item.drawY - bounds.minY + padding
);
}
}
return {
width,
height,
bounds,
png: encodePng(canvas, width, height),
};
}

449
psx-map-exporter/src/wdl.js Normal file
View file

@ -0,0 +1,449 @@
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,
};
}