1631 lines
62 KiB
JavaScript
1631 lines
62 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
buildMode1PaletteBank,
|
|
buildMode1RuntimePalette,
|
|
choosePalette,
|
|
extractMode1PaletteFromGpuRamDump,
|
|
extractPaletteSets,
|
|
} from './bundles.js';
|
|
import { decodeBundleFrame, encodePng, scanSpriteBundles } from './bundles.js';
|
|
import { buildOverrideBundleBindings, parseOverrideBank } from './override-bank.js';
|
|
import { renderMap } from './render.js';
|
|
import { parseCtorPlacementsBlock, parseDispatchRootsBlock, parseLoaderLayout, parseLsetWdl, parseRegion00Records, parseRegion01Records, summarizeRegion02 } from './wdl.js';
|
|
|
|
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
|
|
|
|
function sanitizeStem(name) {
|
|
return name.replace(/[^a-z0-9._-]+/gi, '_');
|
|
}
|
|
|
|
async function ensureDirectory(dirPath) {
|
|
await fs.mkdir(dirPath, { recursive: true });
|
|
}
|
|
|
|
async function resetDirectory(dirPath) {
|
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
await fs.mkdir(dirPath, { recursive: true });
|
|
}
|
|
|
|
async function loadGpuDumpMode1Palette(gpuRamDumpPath) {
|
|
if (!gpuRamDumpPath) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const buffer = await fs.readFile(gpuRamDumpPath);
|
|
return extractMode1PaletteFromGpuRamDump(buffer, 0xf0, 0x00);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseBundleOffset(value) {
|
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
|
return value;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/^0x/i.test(trimmed)) {
|
|
const parsed = Number.parseInt(trimmed, 16);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
}
|
|
|
|
const parsed = Number.parseInt(trimmed, 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
}
|
|
|
|
function chooseRecordSet(wdl, mapSource) {
|
|
const section00 = wdl.sections?.find((section) => section.name === 'post_audio_section_00') ?? null;
|
|
const region00 = wdl.regions.find((region) => region.name === 'post_audio_region_00');
|
|
const region01 = wdl.regions.find((region) => region.name === 'post_audio_region_01');
|
|
|
|
const constructorSource = section00
|
|
? {
|
|
...section00,
|
|
size: wdl.buffer.length - section00.offset,
|
|
buffer: wdl.buffer.subarray(section00.offset),
|
|
}
|
|
: region01;
|
|
|
|
const region00Source = section00 ?? region00;
|
|
const region01Source = constructorSource;
|
|
|
|
const region00Records = region00Source
|
|
? parseRegion00Records(region00Source)
|
|
: { source: 'region00', recordStartOffset: 0, records: [] };
|
|
const region01Records = region01Source
|
|
? 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 rootRecordSet;
|
|
}
|
|
if (mapSource === 'region01' || mapSource === 'constructors') {
|
|
return constructorRecordSet;
|
|
}
|
|
|
|
if (mapSource === 'combined' || mapSource === 'layered' || mapSource === 'auto') {
|
|
const records = [
|
|
...constructorRecordSet.records.map((record) => ({ ...record, authoredLayer: record.authoredLayer ?? 'constructors' })),
|
|
...rootRecordSet.records.map((record) => ({ ...record, authoredLayer: record.authoredLayer ?? 'roots' })),
|
|
];
|
|
|
|
return {
|
|
source: 'combined',
|
|
recordStartOffset: 0,
|
|
records,
|
|
layers: {
|
|
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 constructorRecordSet.records.length >= 8 ? constructorRecordSet : rootRecordSet;
|
|
}
|
|
|
|
function chooseBundleForType(bundles, typeWord) {
|
|
if (typeWord >= 0 && typeWord < bundles.length) {
|
|
return bundles[typeWord];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function loadJsonIfExists(filePath) {
|
|
try {
|
|
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
const provisionalBindings = [];
|
|
|
|
if (!correlation || correlation.currentMapId !== 0 || !Array.isArray(correlation.combined)) {
|
|
return { byType, diagnostics };
|
|
}
|
|
|
|
for (const row of correlation.combined) {
|
|
if (!row || !Number.isInteger(row.typeId) || !Number.isInteger(row.rawRecordCount) || row.rawRecordCount <= 0) {
|
|
continue;
|
|
}
|
|
if ((row.headerKind !== 4 && row.headerKind !== 5) || (row.headerWord3 !== 1 && row.headerWord3 !== 2)) {
|
|
continue;
|
|
}
|
|
if (!Number.isInteger(row.headerWord11) || row.headerWord11 === 0) {
|
|
continue;
|
|
}
|
|
|
|
const maskedAbsoluteOffset = row.headerWord11 & 0x0fffff;
|
|
let bestMatch = null;
|
|
for (const bundle of bundles) {
|
|
if (bundle.kind !== row.headerKind || bundle.mode !== row.headerWord3) {
|
|
continue;
|
|
}
|
|
|
|
const absoluteOffsetDelta = Math.abs(bundle.absoluteOffset - maskedAbsoluteOffset);
|
|
if (!bestMatch || absoluteOffsetDelta < bestMatch.absoluteOffsetDelta) {
|
|
bestMatch = {
|
|
bundle,
|
|
absoluteOffsetDelta,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!bestMatch || bestMatch.absoluteOffsetDelta > tolerance) {
|
|
continue;
|
|
}
|
|
|
|
const binding = {
|
|
typeId: row.typeId,
|
|
bundle: bestMatch.bundle,
|
|
mappingSource: 'runtime-map0-masked-header-offset-proxy',
|
|
runtimeBinding: {
|
|
maskedAbsoluteOffset,
|
|
absoluteOffsetDelta: bestMatch.absoluteOffsetDelta,
|
|
headerKind: row.headerKind,
|
|
headerMode: row.headerWord3,
|
|
headerWord4: row.headerWord4,
|
|
headerWord8: row.headerWord8,
|
|
headerWord10: row.headerWord10,
|
|
visibleCount: row.visibleCount ?? 0,
|
|
rawRecordCount: row.rawRecordCount,
|
|
},
|
|
};
|
|
|
|
provisionalBindings.push(binding);
|
|
}
|
|
|
|
const bundleBuckets = new Map();
|
|
for (const binding of provisionalBindings) {
|
|
const bucket = bundleBuckets.get(binding.bundle.absoluteOffset) ?? [];
|
|
bucket.push(binding);
|
|
bundleBuckets.set(binding.bundle.absoluteOffset, bucket);
|
|
}
|
|
|
|
for (const binding of provisionalBindings) {
|
|
const bucket = bundleBuckets.get(binding.bundle.absoluteOffset) ?? [];
|
|
const isCrowdedLargeSingleFrameBundle =
|
|
bucket.length >= 4 &&
|
|
binding.bundle.frameCount === 1 &&
|
|
binding.bundle.width >= 96 &&
|
|
binding.bundle.height >= 48;
|
|
|
|
if (isCrowdedLargeSingleFrameBundle) {
|
|
continue;
|
|
}
|
|
|
|
byType.set(binding.typeId, binding);
|
|
diagnostics.push({
|
|
typeId: binding.typeId,
|
|
bundleSlot: binding.bundle.slot,
|
|
bundleAbsoluteOffset: binding.bundle.absoluteOffset,
|
|
maskedAbsoluteOffset: binding.runtimeBinding.maskedAbsoluteOffset,
|
|
absoluteOffsetDelta: binding.runtimeBinding.absoluteOffsetDelta,
|
|
headerKind: binding.runtimeBinding.headerKind,
|
|
headerMode: binding.runtimeBinding.headerMode,
|
|
visibleCount: binding.runtimeBinding.visibleCount,
|
|
rawRecordCount: binding.runtimeBinding.rawRecordCount,
|
|
crowdedBundleTypeCount: bucket.length,
|
|
});
|
|
}
|
|
|
|
diagnostics.sort((left, right) => left.typeId - right.typeId);
|
|
return { byType, diagnostics };
|
|
}
|
|
|
|
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) {
|
|
return runtimeBinding;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Mark high-typeWord dispatch_root entries (>= 0xAC) as non-renderable
|
|
// placeholders rather than guessing art for them. The PSX engine switches
|
|
// its palette-token sourcing at typeWord 0xAC: types below use the 16-byte
|
|
// ctor record (renderable world objects) and types at or above use the
|
|
// 24-byte dispatch-root record (spawners, triggers, NPCs, UI panels) and
|
|
// typically have no sprite art at all. Without this guard the
|
|
// `bundles[typeWord]` fallback below picks an arbitrary bundle by raw scan
|
|
// order and produces wildly wrong sprites (e.g. invisible spawners
|
|
// rendered as brick walls / turrets / teleporters). The viewer still gets
|
|
// a placeholder so these entities remain inspectable.
|
|
// Cross-ref `/memories/repo/psx-typeword-renderable-boundary-2026-04-19.md`.
|
|
if (record.sourceFamily === 'section0_dispatch_roots' && record.typeWord >= 0xAC) {
|
|
return { bundle: null, mappingSource: 'placeholder-dispatch-root-high-typeword', runtimeBinding: null, placeholder: true };
|
|
}
|
|
|
|
const rawTypeBundle = chooseBundleForType(bundles, record.typeWord);
|
|
if (!rawTypeBundle) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
bundle: rawTypeBundle,
|
|
mappingSource: 'raw-typeword-bundle-slot-diagnostic',
|
|
runtimeBinding: null,
|
|
};
|
|
}
|
|
|
|
function describeMapScope(recordSet) {
|
|
if (recordSet.source === 'combined') {
|
|
return 'layered object-projection probe from both loader-sized section-0 constructor-placement and root-dispatch records in post_audio_section_00';
|
|
}
|
|
if (recordSet.source === 'region01') {
|
|
return 'object-projection probe from the loader-sized section-0 constructor-placement records in post_audio_section_00';
|
|
}
|
|
if (recordSet.source === 'region00') {
|
|
return 'object-projection probe from the loader-sized section-0 root-dispatch records in post_audio_section_00';
|
|
}
|
|
return 'object-projection probe from unresolved authored record source';
|
|
}
|
|
|
|
function buildSceneInterpretation(recordSet, bindingDiversity) {
|
|
const interpretation = {
|
|
kind: 'unresolved-authored-probe',
|
|
confidence: 'medium',
|
|
warning: 'Current output is a standalone authored-record probe, not a loader-faithful visible-world reconstruction.',
|
|
evidence: [],
|
|
};
|
|
|
|
if (recordSet.source === 'combined') {
|
|
interpretation.kind = 'layered-authored-world-probe';
|
|
interpretation.warning = 'Current output combines section-0 constructor placements with the smaller root-dispatch lane, but it is still not a full visible-world reconstruction.';
|
|
interpretation.evidence.push(
|
|
'The export now draws both authored section-0 lanes that are statically recoverable offline: constructor placements and root-dispatch rows.',
|
|
'This adds a second renderable authored layer without claiming runtime-complete state, control, or dynamic effect parity.',
|
|
);
|
|
} else if (recordSet.source === 'region01') {
|
|
interpretation.kind = 'constructor-live-object-seed-lane';
|
|
interpretation.warning = 'Constructor-placement exports should currently be read as a constructor-fed live-object seed lane, not as the complete visible map or static architecture layer.';
|
|
interpretation.evidence.push(
|
|
'Source records come from loader-sized section-0 constructor placements.',
|
|
'Executable constructors only install type-indexed art/state setup at spawn; final visible resource selection continues through downstream state-script and variant logic.',
|
|
);
|
|
} else if (recordSet.source === 'region00') {
|
|
interpretation.kind = 'root-dispatch-probe-lane';
|
|
interpretation.warning = 'Root-dispatch exports are a smaller authored-record probe and are not the whole map object set.';
|
|
interpretation.evidence.push(
|
|
'Source records come from loader-sized section-0 root dispatch rows.',
|
|
);
|
|
}
|
|
|
|
if (
|
|
bindingDiversity.distinctBundleCount > 0 &&
|
|
bindingDiversity.distinctTypeCount > bindingDiversity.distinctBundleCount
|
|
) {
|
|
interpretation.evidence.push(
|
|
`Rendered type diversity (${bindingDiversity.distinctTypeCount}) exceeds bound bundle diversity (${bindingDiversity.distinctBundleCount}), which is consistent with unresolved runtime binding rather than final map-facing art.`
|
|
);
|
|
}
|
|
|
|
return interpretation;
|
|
}
|
|
|
|
function summarizeAuthoredLayers(records) {
|
|
const counts = new Map();
|
|
|
|
for (const record of records) {
|
|
const layerKey = record.authoredLayer ?? record.sourceRole ?? record.sourceFamily ?? record.source;
|
|
counts.set(layerKey, (counts.get(layerKey) ?? 0) + 1);
|
|
}
|
|
|
|
return [...counts.entries()]
|
|
.map(([layer, recordCount]) => ({ layer, recordCount }))
|
|
.sort((left, right) => right.recordCount - left.recordCount || left.layer.localeCompare(right.layer));
|
|
}
|
|
|
|
function summarizeRenderedLayers(items) {
|
|
const counts = new Map();
|
|
|
|
for (const item of items) {
|
|
const layerKey = item.authoredLayer ?? 'unknown';
|
|
counts.set(layerKey, (counts.get(layerKey) ?? 0) + 1);
|
|
}
|
|
|
|
return [...counts.entries()]
|
|
.map(([layer, renderableItemCount]) => ({ layer, renderableItemCount }))
|
|
.sort((left, right) => right.renderableItemCount - left.renderableItemCount || left.layer.localeCompare(right.layer));
|
|
}
|
|
|
|
function derivePaletteDiagnostics(record, bundle) {
|
|
// For dispatch-root records the meaningful per-record palette token lives
|
|
// in the full 24-byte raw row (12 u16 words), not the projected 6-word
|
|
// record we keep for shared scene math. Constructor placements only have
|
|
// the 12-byte form, so falling back to record.rawWords is correct there.
|
|
const rawWords = Array.isArray(record.dispatchRootRawWords)
|
|
? record.dispatchRootRawWords
|
|
: Array.isArray(record.rawWords)
|
|
? record.rawWords
|
|
: [];
|
|
const token06HighByte = rawWords.length >= 4 ? ((rawWords[3] >>> 8) & 0xff) : null;
|
|
const token0cHighByte = rawWords.length >= 7 ? ((rawWords[6] >>> 8) & 0xff) : null;
|
|
|
|
let expectedAssignmentPath = 'bundle-default';
|
|
let expectedPaletteToken = null;
|
|
|
|
if (record.typeWord >= 0x003e && record.typeWord <= 0x00ab) {
|
|
expectedAssignmentPath = 'main-visible-source-plus-0x06-high-byte-when-nonzero';
|
|
expectedPaletteToken = token06HighByte;
|
|
} else if (record.typeWord >= 0x00ac) {
|
|
expectedAssignmentPath = 'main-visible-source-plus-0x0c-high-byte-when-nonzero';
|
|
expectedPaletteToken = token0cHighByte;
|
|
}
|
|
|
|
if ((record.typeWord === 4) || ((record.laneWord & 0x0400) !== 0)) {
|
|
expectedAssignmentPath = 'special-visible-default-bank-clut-no-authored-token';
|
|
expectedPaletteToken = null;
|
|
}
|
|
|
|
if (bundle?.mode === 1 && expectedPaletteToken === 0) {
|
|
expectedAssignmentPath = 'default-bank-clut-or-bank-clut-proxy';
|
|
}
|
|
|
|
return {
|
|
rawWords,
|
|
token06HighByte,
|
|
token0cHighByte,
|
|
expectedPaletteToken,
|
|
expectedAssignmentPath,
|
|
};
|
|
}
|
|
|
|
function makeRecordLikeFromMapSourceItem(item) {
|
|
return {
|
|
rawWords: item?.rawWords ?? [],
|
|
typeWord: item?.typeId ?? item?.quality ?? 0,
|
|
laneWord: item?.lane ?? item?.mapNum ?? 0,
|
|
};
|
|
}
|
|
|
|
function summarizeBindingDiversity(items) {
|
|
const bundleCounts = new Map();
|
|
const distinctTypes = new Set();
|
|
const distinctBundleFrames = new Set();
|
|
|
|
for (const item of items) {
|
|
distinctTypes.add(item.typeWord);
|
|
bundleCounts.set(item.bundleAbsoluteOffset, (bundleCounts.get(item.bundleAbsoluteOffset) ?? 0) + 1);
|
|
distinctBundleFrames.add(`${item.bundleAbsoluteOffset}:${item.frameIndex}`);
|
|
}
|
|
|
|
const topBundleRepeats = [...bundleCounts.entries()]
|
|
.map(([bundleAbsoluteOffset, count]) => ({ bundleAbsoluteOffset, count }))
|
|
.sort((left, right) => right.count - left.count)
|
|
.slice(0, 10);
|
|
|
|
return {
|
|
distinctTypeCount: distinctTypes.size,
|
|
distinctBundleCount: bundleCounts.size,
|
|
distinctBundleFrameCount: distinctBundleFrames.size,
|
|
topBundleRepeats,
|
|
};
|
|
}
|
|
|
|
function buildResourceKey(bundle, sprite) {
|
|
const paletteIndex = Number.isInteger(bundle.resolvedPaletteIndex) ? bundle.resolvedPaletteIndex : 'na';
|
|
return `${bundle.absoluteOffset.toString(16).padStart(8, '0')}:${sprite.clampedFrameIndex}:${paletteIndex}`;
|
|
}
|
|
|
|
function assignResourceLabelIds(items) {
|
|
const resourceKeys = [...new Set(items.map((item) => item.resourceKey))].sort((left, right) => left.localeCompare(right));
|
|
const resourceLabelIds = new Map(resourceKeys.map((resourceKey, index) => [resourceKey, index]));
|
|
|
|
return items.map((item) => ({
|
|
...item,
|
|
labelId: resourceLabelIds.get(item.resourceKey),
|
|
}));
|
|
}
|
|
|
|
function parseActiveHeaderOverrideCandidate(buffer, startOffset) {
|
|
if (startOffset + 8 > buffer.length) {
|
|
return null;
|
|
}
|
|
|
|
const count = buffer.readUInt32LE(startOffset + 0x00);
|
|
const directoryOffset = buffer.readUInt32LE(startOffset + 0x04);
|
|
if (count === 0 || count > 0x200) {
|
|
return null;
|
|
}
|
|
if (directoryOffset < 8 || directoryOffset + (count * 8) > buffer.length - startOffset) {
|
|
return null;
|
|
}
|
|
|
|
let payloadCursor = startOffset + 0x08;
|
|
const directoryBase = startOffset + directoryOffset;
|
|
let nonZeroCount = 0;
|
|
let clearCount = 0;
|
|
let size58Count = 0;
|
|
let payloadBytes = 0;
|
|
const rows = [];
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
const rowOffset = directoryBase + (index * 8);
|
|
const activeHeaderSize = buffer.readUInt32LE(rowOffset + 0x00);
|
|
const typeId = buffer.readUInt32LE(rowOffset + 0x04);
|
|
if (typeId > 0x1ff || activeHeaderSize > 0x1000) {
|
|
return null;
|
|
}
|
|
if (activeHeaderSize === 0) {
|
|
clearCount += 1;
|
|
rows.push({ index, typeId, activeHeaderSize, payloadOffset: null });
|
|
continue;
|
|
}
|
|
if (payloadCursor + activeHeaderSize > directoryBase) {
|
|
return null;
|
|
}
|
|
|
|
nonZeroCount += 1;
|
|
payloadBytes += activeHeaderSize;
|
|
if (activeHeaderSize === 0x58) {
|
|
size58Count += 1;
|
|
}
|
|
rows.push({
|
|
index,
|
|
typeId,
|
|
activeHeaderSize,
|
|
payloadOffset: payloadCursor - startOffset,
|
|
});
|
|
payloadCursor += activeHeaderSize;
|
|
}
|
|
|
|
if (nonZeroCount < 4 || size58Count < 2) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
startOffset,
|
|
count,
|
|
directoryOffset,
|
|
payloadBytes,
|
|
nonZeroCount,
|
|
clearCount,
|
|
size58Count,
|
|
firstNonZeroTypeIds: rows.filter((row) => row.activeHeaderSize !== 0).slice(0, 16).map((row) => row.typeId),
|
|
rows,
|
|
};
|
|
}
|
|
|
|
function scanActiveHeaderOverrideCandidates(wdl) {
|
|
const candidates = [];
|
|
const seenAbsoluteOffsets = new Set();
|
|
|
|
const sources = [
|
|
...(wdl.sections ?? []).map((section) => ({
|
|
kind: 'section',
|
|
name: section.name,
|
|
offset: section.offset,
|
|
buffer: section.buffer,
|
|
})),
|
|
...(wdl.regions ?? [])
|
|
.filter((region) => region.name !== 'audio_or_spu_blob')
|
|
.map((region) => ({
|
|
kind: 'region',
|
|
name: region.name,
|
|
offset: region.offset,
|
|
buffer: region.buffer,
|
|
})),
|
|
];
|
|
|
|
for (const source of sources) {
|
|
if (!source?.buffer || source.buffer.length < 0x80) {
|
|
continue;
|
|
}
|
|
|
|
for (let startOffset = 0; startOffset + 8 <= source.buffer.length; startOffset += 4) {
|
|
const candidate = parseActiveHeaderOverrideCandidate(source.buffer, startOffset);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
const absoluteOffset = source.offset + startOffset;
|
|
if (seenAbsoluteOffsets.has(absoluteOffset)) {
|
|
continue;
|
|
}
|
|
seenAbsoluteOffsets.add(absoluteOffset);
|
|
|
|
candidates.push({
|
|
sourceKind: source.kind,
|
|
sectionName: source.name,
|
|
sectionOffset: source.offset,
|
|
absoluteOffset,
|
|
...candidate,
|
|
});
|
|
}
|
|
}
|
|
|
|
candidates.sort((left, right) => {
|
|
if (left.size58Count !== right.size58Count) {
|
|
return right.size58Count - left.size58Count;
|
|
}
|
|
if (left.nonZeroCount !== right.nonZeroCount) {
|
|
return right.nonZeroCount - left.nonZeroCount;
|
|
}
|
|
return left.absoluteOffset - right.absoluteOffset;
|
|
});
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function buildMode2PaletteSweepCandidates(bundle, paletteSets) {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
const priorityIndexes = [bundle.defaultPaletteIndex, bundle.resolvedPaletteIndex, bundle.paletteIndex, 0];
|
|
|
|
for (const paletteIndex of priorityIndexes) {
|
|
if (!Number.isInteger(paletteIndex) || paletteIndex < 0 || paletteIndex >= paletteSets.palettes16.length || seen.has(paletteIndex)) {
|
|
continue;
|
|
}
|
|
seen.add(paletteIndex);
|
|
candidates.push({
|
|
label: `pal${paletteIndex}`,
|
|
paletteIndex,
|
|
palette: paletteSets.palettes16[paletteIndex],
|
|
paletteFormula: paletteIndex === bundle.defaultPaletteIndex ? 'bundle-default' : 'palette-sweep',
|
|
});
|
|
}
|
|
|
|
for (let paletteIndex = 0; paletteIndex < Math.min(32, paletteSets.palettes16.length); paletteIndex += 1) {
|
|
if (seen.has(paletteIndex)) {
|
|
continue;
|
|
}
|
|
seen.add(paletteIndex);
|
|
candidates.push({
|
|
label: `pal${paletteIndex}`,
|
|
paletteIndex,
|
|
palette: paletteSets.palettes16[paletteIndex],
|
|
paletteFormula: 'palette-sweep',
|
|
});
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function buildMode1PaletteSweepCandidates(bundle, paletteSets, options = {}) {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
const mode1PaletteBank = options.mode1PaletteBank ?? buildMode1PaletteBank(paletteSets.palettes16);
|
|
|
|
if (options.mode1RuntimePalette?.length === 256) {
|
|
seen.add('gpu-row-f0');
|
|
candidates.push({
|
|
label: 'gpu-row-f0',
|
|
paletteIndex: 0,
|
|
palette: options.mode1RuntimePalette,
|
|
paletteFormula: 'mode1-live-gpu-ram-row-f0-x0',
|
|
});
|
|
}
|
|
|
|
const priorityIndexes = [bundle.defaultPaletteIndex, bundle.resolvedPaletteIndex, bundle.paletteIndex, 0];
|
|
for (const paletteIndex of priorityIndexes) {
|
|
if (!Number.isInteger(paletteIndex) || seen.has(`bank-${paletteIndex}`)) {
|
|
continue;
|
|
}
|
|
const palette = mode1PaletteBank[paletteIndex] ?? null;
|
|
if (!palette) {
|
|
continue;
|
|
}
|
|
seen.add(`bank-${paletteIndex}`);
|
|
candidates.push({
|
|
label: `bank${paletteIndex}`,
|
|
paletteIndex,
|
|
palette,
|
|
paletteFormula: paletteIndex === bundle.defaultPaletteIndex ? 'mode1-runtime-clut-bank-default' : 'mode1-runtime-clut-bank-sweep',
|
|
});
|
|
}
|
|
|
|
for (let paletteIndex = 0; paletteIndex < Math.min(8, mode1PaletteBank.length); paletteIndex += 1) {
|
|
if (seen.has(`bank-${paletteIndex}`) || !mode1PaletteBank[paletteIndex]) {
|
|
continue;
|
|
}
|
|
seen.add(`bank-${paletteIndex}`);
|
|
candidates.push({
|
|
label: `bank${paletteIndex}`,
|
|
paletteIndex,
|
|
palette: mode1PaletteBank[paletteIndex],
|
|
paletteFormula: 'mode1-runtime-clut-bank-sweep',
|
|
});
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function buildBundleValidationCells(region04, bundle, paletteSets, options = {}) {
|
|
const candidates = bundle.mode === 2
|
|
? buildMode2PaletteSweepCandidates(bundle, paletteSets)
|
|
: buildMode1PaletteSweepCandidates(bundle, paletteSets, options);
|
|
const frameIndexes = bundle.frames.slice(0, Math.min(bundle.frames.length, 4)).map((frame) => frame.index);
|
|
const items = [];
|
|
let maxWidth = 0;
|
|
let maxHeight = 0;
|
|
|
|
for (const candidate of candidates) {
|
|
for (const frameIndex of frameIndexes) {
|
|
const previewBundle = {
|
|
...bundle,
|
|
resolvedPaletteIndex: candidate.paletteIndex,
|
|
paletteFormula: candidate.paletteFormula,
|
|
palette: candidate.palette,
|
|
};
|
|
const sprite = decodeBundleFrame(region04, previewBundle, frameIndex, candidate.palette ?? null);
|
|
if (!sprite) {
|
|
continue;
|
|
}
|
|
maxWidth = Math.max(maxWidth, sprite.width);
|
|
maxHeight = Math.max(maxHeight, sprite.height);
|
|
items.push({ candidate, frameIndex, sprite });
|
|
}
|
|
}
|
|
|
|
const columns = Math.max(1, frameIndexes.length);
|
|
const gutter = 24;
|
|
const renderItems = items.map((entry, index) => {
|
|
const column = index % columns;
|
|
const row = Math.floor(index / columns);
|
|
return {
|
|
id: String(index),
|
|
sheetIndex: index,
|
|
recordIndex: entry.frameIndex,
|
|
typeWord: bundle.slot,
|
|
laneWord: entry.candidate.paletteIndex ?? 0,
|
|
requestedFrameIndex: entry.frameIndex,
|
|
frameIndex: entry.frameIndex,
|
|
defaultPaletteIndex: bundle.defaultPaletteIndex ?? bundle.paletteIndex ?? null,
|
|
resolvedPaletteIndex: entry.candidate.paletteIndex,
|
|
paletteFormula: entry.candidate.paletteFormula,
|
|
mappingSource: 'bundle-validation-sweep',
|
|
donorTypeId: null,
|
|
templateTypeId: null,
|
|
bundleAbsoluteOffset: bundle.absoluteOffset,
|
|
width: entry.sprite.width,
|
|
height: entry.sprite.height,
|
|
originX: 0,
|
|
originY: 0,
|
|
drawX: column * (maxWidth + gutter),
|
|
drawY: row * (maxHeight + gutter),
|
|
flipped: false,
|
|
sprite: entry.sprite,
|
|
};
|
|
});
|
|
|
|
const metadata = items.map((entry, index) => ({
|
|
sheetIndex: index,
|
|
frameIndex: entry.frameIndex,
|
|
paletteIndex: entry.candidate.paletteIndex,
|
|
label: entry.candidate.label,
|
|
paletteFormula: entry.candidate.paletteFormula,
|
|
width: entry.sprite.width,
|
|
height: entry.sprite.height,
|
|
}));
|
|
|
|
return { renderItems, metadata };
|
|
}
|
|
|
|
async function writeValidationOutputs(outputRoot, mapStem, region04, bundles, paletteSets, options = {}, bundleOffsets = []) {
|
|
const outputs = [];
|
|
const bundlesByOffset = new Map(bundles.map((bundle) => [bundle.absoluteOffset, bundle]));
|
|
|
|
for (const bundleOffset of bundleOffsets) {
|
|
const bundle = bundlesByOffset.get(bundleOffset);
|
|
if (!bundle) {
|
|
continue;
|
|
}
|
|
|
|
const { renderItems, metadata } = buildBundleValidationCells(region04, bundle, paletteSets, options);
|
|
if (renderItems.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const render = renderMap(renderItems, {
|
|
drawLabels: true,
|
|
background: { red: 18, green: 18, blue: 18, alpha: 255 },
|
|
});
|
|
const stem = `${mapStem}_bundle_${bundleOffset.toString(16).padStart(8, '0')}_sheet`;
|
|
const pngPath = path.join(outputRoot, `${stem}.png`);
|
|
const jsonPath = path.join(outputRoot, `${stem}.json`);
|
|
await fs.writeFile(pngPath, render.png);
|
|
await fs.writeFile(jsonPath, JSON.stringify({
|
|
bundleOffset,
|
|
mode: bundle.mode,
|
|
frameCount: bundle.frames.length,
|
|
candidateCount: metadata.length,
|
|
items: metadata,
|
|
}, null, 2));
|
|
outputs.push({ bundleOffset, pngPath, jsonPath });
|
|
}
|
|
|
|
return outputs;
|
|
}
|
|
|
|
function colorizeU16Value(value) {
|
|
return {
|
|
red: value & 0x1f,
|
|
green: (value >> 5) & 0x1f,
|
|
blue: (value >> 10) & 0x1f,
|
|
};
|
|
}
|
|
|
|
function buildRegion02ExamplePng(region, options = {}) {
|
|
const columns = options.columns ?? 128;
|
|
const cellSize = options.cellSize ?? 4;
|
|
const sampleWordCount = Math.min(options.sampleWordCount ?? 2048, Math.floor(region.buffer.length / 2));
|
|
const rows = Math.max(1, Math.ceil(sampleWordCount / columns));
|
|
const width = columns * cellSize;
|
|
const height = rows * cellSize;
|
|
const rgba = Buffer.alloc(width * height * 4, 0);
|
|
|
|
for (let index = 0; index < sampleWordCount; index += 1) {
|
|
const value = region.buffer.readUInt16LE(index * 2);
|
|
const color = colorizeU16Value(value);
|
|
const column = index % columns;
|
|
const row = Math.floor(index / columns);
|
|
const baseX = column * cellSize;
|
|
const baseY = row * cellSize;
|
|
const red = Math.round((color.red / 31) * 255);
|
|
const green = Math.round((color.green / 31) * 255);
|
|
const blue = Math.round((color.blue / 31) * 255);
|
|
|
|
for (let y = 0; y < cellSize; y += 1) {
|
|
for (let x = 0; x < cellSize; x += 1) {
|
|
const pixel = ((baseY + y) * width + (baseX + x)) * 4;
|
|
rgba[pixel + 0] = red;
|
|
rgba[pixel + 1] = green;
|
|
rgba[pixel + 2] = blue;
|
|
rgba[pixel + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
width,
|
|
height,
|
|
sampleWordCount,
|
|
png: encodePng(rgba, width, height),
|
|
};
|
|
}
|
|
|
|
async function writeRegion02Example(outputRoot, cacheRoot, mapStem, region, summary) {
|
|
if (!region || !summary) {
|
|
return null;
|
|
}
|
|
|
|
const example = buildRegion02ExamplePng(region);
|
|
const pngPath = path.join(outputRoot, `${mapStem}_region02_example.png`);
|
|
const jsonPath = path.join(outputRoot, `${mapStem}_region02_example.json`);
|
|
const cacheJsonPath = path.join(cacheRoot, 'region02-analysis.json');
|
|
|
|
await fs.writeFile(pngPath, example.png);
|
|
await fs.writeFile(jsonPath, JSON.stringify({
|
|
...summary,
|
|
exampleWidth: example.width,
|
|
exampleHeight: example.height,
|
|
sampleWordCount: example.sampleWordCount,
|
|
description: 'False-color grid of the first region-02 u16 words plus structured preview rows in the JSON. This is a raw structure preview, not a decoded floor/map layer.',
|
|
}, null, 2));
|
|
|
|
return { pngPath, jsonPath, cacheJsonPath };
|
|
}
|
|
|
|
function resolveBundlePalettes(bundles, paletteSets, options = {}) {
|
|
const mode1PaletteBank = buildMode1PaletteBank(paletteSets.palettes16);
|
|
const mode1RuntimePalette = options.mode1RuntimePalette
|
|
?? buildMode1RuntimePalette(paletteSets.palettes16)
|
|
?? paletteSets.palettes256[0]
|
|
?? null;
|
|
|
|
return bundles.map((bundle) => {
|
|
const defaultPaletteIndex = bundle.paletteIndex;
|
|
let resolvedPaletteIndex = bundle.paletteIndex;
|
|
let palette = null;
|
|
let paletteFormula = null;
|
|
|
|
if (bundle.mode === 2) {
|
|
// Mode 2 (4bpp) descriptor binder at psx_resource_bind_single_image_vram_slot
|
|
// (0x800444e4) takes the bundle's `paletteIndex` from descriptor+0x14
|
|
// and ADDS 0x10 (= 16) before storing it as the resource's CLUT bank
|
|
// index at resource+0x08. The submitters then read
|
|
// psx_clut_table_by_resource_bank[resource+8] which is identical to
|
|
// palettes16[paletteIndex + 16]. The exporter therefore offsets the
|
|
// bundle index by +16 for mode-2 art.
|
|
const baseIndex = Number.isInteger(bundle.paletteIndex) ? bundle.paletteIndex : null;
|
|
const adjustedIndex = baseIndex !== null ? baseIndex + 16 : null;
|
|
if (adjustedIndex !== null && adjustedIndex >= 0 && adjustedIndex < paletteSets.palettes16.length) {
|
|
resolvedPaletteIndex = adjustedIndex;
|
|
palette = paletteSets.palettes16[adjustedIndex];
|
|
paletteFormula = 'mode2-bundle-index-plus-16';
|
|
}
|
|
if (!palette) {
|
|
if (!Number.isInteger(resolvedPaletteIndex) || resolvedPaletteIndex < 0 || resolvedPaletteIndex >= paletteSets.palettes16.length) {
|
|
resolvedPaletteIndex = choosePalette(paletteSets.palettes16, bundle.frames, bundle.mode);
|
|
}
|
|
if (Number.isInteger(resolvedPaletteIndex) && resolvedPaletteIndex >= 0 && resolvedPaletteIndex < paletteSets.palettes16.length) {
|
|
palette = paletteSets.palettes16[resolvedPaletteIndex];
|
|
paletteFormula = 'mode2-bundle-or-usage-index-fallback';
|
|
}
|
|
}
|
|
} else if (bundle.mode === 1) {
|
|
// Mode 1 is an 8bpp image with a 256-entry CLUT. In this engine the
|
|
// 256-color CLUT is NOT a dedicated palette-256 block — it is the
|
|
// concatenation of 16 consecutive 16-entry CLUTs taken from the
|
|
// `palettes16` bank. The bundle header's `paletteIndex` is the starting
|
|
// CLUT index into `palettes16`. Falling back to `0` matches the legacy
|
|
// behavior but is almost always wrong for mode-1 art; the per-bundle
|
|
// index is the real engine-equivalent rule.
|
|
const bankStart = Number.isInteger(bundle.paletteIndex) && bundle.paletteIndex >= 0
|
|
? bundle.paletteIndex
|
|
: 0;
|
|
const fromBundleIndex = mode1PaletteBank[bankStart];
|
|
if (fromBundleIndex?.length === 256) {
|
|
resolvedPaletteIndex = bankStart;
|
|
palette = fromBundleIndex;
|
|
paletteFormula = 'mode1-palette16-bank-start-index-bundle';
|
|
} else if (options.mode1RuntimePalette?.length === 256) {
|
|
resolvedPaletteIndex = 0;
|
|
palette = options.mode1RuntimePalette;
|
|
paletteFormula = 'mode1-live-gpu-ram-row-f0-x0';
|
|
} else if (mode1PaletteBank[0]?.length) {
|
|
resolvedPaletteIndex = 0;
|
|
palette = mode1PaletteBank[0];
|
|
paletteFormula = 'mode1-palette16-bank-start-index-0-fallback';
|
|
} else {
|
|
resolvedPaletteIndex = null;
|
|
palette = mode1RuntimePalette;
|
|
paletteFormula = palette ? 'mode1-runtime-clut-band-start-index-0' : null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...bundle,
|
|
defaultPaletteIndex,
|
|
resolvedPaletteIndex,
|
|
paletteFormula,
|
|
palette,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function buildSceneItems(region04, records, bundles, options = {}) {
|
|
const items = [];
|
|
const spriteCache = new Map();
|
|
const skippedRecords = [];
|
|
|
|
const nonMapFacingRootTypes = new Set([0x42, 0x49]);
|
|
// Bundles that bind via dispatch_roots but are HUD/UI artwork, not authored
|
|
// map placements. They surface in the scene because they share the dispatch
|
|
// table format with world objects, but rendering them produces a stray UI
|
|
// panel floating in the middle of an empty area.
|
|
// 0x000d84f4 - portrait/talk panel (already known)
|
|
// 0x00074f44 - type 0xAC (172) full-screen UI panel, 216x126, kind-4
|
|
// mode-2; user-confirmed as the lone "TAC:b4f44" leak.
|
|
// 0x00086810 - "ceiling" tile (T0x43, kind-5 mode-2, 128x65). Engine uses
|
|
// it at runtime to temporarily obscure rooms the player has
|
|
// not entered yet; for static map rendering it just hides
|
|
// the geometry the user wants to see, so it is suppressed.
|
|
const nonMapFacingBundleOffsets = new Set([0x000d84f4, 0x00074f44, 0x00086810]);
|
|
for (const record of records) {
|
|
if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) {
|
|
skippedRecords.push({
|
|
recordIndex: record.index,
|
|
typeWord: record.typeWord,
|
|
sourceFamily: record.sourceFamily,
|
|
reason: 'non-map-facing portrait/talk root type',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const binding = chooseBundleBinding(record, bundles, options);
|
|
const bundle = binding?.bundle ?? null;
|
|
if (!binding) {
|
|
continue;
|
|
}
|
|
if (binding.placeholder) {
|
|
// Non-renderable entity (spawner, trigger, NPC, UI marker). Emit a small
|
|
// square footprint so the viewer can still display and inspect it.
|
|
const PLACEHOLDER_SIZE = 12;
|
|
items.push({
|
|
id: record.index,
|
|
instanceId: record.index,
|
|
recordIndex: record.index,
|
|
recordSource: record.source,
|
|
sourceFamily: record.sourceFamily,
|
|
authoredLayer: record.authoredLayer ?? record.sourceRole ?? null,
|
|
recordSide: record.recordSide,
|
|
rowIndex: record.rowIndex,
|
|
typeWord: record.typeWord,
|
|
laneWord: record.laneWord,
|
|
worldX: record.xWord ?? null,
|
|
worldY: record.yWord ?? null,
|
|
worldZ: record.zWord ?? null,
|
|
screenX: record.screenX,
|
|
screenY: record.screenY,
|
|
bundleSlot: null,
|
|
bundleAbsoluteOffset: null,
|
|
bundleSource: null,
|
|
requestedFrameIndex: record.selectorWord,
|
|
frameIndex: null,
|
|
defaultPaletteIndex: null,
|
|
resolvedPaletteIndex: null,
|
|
paletteFormula: null,
|
|
mappingSource: binding.mappingSource,
|
|
templateTypeId: null,
|
|
donorTypeId: null,
|
|
runtimeBindingMaskedAbsoluteOffset: null,
|
|
runtimeBindingOffsetDelta: null,
|
|
runtimeBindingVisibleCount: null,
|
|
runtimeBindingRawRecordCount: null,
|
|
rawWords: record.rawWords ?? record.words,
|
|
flipped: false,
|
|
width: PLACEHOLDER_SIZE,
|
|
height: PLACEHOLDER_SIZE,
|
|
originX: PLACEHOLDER_SIZE / 2,
|
|
originY: PLACEHOLDER_SIZE / 2,
|
|
drawX: record.screenX - PLACEHOLDER_SIZE / 2,
|
|
drawY: record.screenY - PLACEHOLDER_SIZE / 2,
|
|
stage: 1, // overlays so placeholders sit on top of geometry
|
|
isFlat: false,
|
|
resourceKey: 'placeholder',
|
|
paletteDiagnostics: null,
|
|
sprite: null,
|
|
placeholder: true,
|
|
});
|
|
continue;
|
|
}
|
|
if (!bundle) {
|
|
continue;
|
|
}
|
|
if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) {
|
|
skippedRecords.push({
|
|
recordIndex: record.index,
|
|
typeWord: record.typeWord,
|
|
sourceFamily: record.sourceFamily,
|
|
bundleAbsoluteOffset: bundle.absoluteOffset,
|
|
reason: 'known non-map-facing portrait/talk bundle',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const requestedFrameIndex = record.selectorWord;
|
|
const clampedFrameIndex = Math.max(0, Math.min(requestedFrameIndex, bundle.frames.length - 1));
|
|
const spriteKey = `${bundle.absoluteOffset}:${clampedFrameIndex}`;
|
|
let sprite = spriteCache.get(spriteKey);
|
|
if (!sprite) {
|
|
sprite = decodeBundleFrame(region04, bundle, requestedFrameIndex, bundle.palette ?? null);
|
|
if (!sprite) {
|
|
continue;
|
|
}
|
|
spriteCache.set(spriteKey, sprite);
|
|
}
|
|
|
|
const resourceKey = buildResourceKey(bundle, sprite);
|
|
items.push({
|
|
id: record.index,
|
|
instanceId: record.index,
|
|
recordIndex: record.index,
|
|
recordSource: record.source,
|
|
sourceFamily: record.sourceFamily,
|
|
authoredLayer: record.authoredLayer ?? record.sourceRole ?? null,
|
|
recordSide: record.recordSide,
|
|
rowIndex: record.rowIndex,
|
|
typeWord: record.typeWord,
|
|
laneWord: record.laneWord,
|
|
worldX: record.xWord ?? null,
|
|
worldY: record.yWord ?? null,
|
|
worldZ: record.zWord ?? null,
|
|
screenX: record.screenX,
|
|
screenY: record.screenY,
|
|
bundleSlot: bundle.slot,
|
|
bundleAbsoluteOffset: bundle.absoluteOffset,
|
|
bundleSource: bundle.bundleSource ?? 'raw-scan',
|
|
requestedFrameIndex,
|
|
frameIndex: sprite.clampedFrameIndex,
|
|
defaultPaletteIndex: bundle.defaultPaletteIndex ?? null,
|
|
resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null,
|
|
paletteFormula: bundle.paletteFormula ?? null,
|
|
mappingSource: binding.mappingSource,
|
|
templateTypeId: null,
|
|
donorTypeId: null,
|
|
runtimeBindingMaskedAbsoluteOffset: binding.runtimeBinding?.maskedAbsoluteOffset ?? null,
|
|
runtimeBindingOffsetDelta: binding.runtimeBinding?.absoluteOffsetDelta ?? null,
|
|
runtimeBindingVisibleCount: binding.runtimeBinding?.visibleCount ?? null,
|
|
runtimeBindingRawRecordCount: binding.runtimeBinding?.rawRecordCount ?? null,
|
|
rawWords: record.rawWords ?? record.words,
|
|
flipped: (record.laneWord & 0x0002) !== 0,
|
|
width: sprite.width,
|
|
height: sprite.height,
|
|
originX: sprite.originX,
|
|
originY: sprite.originY,
|
|
// Engine-accurate anchor selection per psx_project_object_main_visible
|
|
// (0x80040d44 / 0x80040ddc): when laneWord bit 0x0002 is set the engine
|
|
// mirrors the horizontal anchor from origin_x to (frame_w - origin_x).
|
|
// The blit step then flips the sprite. Applying both keeps the visible
|
|
// anchor on the same world point, fixing flipped walls that previously
|
|
// landed (frame_w - 2*origin_x) px too far left.
|
|
drawX: ((record.laneWord & 0x0002) !== 0)
|
|
? record.screenX - (sprite.width - sprite.originX)
|
|
: record.screenX - sprite.originX,
|
|
drawY: record.screenY - sprite.originY,
|
|
stage: record.typeWord === 4 || (record.laneWord & 0x0400) !== 0 ? 1 : 0,
|
|
// Heuristic: flat floor/ceiling decals are tile-sized sprites whose
|
|
// anchor sits at the bottom edge AND whose silhouette is wider than
|
|
// tall (i.e. matches the engine's 2:1 isometric ground footprint).
|
|
// Upright sprites (walls, crates, terminals, props) extend well above
|
|
// the anchor and have height >= width.
|
|
isFlat:
|
|
sprite.width >= 48 &&
|
|
sprite.height > 0 &&
|
|
sprite.height / sprite.width <= 0.6 &&
|
|
sprite.originY >= sprite.height - 4,
|
|
resourceKey,
|
|
paletteDiagnostics: derivePaletteDiagnostics(record, bundle),
|
|
sprite,
|
|
});
|
|
}
|
|
|
|
// Painter's order for an isometric top-down full-map render.
|
|
// 1. `stage`: runtime sub-stage flag (0 = default, 1 = overlays flagged via
|
|
// typeWord===4 or laneWord bit 0x0400). Overlays always on top.
|
|
// 2. Isometric depth: back-to-front by world `X + Y` (ground depth along the
|
|
// engine's isometric axis). Constructors (static geometry: walls, floors,
|
|
// architecture) and roots (dynamic props: crates, terminals, doors) are
|
|
// interleaved by depth so a crate placed in front of a wall does not get
|
|
// painted over by a wall placed further back. Falls back to screenY when
|
|
// world coords are unavailable.
|
|
// 3. `isFlat`: at the SAME depth, flat ground decals (floor/ceiling tiles)
|
|
// must draw BEFORE upright sprites (walls, crates, props) so the props
|
|
// standing on the tile sit visually on top of it. Without this bias,
|
|
// floors painted on the same world tile as a prop will overpaint the
|
|
// prop whenever screenX happens to sort the floor last.
|
|
// 4. `worldZ` ascending: lower objects at the same ground cell draw before
|
|
// taller ones at that cell.
|
|
// 5. `authoredLayer`: when world coords + flatness tie, draw constructors
|
|
// (geometry) before roots (props) so props sit on top of their floor.
|
|
// 6. `screenX` ascending: stable tie-breaker left-to-right.
|
|
const layerTieBreak = (item) => {
|
|
if (item.authoredLayer === 'constructors') return 0;
|
|
if (item.authoredLayer === 'roots') return 1;
|
|
return 2;
|
|
};
|
|
const depthKey = (item) => {
|
|
if (Number.isFinite(item.worldX) && Number.isFinite(item.worldY)) {
|
|
return item.worldX + item.worldY;
|
|
}
|
|
return item.screenY;
|
|
};
|
|
items.sort((left, right) => {
|
|
if (left.stage !== right.stage) {
|
|
return left.stage - right.stage;
|
|
}
|
|
// Two-pass painter: ALL flat ground decals first (back-to-front by depth),
|
|
// then ALL upright sprites (back-to-front by depth). This avoids the
|
|
// floor-anchor problem where a flat tile's authored anchor sits at its
|
|
// FRONT tip (largest world X+Y in the footprint), which a single-pass
|
|
// depth sort would draw AFTER walls/props that visually stand on it.
|
|
if (left.isFlat !== right.isFlat) {
|
|
return left.isFlat ? -1 : 1;
|
|
}
|
|
const leftDepth = depthKey(left);
|
|
const rightDepth = depthKey(right);
|
|
if (leftDepth !== rightDepth) {
|
|
return leftDepth - rightDepth;
|
|
}
|
|
const leftZ = Number.isFinite(left.worldZ) ? left.worldZ : 0;
|
|
const rightZ = Number.isFinite(right.worldZ) ? right.worldZ : 0;
|
|
if (leftZ !== rightZ) {
|
|
return leftZ - rightZ;
|
|
}
|
|
const leftLayer = layerTieBreak(left);
|
|
const rightLayer = layerTieBreak(right);
|
|
if (leftLayer !== rightLayer) {
|
|
return leftLayer - rightLayer;
|
|
}
|
|
return left.screenX - right.screenX;
|
|
});
|
|
|
|
return {
|
|
items: assignResourceLabelIds(items),
|
|
skippedRecords,
|
|
};
|
|
}
|
|
|
|
async function writeBundleCache(spriteRoot, region04, bundles) {
|
|
const manifest = [];
|
|
|
|
for (const bundle of bundles) {
|
|
const bundleDir = path.join(spriteRoot, `bundle_${bundle.absoluteOffset.toString(16).padStart(8, '0')}`);
|
|
await ensureDirectory(bundleDir);
|
|
|
|
const frames = [];
|
|
for (const frame of bundle.frames) {
|
|
const decoded = decodeBundleFrame(region04, bundle, frame.index, bundle.palette ?? null);
|
|
const fileName = `frame_${String(frame.index).padStart(3, '0')}.png`;
|
|
await fs.writeFile(path.join(bundleDir, fileName), encodePng(decoded.rgba, decoded.width, decoded.height));
|
|
frames.push({
|
|
index: frame.index,
|
|
width: decoded.width,
|
|
height: decoded.height,
|
|
originX: decoded.originX,
|
|
originY: decoded.originY,
|
|
fileName,
|
|
});
|
|
}
|
|
|
|
manifest.push({
|
|
slot: bundle.slot,
|
|
absoluteOffset: bundle.absoluteOffset,
|
|
offsetInRegion: bundle.offsetInRegion,
|
|
kind: bundle.kind,
|
|
mode: bundle.mode,
|
|
paletteIndex: bundle.paletteIndex,
|
|
defaultPaletteIndex: bundle.defaultPaletteIndex ?? null,
|
|
resolvedPaletteIndex: bundle.resolvedPaletteIndex ?? null,
|
|
paletteFormula: bundle.paletteFormula ?? null,
|
|
bundleSource: bundle.bundleSource,
|
|
frameCount: bundle.frameCount,
|
|
frameTableOffset: bundle.frameTableOffset,
|
|
dataOffset: bundle.dataOffset,
|
|
frames,
|
|
});
|
|
}
|
|
|
|
return manifest;
|
|
}
|
|
|
|
export async function exportMap(options) {
|
|
const buffer = await fs.readFile(options.wdlPath);
|
|
const wdl = parseLsetWdl(buffer, options.wdlPath);
|
|
const mapStem = sanitizeStem(options.outName ?? path.parse(options.wdlPath).name);
|
|
const cacheBaseRoot = path.join(options.projectRoot, '.cache');
|
|
const cacheRoot = path.join(options.projectRoot, '.cache', mapStem);
|
|
const spriteRoot = path.join(cacheRoot, 'sprites');
|
|
const outputRoot = options.outputRoot
|
|
? path.resolve(options.outputRoot)
|
|
: path.join(options.projectRoot, '.output');
|
|
|
|
await ensureDirectory(cacheBaseRoot);
|
|
await resetDirectory(cacheRoot);
|
|
await ensureDirectory(cacheRoot);
|
|
await ensureDirectory(spriteRoot);
|
|
if (options.resetOutputRoot === false) {
|
|
await ensureDirectory(outputRoot);
|
|
} else {
|
|
await resetDirectory(outputRoot);
|
|
}
|
|
|
|
const recordSet = chooseRecordSet(wdl, options.mapSource);
|
|
const region04 = wdl.regions.find((region) => region.name === 'post_audio_region_04');
|
|
const region02 = wdl.regions.find((region) => region.name === 'post_audio_region_02');
|
|
if (!region04) {
|
|
throw new Error('The WDL did not expose post_audio_region_04.');
|
|
}
|
|
const sceneScope = options.sceneScope === 'full' ? 'full' : 'probe';
|
|
if (sceneScope === 'full') {
|
|
throw new Error('Full-scene export is disabled. Current research does not support a trustworthy raw floor or full-map reconstruction yet.');
|
|
}
|
|
const paletteSets = extractPaletteSets(wdl.buffer, wdl.headerWords);
|
|
const region02Summary = region02 ? summarizeRegion02(region02) : null;
|
|
const activeHeaderOverrideCandidates = scanActiveHeaderOverrideCandidates(wdl);
|
|
|
|
let bundles = scanSpriteBundles(region04).map((bundle) => ({ ...bundle, bundleSource: 'raw-scan' }));
|
|
const mode1RuntimePalette = await loadGpuDumpMode1Palette(options.gpuRamDumpPath);
|
|
const mode1PaletteBank = buildMode1PaletteBank(paletteSets.palettes16);
|
|
bundles = resolveBundlePalettes(
|
|
bundles,
|
|
paletteSets,
|
|
{ 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;
|
|
const runtimeMap0BindingResult = buildRuntimeMap0MaskedBindings(runtimeMap0Correlation, bundles);
|
|
|
|
const { items: sceneItems, skippedRecords } = await buildSceneItems(region04, recordSet.records, bundles, {
|
|
paletteSets,
|
|
bindingMode: options.bindingMode,
|
|
mode1RuntimePalette,
|
|
mode1PaletteBank,
|
|
runtimeMap0Bindings: runtimeMap0BindingResult.byType,
|
|
overrideBundlesByType: overrideBankResult.byType,
|
|
});
|
|
const bindingDiversity = summarizeBindingDiversity(sceneItems);
|
|
const authoredLayerSummary = summarizeAuthoredLayers(recordSet.records);
|
|
const renderedLayerSummary = summarizeRenderedLayers(sceneItems);
|
|
const sceneInterpretation = buildSceneInterpretation(recordSet, bindingDiversity);
|
|
const renderOptions = { background: DEFAULT_BACKGROUND };
|
|
const render = renderMap(sceneItems, renderOptions);
|
|
const debugRender = options.debugLabels ? renderMap(sceneItems, { ...renderOptions, drawLabels: true }) : null;
|
|
const layerRenders = renderedLayerSummary.map(({ layer }) => {
|
|
const layerItems = sceneItems.filter((item) => item.authoredLayer === layer);
|
|
return {
|
|
layer,
|
|
itemCount: layerItems.length,
|
|
render: renderMap(layerItems, renderOptions),
|
|
debugRender: options.debugLabels ? renderMap(layerItems, { ...renderOptions, drawLabels: true }) : null,
|
|
};
|
|
});
|
|
const bundleManifest = await writeBundleCache(spriteRoot, region04, bundles);
|
|
const validationBundleOffsets = (options.validationBundles ?? [])
|
|
.map((value) => parseBundleOffset(value))
|
|
.filter((value) => Number.isInteger(value));
|
|
const validationOutputs = await writeValidationOutputs(outputRoot, mapStem, region04, bundles, paletteSets, {
|
|
mode1RuntimePalette,
|
|
mode1PaletteBank,
|
|
}, validationBundleOffsets);
|
|
|
|
const summary = {
|
|
sourceFile: options.wdlPath,
|
|
mapSource: recordSet.source,
|
|
mapScope: describeMapScope(recordSet),
|
|
recordStartOffset: recordSet.recordStartOffset,
|
|
streamHeaderOffset: recordSet.streamHeaderOffset ?? null,
|
|
streamRecordCount: recordSet.streamRecordCount ?? null,
|
|
streamStructuredPrefixCount: recordSet.streamStructuredPrefixCount ?? null,
|
|
recordCount: recordSet.records.length,
|
|
renderableItemCount: sceneItems.length,
|
|
skippedProbeRecordCount: skippedRecords.length,
|
|
authoredLayerSummary,
|
|
renderedLayerSummary,
|
|
bundleCount: bundles.length,
|
|
bundleSource: bundles[0]?.bundleSource ?? 'none',
|
|
gpuRamDumpPath: options.gpuRamDumpPath ?? null,
|
|
artBindingSource: options.bindingMode === 'runtime-map0-masked-proxy'
|
|
? 'runtime-map0-masked-header-offset-proxy-with-raw-fallback'
|
|
: 'raw-typeword-bundle-slot-diagnostic',
|
|
runtimeMap0BindingTypeCount: runtimeMap0BindingResult.diagnostics.length,
|
|
runtimeMap0BindingTypes: runtimeMap0BindingResult.diagnostics,
|
|
activeHeaderOverrideCandidateCount: activeHeaderOverrideCandidates.length,
|
|
bestActiveHeaderOverrideCandidate: activeHeaderOverrideCandidates[0]
|
|
? {
|
|
sectionName: activeHeaderOverrideCandidates[0].sectionName,
|
|
absoluteOffset: activeHeaderOverrideCandidates[0].absoluteOffset,
|
|
count: activeHeaderOverrideCandidates[0].count,
|
|
directoryOffset: activeHeaderOverrideCandidates[0].directoryOffset,
|
|
nonZeroCount: activeHeaderOverrideCandidates[0].nonZeroCount,
|
|
clearCount: activeHeaderOverrideCandidates[0].clearCount,
|
|
size58Count: activeHeaderOverrideCandidates[0].size58Count,
|
|
}
|
|
: null,
|
|
bindingDiversity,
|
|
sceneInterpretation,
|
|
region02Note: region02Summary?.note ?? null,
|
|
limitations: [
|
|
...(sceneScope === 'full'
|
|
? ['Full-scene export is intentionally disabled until raw floor and subordinate-state decode is recovered from current research.']
|
|
: [
|
|
'Exports only standalone object-placement probes from the loader-sized section-0 root or constructor-placement families.',
|
|
'Auto mode now emits a layered probe that combines section-0 constructor placements with the smaller root-dispatch lane when both are available.',
|
|
'Constructor-placement output should currently be interpreted as a constructor-fed live-object seed lane rather than the final visible map.',
|
|
'The root-dispatch lane is now rendered as a second authored layer, but runtime-driven control mutations and dynamic effect spawns are still out of scope.',
|
|
'Known non-map-facing portrait/talk root types `0x0042` and `0x0049`, plus the known portrait bundle `0x000D84F4`, are excluded from probe rendering.',
|
|
'Viewer-derived sidecars, donor mappings, and cached scene references are intentionally disabled in this standalone exporter.',
|
|
options.bindingMode === 'runtime-map0-masked-proxy'
|
|
? 'Current bundle selection uses an experimental map-0 runtime masked-offset proxy where live headerWord11 low bits land near a scanned raw bundle; all non-matching types still fall back to the raw slot diagnostic.'
|
|
: 'Current bundle selection is still diagnostic-only until the late DAT_800758d8 art bank is parsed directly.',
|
|
'No floor or tile layer is decoded directly yet; post_audio_region_02 and the decompressed level-state lane remain unresolved.',
|
|
]),
|
|
'Palette routing remains partly heuristic when authored token and default bank evidence are both absent.',
|
|
],
|
|
debugLabels: Boolean(options.debugLabels),
|
|
headerSize: wdl.headerSize,
|
|
audioSize: wdl.audioSize,
|
|
boundaryCandidates: wdl.boundaryCandidates,
|
|
regions: wdl.regions.map((region) => ({
|
|
name: region.name,
|
|
offset: region.offset,
|
|
size: region.size,
|
|
})),
|
|
};
|
|
|
|
const sceneManifest = {
|
|
...summary,
|
|
renderWidth: render.width,
|
|
renderHeight: render.height,
|
|
bounds: render.bounds,
|
|
skippedRecords,
|
|
items: sceneItems.map((item) => ({
|
|
recordIndex: item.recordIndex,
|
|
recordSource: item.recordSource,
|
|
sourceFamily: item.sourceFamily,
|
|
authoredLayer: item.authoredLayer,
|
|
recordSide: item.recordSide,
|
|
rowIndex: item.rowIndex,
|
|
rawWords: item.rawWords,
|
|
typeWord: item.typeWord,
|
|
laneWord: item.laneWord,
|
|
worldX: item.worldX ?? null,
|
|
worldY: item.worldY ?? null,
|
|
worldZ: item.worldZ ?? null,
|
|
screenX: item.screenX,
|
|
screenY: item.screenY,
|
|
bundleSlot: item.bundleSlot,
|
|
bundleAbsoluteOffset: item.bundleAbsoluteOffset,
|
|
bundleSource: item.bundleSource,
|
|
requestedFrameIndex: item.requestedFrameIndex,
|
|
frameIndex: item.frameIndex,
|
|
instanceId: item.instanceId,
|
|
resourceKey: item.resourceKey,
|
|
resourceLabelId: item.labelId,
|
|
defaultPaletteIndex: item.defaultPaletteIndex,
|
|
resolvedPaletteIndex: item.resolvedPaletteIndex,
|
|
paletteFormula: item.paletteFormula,
|
|
mappingSource: item.mappingSource,
|
|
templateTypeId: item.templateTypeId,
|
|
donorTypeId: item.donorTypeId,
|
|
runtimeBindingMaskedAbsoluteOffset: item.runtimeBindingMaskedAbsoluteOffset,
|
|
runtimeBindingOffsetDelta: item.runtimeBindingOffsetDelta,
|
|
runtimeBindingVisibleCount: item.runtimeBindingVisibleCount,
|
|
runtimeBindingRawRecordCount: item.runtimeBindingRawRecordCount,
|
|
token06HighByte: item.paletteDiagnostics?.token06HighByte ?? null,
|
|
token0cHighByte: item.paletteDiagnostics?.token0cHighByte ?? null,
|
|
expectedPaletteToken: item.paletteDiagnostics?.expectedPaletteToken ?? null,
|
|
expectedAssignmentPath: item.paletteDiagnostics?.expectedAssignmentPath ?? null,
|
|
flipped: item.flipped,
|
|
drawX: item.drawX,
|
|
drawY: item.drawY,
|
|
width: item.width,
|
|
height: item.height,
|
|
originX: item.originX,
|
|
originY: item.originY,
|
|
stage: item.stage,
|
|
placeholder: item.placeholder === true,
|
|
})),
|
|
};
|
|
|
|
await fs.writeFile(path.join(cacheRoot, 'wdl-summary.json'), JSON.stringify(summary, null, 2));
|
|
await fs.writeFile(path.join(cacheRoot, 'records.json'), JSON.stringify(recordSet, null, 2));
|
|
await fs.writeFile(path.join(cacheRoot, 'bundles.json'), JSON.stringify(bundleManifest, null, 2));
|
|
await fs.writeFile(path.join(cacheRoot, 'frame-manifest.json'), JSON.stringify(bundleManifest, null, 2));
|
|
await fs.writeFile(path.join(cacheRoot, 'active-header-overrides.json'), JSON.stringify(activeHeaderOverrideCandidates, null, 2));
|
|
if (region02Summary) {
|
|
await fs.writeFile(path.join(cacheRoot, 'region02-analysis.json'), JSON.stringify(region02Summary, null, 2));
|
|
}
|
|
const region02Example = await writeRegion02Example(outputRoot, cacheRoot, mapStem, region02, region02Summary);
|
|
await fs.writeFile(path.join(outputRoot, `${mapStem}.png`), render.png);
|
|
if (debugRender) {
|
|
await fs.writeFile(path.join(outputRoot, `${mapStem}_labels.png`), debugRender.png);
|
|
}
|
|
for (const layerRender of layerRenders) {
|
|
await fs.writeFile(path.join(outputRoot, `${mapStem}_${layerRender.layer}.png`), layerRender.render.png);
|
|
if (layerRender.debugRender) {
|
|
await fs.writeFile(path.join(outputRoot, `${mapStem}_${layerRender.layer}_labels.png`), layerRender.debugRender.png);
|
|
}
|
|
}
|
|
await fs.writeFile(path.join(outputRoot, `${mapStem}.json`), JSON.stringify(sceneManifest, null, 2));
|
|
|
|
return {
|
|
mapStem,
|
|
cacheRoot,
|
|
outputRoot,
|
|
summary,
|
|
outputPngPath: path.join(outputRoot, `${mapStem}.png`),
|
|
debugPngPath: debugRender ? path.join(outputRoot, `${mapStem}_labels.png`) : null,
|
|
outputJsonPath: path.join(outputRoot, `${mapStem}.json`),
|
|
validationOutputs,
|
|
region02Example,
|
|
};
|
|
}
|