psx map improvement

This commit is contained in:
MaddoScientisto 2026-04-16 23:52:41 +02:00
commit 9fe261610f
14 changed files with 859 additions and 483 deletions

View file

@ -231,96 +231,119 @@ export function scanSpriteBundles(region) {
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) {
const payloadBuffer = region.buffer.subarray(offset);
const bundle = extractBundleFromHeader(payloadBuffer, region.offset + offset);
if (!bundle) {
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,
});
bundle.slot = bundles.length;
bundle.offsetInRegion = offset;
bundle.sourceBuffer = payloadBuffer;
bundle.bindingSource = bundle.bindingSource ?? 'raw-scan';
seenRanges.push([offset, offset + bundle.dataOffset]);
bundles.push(bundle);
}
return bundles;
}
// Parses a single kind-4/kind-5 drawable-header bundle starting at offset 0 of
// `payloadBuffer`. Returns null if the header is not a plausible bundle.
//
// `absoluteHeaderOffset` is the file-level offset of the header, used to tag
// the returned bundle and its frames for cache metadata / diagnostics. Frame
// decode later needs the same `payloadBuffer` (or a region whose `buffer`
// slice starts at the header) so that `frame.dataStart` lands correctly.
export function extractBundleFromHeader(payloadBuffer, absoluteHeaderOffset) {
if (!payloadBuffer || payloadBuffer.length < 0x34) {
return null;
}
if (!isValidBundleHeader(payloadBuffer, 0)) {
return null;
}
const kind = readU32LE(payloadBuffer, 0x00);
const width = readU32LE(payloadBuffer, 0x08);
const height = readU32LE(payloadBuffer, 0x0c);
const mode = readU32LE(payloadBuffer, 0x10);
const paletteIndex = readU32LE(payloadBuffer, 0x14);
const dataOffset = readU32LE(payloadBuffer, 0x1c);
const frameCount = readU32LE(payloadBuffer, 0x20);
const frameTableOffset = 0x34;
if (paletteIndex > 127) {
return null;
}
const frames = [];
for (let index = 0; index < frameCount; index += 1) {
const entryOffset = frameTableOffset + (index * 20);
if (entryOffset + 20 > payloadBuffer.length) {
return null;
}
const flags = readU32LE(payloadBuffer, entryOffset + 0x00);
const relativeDataOffset = readU32LE(payloadBuffer, entryOffset + 0x08);
const frameWidth = readU16LE(payloadBuffer, entryOffset + 0x0c);
const frameHeight = readU16LE(payloadBuffer, entryOffset + 0x0e);
const originX = readU16LE(payloadBuffer, entryOffset + 0x10);
const originY = readU16LE(payloadBuffer, entryOffset + 0x12);
const dataStart = dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
if (
frameWidth === 0
|| frameHeight === 0
|| frameWidth > 512
|| frameHeight > 512
|| dataStart >= payloadBuffer.length
|| (((flags & 1) === 0) && (dataStart + rawSize > payloadBuffer.length))
) {
return null;
}
let consumed;
if ((flags & 1) === 1) {
const decoded = decodeRleRows(payloadBuffer, dataStart, frameWidth, frameHeight, mode);
if (!decoded) {
return null;
}
consumed = decoded.consumed;
} else {
consumed = rawSize;
}
frames.push({
index,
consumed,
relativeDataOffset,
width: frameWidth,
height: frameHeight,
originX,
originY,
flags,
dataStart,
absoluteDataStart: absoluteHeaderOffset + dataStart,
});
}
return {
slot: 0,
offsetInRegion: 0,
absoluteOffset: absoluteHeaderOffset,
kind,
width,
height,
mode,
paletteIndex,
dataOffset,
frameCount,
frameTableOffset,
frames,
};
}
function decodeRleRows(buffer, start, width, height, mode) {
const expectedSize = rowByteWidth(width, mode) * height;
const output = [];
@ -434,21 +457,28 @@ function indexedToColorRgba(pixels, palette) {
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;
// Bundle-local source buffer when present (override-bank bundles carry their
// own payload slice); fall back to the supplied region buffer for bundles
// recovered via raw scan of post_audio_region_04.
const sourceBuffer = bundle.sourceBuffer ?? region?.buffer;
if (!sourceBuffer) {
throw new Error('decodeBundleFrame requires a bundle.sourceBuffer or region.buffer');
}
let rawPixels;
let consumed;
if ((frame.flags & 1) === 1) {
const decoded = decodeRleRows(region.buffer, frame.dataStart, frame.width, frame.height, bundle.mode);
const decoded = decodeRleRows(sourceBuffer, 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)}`);
if (frame.dataStart + rawSize > sourceBuffer.length) {
throw new Error(`Frame overruns source buffer at 0x${frame.absoluteDataStart.toString(16)}`);
}
rawPixels = region.buffer.subarray(frame.dataStart, frame.dataStart + rawSize);
rawPixels = sourceBuffer.subarray(frame.dataStart, frame.dataStart + rawSize);
consumed = rawSize;
}

View file

@ -139,6 +139,7 @@ async function main() {
projectRoot,
wdlPath,
sourceRelPath: options.source,
discRoot: options.discRoot,
mapSource: options.mapSource,
bindingMode: options.bindingMode,
sceneScope: options.sceneScope,

View file

@ -9,8 +9,9 @@ import {
extractPaletteSets,
} from './bundles.js';
import { decodeBundleFrame, encodePng, scanSpriteBundles } from './bundles.js';
import { buildOverrideBundleBindings, parseOverrideBank } from './override-bank.js';
import { renderMap } from './render.js';
import { parseLsetWdl, parseRegion00Records, parseRegion01Records, summarizeRegion02 } from './wdl.js';
import { parseCtorPlacementsBlock, parseDispatchRootsBlock, parseLoaderLayout, parseLsetWdl, parseRegion00Records, parseRegion01Records, summarizeRegion02 } from './wdl.js';
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
@ -85,17 +86,38 @@ function chooseRecordSet(wdl, mapSource) {
? parseRegion01Records(region01Source)
: { source: 'region01', recordStartOffset: 0, records: [] };
// Loader-faithful paths: pull ctorPlacements and dispatchRoots directly from
// the packSubranges in the 14-u32 loader header. When present these override
// the heuristic region scans because they match the executable's dispatch
// iterators 1:1.
const packSubranges = wdl.loaderLayout?.packSubranges ?? [];
const ctorBlock = packSubranges.find((block) => block.name === 'ctorPlacements');
const dispatchBlock = packSubranges.find((block) => block.name === 'dispatchRoots');
const ctorPlacementsLoader = ctorBlock
? parseCtorPlacementsBlock(ctorBlock)
: { source: 'ctorPlacements', records: [] };
const dispatchRootsLoader = dispatchBlock
? parseDispatchRootsBlock(dispatchBlock)
: { source: 'dispatchRoots', records: [] };
const constructorRecordSet = ctorPlacementsLoader.records.length > 0
? ctorPlacementsLoader
: region01Records;
const rootRecordSet = dispatchRootsLoader.records.length > 0
? dispatchRootsLoader
: region00Records;
if (mapSource === 'region00' || mapSource === 'roots') {
return region00Records;
return rootRecordSet;
}
if (mapSource === 'region01' || mapSource === 'constructors') {
return region01Records;
return constructorRecordSet;
}
if (mapSource === 'combined' || mapSource === 'layered' || mapSource === 'auto') {
const records = [
...region01Records.records.map((record) => ({ ...record, authoredLayer: 'constructors' })),
...region00Records.records.map((record) => ({ ...record, authoredLayer: 'roots' })),
...constructorRecordSet.records.map((record) => ({ ...record, authoredLayer: record.authoredLayer ?? 'constructors' })),
...rootRecordSet.records.map((record) => ({ ...record, authoredLayer: record.authoredLayer ?? 'roots' })),
];
return {
@ -103,13 +125,25 @@ function chooseRecordSet(wdl, mapSource) {
recordStartOffset: 0,
records,
layers: {
constructors: region01Records.records.length,
roots: region00Records.records.length,
constructors: constructorRecordSet.records.length,
roots: rootRecordSet.records.length,
},
loaderSources: {
ctorPlacements: {
used: ctorPlacementsLoader.records.length > 0,
count: ctorPlacementsLoader.records.length,
reportedCount: ctorPlacementsLoader.reportedCount ?? null,
},
dispatchRoots: {
used: dispatchRootsLoader.records.length > 0,
count: dispatchRootsLoader.records.length,
reportedCount: dispatchRootsLoader.reportedCount ?? null,
},
},
};
}
return region01Records.records.length >= 8 ? region01Records : region00Records;
return constructorRecordSet.records.length >= 8 ? constructorRecordSet : rootRecordSet;
}
function chooseBundleForType(bundles, typeWord) {
@ -127,6 +161,146 @@ async function loadJsonIfExists(filePath) {
}
}
async function loadOverrideBankBindingsForBuffer(buffer, layout, variant, paletteSets, options = {}) {
if (!layout) {
return {
bundles: [],
byType: new Map(),
overrideBank: null,
artInstallBank: null,
failedEntries: [],
};
}
const overrideBlock = layout.blocksByName.get('override');
const artInstallBlock = layout.blocksByName.get('artInstall');
const result = {
bundles: [],
byType: new Map(),
overrideBank: null,
artInstallBank: null,
failedEntries: [],
};
// Parse art-install first: it provides the initial per-type drawable bindings
// for both kind-4 and kind-5 resources. The later override pass then
// replaces some slots with raw-header pointers.
if (artInstallBlock && artInstallBlock.buffer) {
const bank = parseOverrideBank(artInstallBlock, {
variant,
payloadBase: 0x2718,
});
result.artInstallBank = bank;
if (bank.valid) {
const { bundles: raw, failedEntries } = buildOverrideBundleBindings(bank, { buffer });
const tagged = raw.map((bundle) => ({
...bundle,
bundleSource: variant === 'spec_a' ? 'art-install-spec-a' : 'art-install-lset',
}));
const resolved = resolveBundlePalettes(tagged, paletteSets, {
mode1RuntimePalette: options.mode1RuntimePalette,
});
for (const bundle of resolved) {
if (!bundle.sourceBuffer && Number.isInteger(bundle.absoluteOffset)) {
bundle.sourceBuffer = buffer.subarray(bundle.absoluteOffset);
}
result.byType.set(bundle.typeId, bundle);
result.bundles.push(bundle);
}
result.failedEntries.push(...failedEntries);
}
}
if (overrideBlock && overrideBlock.buffer) {
const bank = parseOverrideBank(overrideBlock, { variant, payloadBase: 8 });
result.overrideBank = bank;
if (bank.valid) {
const { bundles: raw, failedEntries } = buildOverrideBundleBindings(bank, { buffer });
const tagged = raw.map((bundle) => ({
...bundle,
bundleSource: variant === 'spec_a' ? 'override-bank-spec-a' : 'override-bank-lset',
}));
const resolved = resolveBundlePalettes(tagged, paletteSets, {
mode1RuntimePalette: options.mode1RuntimePalette,
});
for (const bundle of resolved) {
if (!bundle.sourceBuffer && Number.isInteger(bundle.absoluteOffset)) {
bundle.sourceBuffer = buffer.subarray(bundle.absoluteOffset);
}
// Late override wins over earlier art-install for the same type,
// matching the loader's bank-slot overwrite behaviour.
result.byType.set(bundle.typeId, bundle);
result.bundles.push(bundle);
}
result.failedEntries.push(...failedEntries);
}
}
return result;
}
async function loadSpecAOverrideBank(wdlPath, discRoot, paletteSets, options = {}) {
const candidatePaths = [];
if (discRoot) {
candidatePaths.push(path.join(discRoot, 'SPEC_A.WDL'));
}
const wdlDir = path.dirname(wdlPath);
candidatePaths.push(path.resolve(wdlDir, '..', 'SPEC_A.WDL'));
candidatePaths.push(path.resolve(wdlDir, 'SPEC_A.WDL'));
for (const candidate of candidatePaths) {
try {
const buffer = await fs.readFile(candidate);
const layout = parseLoaderLayout(buffer, { variant: 'spec_a' });
const result = await loadOverrideBankBindingsForBuffer(
buffer,
layout,
'spec_a',
paletteSets,
options,
);
result.sourcePath = candidate;
return result;
} catch {
// try next candidate
}
}
return { bundles: [], byType: new Map(), overrideBank: null, failedEntries: [], sourcePath: null };
}
async function loadOverrideBankBindings(lsetWdl, wdlPath, discRoot, paletteSets, options = {}) {
// SPEC_A.WDL provides the base (bundle A) override table; the map-local
// LSET*.WDL provides the override B table. The loader runs SPEC_A first
// then LSET, and for matching typeIds LSET wins because it overwrites the
// bank slot. We merge in the same order here.
const specAResult = await loadSpecAOverrideBank(wdlPath, discRoot, paletteSets, options);
const lsetLayout = lsetWdl.loaderLayout;
const lsetResult = await loadOverrideBankBindingsForBuffer(
lsetWdl.buffer,
lsetLayout,
'lset',
paletteSets,
options,
);
const bundles = [...specAResult.bundles, ...lsetResult.bundles];
const byType = new Map();
for (const bundle of specAResult.bundles) {
byType.set(bundle.typeId, bundle);
}
// LSET wins over SPEC_A for identical typeIds (matches loader behaviour).
for (const bundle of lsetResult.bundles) {
byType.set(bundle.typeId, bundle);
}
return {
bundles,
byType,
specA: specAResult,
lset: lsetResult,
};
}
function buildRuntimeMap0MaskedBindings(correlation, bundles, tolerance = 0x200) {
const byType = new Map();
const diagnostics = [];
@ -226,6 +400,23 @@ function buildRuntimeMap0MaskedBindings(correlation, bundles, tolerance = 0x200)
}
function chooseBundleBinding(record, bundles, options = {}) {
// Primary path: per-type override-bank binding. The late header-only override
// block of each WDL pass (SPEC_A.WDL followed by the map-local LSET*.WDL)
// writes raw 0x58-byte active-header pointers into
// `psx_type_art_active_header_bank[type]`. Constructors and the render
// submitters read that bank at runtime, so an override-bank bundle IS the
// drawable resource bound to that type for this level.
const overrideBundle = options.overrideBundlesByType?.get(record.typeWord) ?? null;
if (overrideBundle) {
return {
bundle: overrideBundle,
mappingSource: overrideBundle.overrideVariant === 'spec_a'
? 'override-bank-spec-a'
: 'override-bank-lset',
runtimeBinding: null,
};
}
if (options.bindingMode === 'runtime-map0-masked-proxy') {
const runtimeBinding = options.runtimeMap0Bindings?.get(record.typeWord) ?? null;
if (runtimeBinding?.bundle) {
@ -233,6 +424,13 @@ function chooseBundleBinding(record, bundles, options = {}) {
}
}
if (options.bindingMode === 'override-bank') {
// Strict mode: refuse to fall back to the diagnostic slot rule. Types with
// no override binding get dropped from the scene so the render reflects
// only executable-backed art.
return null;
}
const rawTypeBundle = chooseBundleForType(bundles, record.typeWord);
if (!rawTypeBundle) {
return null;
@ -1033,6 +1231,20 @@ export async function exportMap(options) {
{ mode1RuntimePalette }
);
// Override-bank bindings: per-type drawable-header pointers installed by the
// late override pass in wdl_resource_bundle_load_by_index. This is the same
// runtime state constructors and render submitters read when resolving art,
// so it gives executable-backed typeWord -> bundle truth for the bundle A
// (SPEC_A.WDL) and bundle B (map-local LSET WDL) passes combined.
const overrideBankResult = await loadOverrideBankBindings(
wdl,
options.wdlPath,
options.discRoot ?? null,
paletteSets,
{ mode1RuntimePalette },
);
bundles = [...bundles, ...overrideBankResult.bundles];
const runtimeMap0Correlation = options.bindingMode === 'runtime-map0-masked-proxy'
? await loadJsonIfExists(path.join(cacheBaseRoot, 'runtime-map0-correlation.json'))
: null;
@ -1044,6 +1256,7 @@ export async function exportMap(options) {
mode1RuntimePalette,
mode1PaletteBank,
runtimeMap0Bindings: runtimeMap0BindingResult.byType,
overrideBundlesByType: overrideBankResult.byType,
});
const bindingDiversity = summarizeBindingDiversity(sceneItems);
const authoredLayerSummary = summarizeAuthoredLayers(recordSet.records);

View file

@ -0,0 +1,164 @@
// Parser for the late-override per-type active-header block.
//
// This block is emitted twice per loader pass (bundle A from SPEC_A.WDL and
// bundle B from the map-local LSET*.WDL). After the earlier art-install pass
// builds kind-4/kind-5 built resources and mirrors them into
// `psx_type_art_active_header_bank`, this block REPLACES the active-header
// slot for each type with a raw pointer to a self-describing 0x58-byte
// drawable descriptor that lives inside the block itself.
//
// Constructors, object state-script and render submitters all read
// `psx_type_art_active_header_bank[type]` at runtime, so parsing this block
// gives us the definitive typeWord -> drawable-header binding.
//
// Block layout:
// u32 count
// u32 directoryOffset (from payload base = block+8 to directory)
// byte payload[ directoryOffset ] (concatenated 0x58-byte headers, variable sz)
// { u32 size; u32 typeId; } directory[count]
//
// A directory entry with size == 0 clears the slot (writes 0 to the bank).
// Non-zero entries emit `payloadCursor` as the active-header pointer for
// `typeId` and advance the cursor by `size`.
import { extractBundleFromHeader } from './bundles.js';
function readU32LE(buffer, offset) {
return buffer.readUInt32LE(offset);
}
export function parseOverrideBank(overrideBlock, options = {}) {
if (!overrideBlock || !overrideBlock.buffer || overrideBlock.size < 8) {
return {
valid: false,
reason: 'missing-or-undersized-block',
entries: [],
};
}
const buffer = overrideBlock.buffer;
const blockOffset = overrideBlock.offset;
const count = readU32LE(buffer, 0);
const directoryOffset = readU32LE(buffer, 4);
if (count === 0 || count > 0x400) {
return { valid: false, reason: 'implausible-count', count, entries: [] };
}
// Override block layout uses payloadBase = 8 (directly after the 8-byte
// prefix). Art-install blocks use payloadBase = 0x2718 because the loader
// reserves a fixed 0x2710-byte header-cache area before payloads start.
// Both variants store the directory at `payloadBase + directoryOffset` and
// describe payloads as { u32 size; u32 typeId; } pairs.
const payloadBase = options.payloadBase ?? 8;
const directoryBase = payloadBase + directoryOffset;
if (directoryBase + count * 8 > buffer.length) {
return { valid: false, reason: 'directory-overrun', count, directoryOffset, payloadBase, entries: [] };
}
const entries = [];
let payloadCursor = payloadBase;
let nonZeroCount = 0;
let clearCount = 0;
for (let index = 0; index < count; index += 1) {
const size = readU32LE(buffer, directoryBase + index * 8 + 0);
const typeId = readU32LE(buffer, directoryBase + index * 8 + 4);
if (size === 0) {
entries.push({ index, typeId, size: 0, payloadOffset: null, kind: 'clear' });
clearCount += 1;
continue;
}
if (payloadCursor + size > directoryBase) {
return {
valid: false,
reason: 'payload-overruns-directory',
count,
directoryOffset,
entries,
truncatedAt: index,
};
}
entries.push({
index,
typeId,
size,
payloadOffset: payloadCursor,
absolutePayloadOffset: blockOffset + payloadCursor,
kind: 'install',
});
payloadCursor += size;
nonZeroCount += 1;
}
return {
valid: true,
count,
directoryOffset,
directoryBase,
payloadBase,
directoryAbsoluteOffset: blockOffset + directoryBase,
nonZeroCount,
clearCount,
payloadEnd: payloadCursor,
entries,
overrideBlockOffset: blockOffset,
overrideBlockSize: overrideBlock.size,
variant: options.variant ?? 'unknown',
};
}
// For each installed entry, treat its payload as a single drawable-header
// bundle (the 0x58-byte descriptor + embedded frame table + embedded data).
// Returns a map from typeId to a fully-hydrated bundle with `frames[]`.
export function buildOverrideBundleBindings(overrideBank, options = {}) {
if (!overrideBank || !overrideBank.valid) {
return { bundles: [], byType: new Map(), failedEntries: [] };
}
const buffer = options.buffer;
if (!buffer) {
throw new Error('buildOverrideBundleBindings requires { buffer } of the full WDL file');
}
const bundles = [];
const byType = new Map();
const failedEntries = [];
for (const entry of overrideBank.entries) {
if (entry.kind !== 'install') {
continue;
}
const absoluteHeaderOffset = entry.absolutePayloadOffset;
const payloadLimit = Math.min(entry.size, buffer.length - absoluteHeaderOffset);
if (payloadLimit < 0x34) {
failedEntries.push({ ...entry, reason: 'payload-too-small-for-header' });
continue;
}
const payloadBuffer = buffer.subarray(absoluteHeaderOffset, absoluteHeaderOffset + payloadLimit);
const bundle = extractBundleFromHeader(payloadBuffer, absoluteHeaderOffset);
if (!bundle) {
failedEntries.push({ ...entry, reason: 'invalid-bundle-header' });
continue;
}
bundle.bindingSource = 'override-bank';
bundle.overrideVariant = overrideBank.variant;
bundle.typeId = entry.typeId;
bundle.overrideEntryIndex = entry.index;
bundle.slot = entry.typeId;
bundle.sourceBuffer = payloadBuffer;
bundles.push(bundle);
// Later bundle pass (override B) wins over earlier (override A) for the
// same type; the loader mirrors this exactly by overwriting the bank slot.
byType.set(entry.typeId, bundle);
}
return { bundles, byType, failedEntries };
}

View file

@ -9,12 +9,150 @@ function readU16LE(buffer, offset) {
}
const ALLOWED_LANE_WORDS = new Set([0x20, 0x22, 0x30]);
const PSX_SCREEN_SCALE = 2;
// 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}`);
@ -101,6 +239,13 @@ export function parseLsetWdl(buffer, filePath) {
sections,
boundaryCandidates,
regions,
loaderLayout: (() => {
try {
return parseLoaderLayout(buffer, { variant: 'lset' });
} catch {
return null;
}
})(),
};
}
@ -447,3 +592,157 @@ export function parseRegion01Records(region) {
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,
};
}