Fixed palettes

This commit is contained in:
MaddoScientisto 2026-04-18 14:38:40 +02:00
commit 93bc6e7a07
6 changed files with 328 additions and 100 deletions

View file

@ -525,7 +525,15 @@ function summarizeRenderedLayers(items) {
}
function derivePaletteDiagnostics(record, bundle) {
const rawWords = Array.isArray(record.rawWords) ? record.rawWords : [];
// 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;
@ -1008,29 +1016,57 @@ function resolveBundlePalettes(bundles, paletteSets, options = {}) {
let paletteFormula = null;
if (bundle.mode === 2) {
if (!Number.isInteger(resolvedPaletteIndex) || resolvedPaletteIndex < 0 || resolvedPaletteIndex >= paletteSets.palettes16.length) {
resolvedPaletteIndex = choosePalette(paletteSets.palettes16, bundle.frames, bundle.mode);
// 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 (Number.isInteger(resolvedPaletteIndex) && resolvedPaletteIndex >= 0 && resolvedPaletteIndex < paletteSets.palettes16.length) {
palette = paletteSets.palettes16[resolvedPaletteIndex];
paletteFormula = 'mode2-bundle-or-usage-index';
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) {
if (options.mode1RuntimePalette?.length === 256) {
// 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;
}
if (palette) {
paletteFormula = options.mode1RuntimePalette?.length === 256
? 'mode1-live-gpu-ram-row-f0-x0'
: 'mode1-runtime-clut-band-start-index-0';
paletteFormula = palette ? 'mode1-runtime-clut-band-start-index-0' : null;
}
}
@ -1050,7 +1086,14 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
const skippedRecords = [];
const nonMapFacingRootTypes = new Set([0x42, 0x49]);
const nonMapFacingBundleOffsets = new Set([0x000d84f4]);
// 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.
const nonMapFacingBundleOffsets = new Set([0x000d84f4, 0x00074f44]);
for (const record of records) {
if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) {
skippedRecords.push({
@ -1102,6 +1145,9 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
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,
@ -1134,12 +1180,49 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
});
}
// 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. `authoredLayer`: `constructors` is the static geometry lane (walls,
// floors, architecture placed by `psx_dispatch_section0_constructor_placements`).
// `roots` is the dispatch-root lane — interactive/dynamic objects such as
// crates, terminals, doors, pickups placed by
// `psx_dispatch_section0_dispatch_roots`. Geometry draws first so that
// props (roots) sit ON TOP of the room they occupy.
// 3. Isometric depth within a layer: back-to-front by world `X + Y` (ground
// depth along the engine's isometric axis), then by `Z` ascending so a
// lower object at the same ground cell does not occlude a taller one
// behind it. Falls back to screenY when world coords are unavailable.
// 4. `screenX` ascending: stable tie-breaker left-to-right.
const layerPriority = (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;
}
if (left.screenY !== right.screenY) {
return left.screenY - right.screenY;
const leftLayer = layerPriority(left);
const rightLayer = layerPriority(right);
if (leftLayer !== rightLayer) {
return leftLayer - rightLayer;
}
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;
}
return left.screenX - right.screenX;
});