Fixed palettes
This commit is contained in:
parent
9fe261610f
commit
93bc6e7a07
6 changed files with 328 additions and 100 deletions
|
|
@ -201,27 +201,81 @@ For each record:
|
|||
- `screenX - originX`
|
||||
- `screenY - originY`
|
||||
|
||||
Current draw order is conservative:
|
||||
### Projection sign convention
|
||||
|
||||
- main-visible before special-visible
|
||||
- then ascending `screenY`
|
||||
- then ascending `screenX`
|
||||
`psx_project_object_main_visible @ 0x80040d44` writes `proj_x = Y - X` and
|
||||
`proj_y = 2*Z - (X + Y) / 2` to `obj+0x78/0x7c`. The engine's draw step at
|
||||
`0x80040e3c/0x80040e5c` then computes `screen = (cam - proj) - origin`, which
|
||||
flips the sign of the projection relative to canvas Y-down space. For a
|
||||
camera-less full-map export the exporter bakes that flip into
|
||||
`projectCtorPlacement`, so higher world-Z and smaller (X+Y) sit visibly higher
|
||||
on the output PNG. The same projection is applied to both constructor
|
||||
placements and dispatch-root records; dispatch-root X/Y fields are in world
|
||||
coordinates, not pre-projected screen coordinates, despite the runtime
|
||||
`camera +/- 0x140` cull comparing against them directly.
|
||||
|
||||
This is a probe approximation. The later graph-based stage-1 ordering still belongs to a future pass.
|
||||
### Authored layer semantics
|
||||
|
||||
The rendered PNG uses a neutral opaque background by default so probe silhouettes are legible without relying on transparency.
|
||||
The two authored lanes carry different responsibilities:
|
||||
|
||||
- `constructors`: static level geometry — walls, floors, architecture placed
|
||||
by `psx_dispatch_section0_constructor_placements`.
|
||||
- `roots`: interactive / dynamic objects — crates, terminals, doors, pickups
|
||||
placed by `psx_dispatch_section0_dispatch_roots`.
|
||||
|
||||
Despite the dispatcher name, the `roots` lane is not the map background; it is
|
||||
the live-object seed list. For the exporter, "constructors" is the geometry
|
||||
layer and "roots" is the object layer.
|
||||
|
||||
### Painter's order
|
||||
|
||||
The exporter sorts items before blitting using the following keys, in order:
|
||||
|
||||
1. `stage` ascending. `stage = 1` when `typeWord === 4` or `laneWord & 0x0400`
|
||||
is set; those overlays draw last.
|
||||
2. Authored-layer priority: `constructors` (0) before `roots` (1). Static
|
||||
geometry draws first so interactive props in the same room do not get
|
||||
hidden behind the floor or wall pieces that occupy the same cell.
|
||||
3. Isometric depth ascending: back-to-front by world `X + Y` (isometric
|
||||
ground-depth axis). Falls back to projected `screenY` when world
|
||||
coordinates are unavailable.
|
||||
4. World `Z` ascending within the same ground cell so lower elevations draw
|
||||
before taller objects sharing the same footprint.
|
||||
5. `screenX` ascending as a stable tie-breaker.
|
||||
|
||||
This is still an approximation of the engine's stage-1 graph order but is
|
||||
closer to what an isometric painter's algorithm would produce than the earlier
|
||||
screenY-only sort.
|
||||
|
||||
The rendered PNG uses a neutral opaque background by default so probe
|
||||
silhouettes are legible without relying on transparency.
|
||||
|
||||
## Color Rule
|
||||
|
||||
`v0` emits grayscale art from raw pixel indices.
|
||||
The exporter resolves palettes entirely from the WDL contents. It does not
|
||||
require any RAM or VRAM dump; those paths are now optional research overrides.
|
||||
|
||||
Reason:
|
||||
The map-local palette blob lives at `headerWords[2] .. headerWords[2] + 0x1000`
|
||||
(4096 bytes = 2048 colors = 128 × 16-entry CLUTs). The blob is what the engine
|
||||
uploads to VRAM rows `0xf0 .. 0xf7` on map load; each VRAM row is 16 CLUTs
|
||||
wide so the 128 CLUTs tile exactly 8 rows of 16 CLUTs.
|
||||
|
||||
- bundle frame decode is already well constrained
|
||||
- full CLUT parity is not
|
||||
- grayscale preserves shape/variant evidence without pretending the palette problem is solved
|
||||
Resolution rules by bundle mode:
|
||||
|
||||
Transparent index `0` stays transparent.
|
||||
- **Mode 2 (4bpp)**: the bundle header's `paletteIndex` at `+0x14` is the
|
||||
16-entry CLUT index into the WDL `palettes16` bank. When that index points
|
||||
at a sparse/empty CLUT the exporter falls back to a per-bundle palette sweep
|
||||
that picks a CLUT covering the pixel-index set used by the bundle frames.
|
||||
- **Mode 1 (8bpp)**: the 256-color CLUT is the concatenation of 16 consecutive
|
||||
16-entry CLUTs from the WDL bank. The bundle's `paletteIndex` is treated as
|
||||
the starting CLUT index. For the current L0 dataset every mode-1 bundle
|
||||
stores `paletteIndex = 0`, which is the top-left 256-color bank. Mode-1
|
||||
color fidelity is therefore approximate until the level-specific 256-CLUT
|
||||
source (suspected to live in the `stateBank` block) is decoded — tracked as
|
||||
a follow-up.
|
||||
|
||||
Transparent pixel index `0` stays transparent during blit regardless of the
|
||||
color value stored at CLUT index 0.
|
||||
|
||||
## CLI
|
||||
|
||||
|
|
@ -254,8 +308,13 @@ Supported options:
|
|||
|
||||
## Planned Follow-Ups
|
||||
|
||||
- extend `sceneInterpretation` so it reflects the landed loader-faithful binding instead of the older repeated-wrong-art warning
|
||||
- identify and parse the separate static-world or subordinate level substrate that complements the constructor-fed live-object lane, instead of treating section-0 constructor placements as the whole map
|
||||
- add palette/CLUT reconstruction
|
||||
- add stage-1 graph ordering recovery
|
||||
- compare the probe scene against fixed live samples such as `map 104` without reintroducing viewer-side donor assumptions
|
||||
- decode the `stateBank` and `stateBank2` blocks to recover the level-specific
|
||||
256-color CLUT used by mode-1 sprites. Current mode-1 palettes default to
|
||||
CLUT-bank start 0, which produces plausible colors for some sprites but
|
||||
renders many indoor floor tiles as solid green plates.
|
||||
- extend `sceneInterpretation` so it reflects the landed loader-faithful
|
||||
binding instead of the older repeated-wrong-art warning.
|
||||
- recover the engine's stage-1 graph ordering instead of approximating with
|
||||
isometric `(X + Y, Z, screenX)` sort keys.
|
||||
- compare the probe scene against fixed live samples such as `map 104` without
|
||||
reintroducing viewer-side donor assumptions.
|
||||
|
|
|
|||
|
|
@ -26,17 +26,45 @@ function psx555ToRgba(color) {
|
|||
}
|
||||
|
||||
export function extractPaletteSets(buffer, headerWords) {
|
||||
if (!Array.isArray(headerWords) || headerWords.length < 4) {
|
||||
if (!Array.isArray(headerWords) || headerWords.length < 9) {
|
||||
return { palettes16: [], palettes256: [] };
|
||||
}
|
||||
|
||||
const paletteOffset = headerWords[2];
|
||||
const paletteSize = headerWords[3];
|
||||
if (paletteSize !== 0x1000 || paletteOffset < 0 || paletteOffset + paletteSize > buffer.length) {
|
||||
// The runtime CLUT table is the LAST chunk of the level "section pack" that
|
||||
// wdl_resource_bundle_load_by_index @ 0x80039918 reads from the per-map .WDL
|
||||
// file. The 0x38-byte header is an array of 14 u32 sizes:
|
||||
// [c8, c4, c0, bc, b8, b4, b0, ac, a8, a4, a0, 9c, 98, 94]
|
||||
// The section pack covers the first sum(headerWords[0..9]) bytes after the
|
||||
// header. Within it, the engine derives section pointers as:
|
||||
// dispatch_roots @ +headerWords[0]
|
||||
// ctor_placement_records @ +headerWords[1]
|
||||
// ctor_placement_section @ +headerWords[2]
|
||||
// (local_bc scratch gap) @ headerWords[3] <-- SKIPPED in pointer math
|
||||
// section_pack_base @ +headerWords[4]
|
||||
// type_policy_table @ +headerWords[5]
|
||||
// 8006754c table @ +headerWords[6]
|
||||
// control_opcode_stream @ +headerWords[7]
|
||||
// psx_level_clut_table @ +headerWords[8] <-- CLUT starts here
|
||||
// Note that the engine's chain skips headerWords[3] (`local_bc`); that field
|
||||
// is a separate scratch reservation released by psx_level_heap_rewind after
|
||||
// level_palette_header_apply returns. The CLUT absolute file offset is
|
||||
// therefore 0x38 plus the sum of headerWords[0..2, 4..8].
|
||||
// The CLUT spans 0x1000 bytes (128 x 32-byte 16-color CLUTs) which matches
|
||||
// the engine's psx_clut_table_by_resource_bank layout (8 rows x 16 cols).
|
||||
// The exporter previously used headerWords[2] / headerWords[3] which are
|
||||
// actually section sizes, not a palette offset; that mismatch produced the
|
||||
// wrong colors (e.g. neon-green floors instead of grey concrete).
|
||||
let clutOffset = 0x38;
|
||||
for (let i = 0; i < 9 && i < headerWords.length; i += 1) {
|
||||
if (i === 3) continue; // skip local_bc scratch reservation
|
||||
clutOffset += headerWords[i] >>> 0;
|
||||
}
|
||||
const paletteSize = 0x1000;
|
||||
if (clutOffset < 0 || clutOffset + paletteSize > buffer.length) {
|
||||
return { palettes16: [], palettes256: [] };
|
||||
}
|
||||
|
||||
const blob = buffer.subarray(paletteOffset, paletteOffset + paletteSize);
|
||||
const blob = buffer.subarray(clutOffset, clutOffset + paletteSize);
|
||||
const palettes16 = [];
|
||||
const palettes256 = [];
|
||||
|
||||
|
|
@ -56,7 +84,7 @@ export function extractPaletteSets(buffer, headerWords) {
|
|||
palettes256.push(palette);
|
||||
}
|
||||
|
||||
return { palettes16, palettes256 };
|
||||
return { palettes16, palettes256, clutOffset };
|
||||
}
|
||||
|
||||
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
|
||||
|
|
|
|||
|
|
@ -12,17 +12,11 @@ function parseArgs(argv) {
|
|||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'Crusader-Map-Viewer',
|
||||
'crusader_map_viewer',
|
||||
'map_renderer',
|
||||
'STATIC_PSX'
|
||||
),
|
||||
gpuRamDump: path.resolve(
|
||||
moduleDir,
|
||||
'..',
|
||||
'..',
|
||||
'binary',
|
||||
'Crusader - No Remorse (USA) GPU RAM 2.bin'
|
||||
),
|
||||
gpuRamDump: null,
|
||||
mapSource: 'auto',
|
||||
bindingMode: 'raw',
|
||||
sceneScope: 'probe',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@ const GLYPHS = {
|
|||
'7': ['111', '001', '001', '001', '001'],
|
||||
'8': ['111', '101', '111', '101', '111'],
|
||||
'9': ['111', '101', '111', '001', '111'],
|
||||
'a': ['010', '101', '111', '101', '101'],
|
||||
'b': ['110', '101', '110', '101', '110'],
|
||||
'c': ['111', '100', '100', '100', '111'],
|
||||
'd': ['110', '101', '101', '101', '110'],
|
||||
'e': ['111', '100', '110', '100', '111'],
|
||||
'f': ['111', '100', '110', '100', '100'],
|
||||
'p': ['110', '101', '110', '100', '100'],
|
||||
't': ['111', '010', '010', '010', '010'],
|
||||
'#': ['101', '111', '101', '111', '101'],
|
||||
':': ['000', '010', '000', '010', '000'],
|
||||
'/': ['001', '001', '010', '100', '100'],
|
||||
'-': ['000', '000', '111', '000', '000'],
|
||||
};
|
||||
|
||||
function clearCanvas(width, height, background = null) {
|
||||
|
|
@ -87,6 +99,18 @@ function drawLabel(canvas, canvasWidth, canvasHeight, text, x, y) {
|
|||
}
|
||||
}
|
||||
|
||||
function drawBoundingBox(canvas, canvasWidth, canvasHeight, x, y, width, height, alpha = 220) {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
// Top + bottom edges
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x, y, width, 1, 255, 255, 255, alpha);
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x, y + height - 1, width, 1, 255, 255, 255, alpha);
|
||||
// Left + right edges
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x, y, 1, height, 255, 255, 255, alpha);
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x + width - 1, y, 1, height, 255, 255, 255, alpha);
|
||||
}
|
||||
|
||||
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
|
||||
for (let y = 0; y < sprite.height; y += 1) {
|
||||
const canvasY = dstY + y;
|
||||
|
|
@ -148,15 +172,37 @@ export function renderMap(items, options = {}) {
|
|||
}
|
||||
|
||||
if (options.drawLabels) {
|
||||
// Cap labels at 10 instances per (typeWord, bundleAbsoluteOffset) pair so
|
||||
// dense root layers (e.g. floor tiles) do not bury the labels we actually
|
||||
// need to read. The bounding box is also suppressed once the label budget
|
||||
// is exhausted so duplicate-instance clusters do not redraw white outlines
|
||||
// on every tile and obscure surrounding art.
|
||||
const labelBudgetPerBundle = 10;
|
||||
const labelTally = new Map();
|
||||
for (const item of items) {
|
||||
drawLabel(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
item.labelId ?? item.id,
|
||||
item.drawX - bounds.minX + padding,
|
||||
item.drawY - bounds.minY + padding
|
||||
);
|
||||
const tallyKey = `${item.typeWord ?? '?'}|${item.bundleAbsoluteOffset ?? '?'}`;
|
||||
const used = labelTally.get(tallyKey) ?? 0;
|
||||
if (used >= labelBudgetPerBundle) {
|
||||
continue;
|
||||
}
|
||||
labelTally.set(tallyKey, used + 1);
|
||||
const boxX = item.drawX - bounds.minX + padding;
|
||||
const boxY = item.drawY - bounds.minY + padding;
|
||||
const boxW = item.width ?? item.sprite?.width ?? 0;
|
||||
const boxH = item.height ?? item.sprite?.height ?? 0;
|
||||
drawBoundingBox(canvas, width, height, boxX, boxY, boxW, boxH);
|
||||
// Compose a useful label: typeWord (hex) + bundle absolute offset (last 4 hex)
|
||||
const labelParts = [];
|
||||
if (Number.isInteger(item.typeWord)) {
|
||||
labelParts.push('t' + item.typeWord.toString(16));
|
||||
}
|
||||
if (Number.isInteger(item.bundleAbsoluteOffset)) {
|
||||
labelParts.push('b' + (item.bundleAbsoluteOffset & 0xffff).toString(16).padStart(4, '0'));
|
||||
}
|
||||
if (labelParts.length === 0 && (item.labelId ?? item.id) !== undefined) {
|
||||
labelParts.push('#' + (item.labelId ?? item.id));
|
||||
}
|
||||
drawLabel(canvas, width, height, labelParts.join(':'), boxX, boxY);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -286,14 +286,34 @@ function isStructuredCandidate(words) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function projectCtorPlacement(xWord, yWord, zWord) {
|
||||
// Engine-accurate projection, matching `psx_project_object_main_visible`
|
||||
// (0x80040d44) followed by the camera subtraction at 0x80040e3c/0x80040e5c.
|
||||
//
|
||||
// The projection step writes to obj+0x78/0x7c:
|
||||
// proj_x = Y - X
|
||||
// proj_y = 2*Z - (X+Y)/2
|
||||
// The draw step then writes to obj+0x20/0x22:
|
||||
// screen_x = (cam_X - proj_x) - origin_X
|
||||
// screen_y = (cam_Y - proj_y) - origin_Y
|
||||
// i.e. the engine renders the world with the sign flipped relative to the
|
||||
// raw projection. For a camera-less full-map export we fold that flip into
|
||||
// the projection so higher world Z appears higher on the canvas (Y-down).
|
||||
const projX = yWord - xWord;
|
||||
const projY = (2 * zWord) - Math.floor((xWord + yWord) / 2);
|
||||
return {
|
||||
screenX: -projX * PSX_SCREEN_SCALE,
|
||||
screenY: -projY * PSX_SCREEN_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecord(words, source, offset, rawWords = words) {
|
||||
if (!isPlausibleRecord(words)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
const screenX = (yWord - xWord) * PSX_SCREEN_SCALE;
|
||||
const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE;
|
||||
const { screenX, screenY } = projectCtorPlacement(xWord, yWord, zWord);
|
||||
|
||||
return {
|
||||
index: -1,
|
||||
|
|
@ -595,11 +615,17 @@ export function parseRegion01Records(region) {
|
|||
|
||||
// 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.
|
||||
// `psx_object_create_compound_record @ 0x80025040`:
|
||||
// +0x00 u16 typeWord -> descriptor_table index
|
||||
// +0x02 u16 X -> obj world X (<<16 seed)
|
||||
// +0x04 u16 Y -> obj world Y (<<16 seed)
|
||||
// +0x06 u8 Z (low byte only!)-> obj world Z (<<16 seed). The high byte at
|
||||
// +0x07 is not read as part of Z.
|
||||
// +0x08 u8 selector -> state-script seed
|
||||
// +0x09 u8 (padding/unknown)
|
||||
// +0x0a u16 laneWord -> OR'd into obj+0x1c (flags/lane)
|
||||
// The previous implementation decoded Z as a u16 from +0x06, which mixed the
|
||||
// selector byte into Z and produced large bogus elevations.
|
||||
export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
||||
if (!block || !block.buffer || block.size < 4) {
|
||||
return { source: 'ctorPlacements', records: [], count: 0 };
|
||||
|
|
@ -614,11 +640,17 @@ export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
|||
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);
|
||||
const typeWord = readU16LE(block.buffer, recordOffset + 0x00);
|
||||
const xWord = readU16LE(block.buffer, recordOffset + 0x02);
|
||||
const yWord = readU16LE(block.buffer, recordOffset + 0x04);
|
||||
const zWord = block.buffer[recordOffset + 0x06];
|
||||
const z2Byte = block.buffer[recordOffset + 0x07];
|
||||
const selectorWord = block.buffer[recordOffset + 0x08];
|
||||
const selectorHighByte = block.buffer[recordOffset + 0x09];
|
||||
const laneWord = readU16LE(block.buffer, recordOffset + 0x0a);
|
||||
const rawWords = [typeWord, xWord, yWord, (z2Byte << 8) | zWord, (selectorHighByte << 8) | selectorWord, laneWord];
|
||||
const words = [typeWord, xWord, yWord, zWord, selectorWord, laneWord];
|
||||
const record = buildRecord(words, 'ctorPlacements', recordOffset, rawWords);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -646,17 +678,17 @@ export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
|||
// `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.
|
||||
// +0x04 u16 typeId (descriptor_table index)
|
||||
// +0x08 u16 worldX (compared to cam+0x3e +/- 0x140 — cam+0x3e is the
|
||||
// camera's world-X short, not the projected integer
|
||||
// part; proof: root worldX values overlap constructor
|
||||
// placement worldX values in the same map)
|
||||
// +0x0a u16 worldY (compared to cam+0x42 +/- 0x140)
|
||||
// +0x10 u16 flags (bit 3 = skip this record)
|
||||
// We therefore project root worldX/worldY through the same projection as
|
||||
// constructor placements (with Z=0 since dispatch roots do not carry a Z byte
|
||||
// at a known offset) and render them on the same canvas. The trailing raw
|
||||
// fields are preserved as `dispatchRootRawWords` for future probes.
|
||||
export function parseDispatchRootsBlock(block, variant = 'lset') {
|
||||
if (!block || !block.buffer || block.size < 4) {
|
||||
return { source: 'dispatchRoots', records: [], count: 0 };
|
||||
|
|
@ -675,17 +707,6 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
|
|||
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;
|
||||
|
|
@ -693,14 +714,12 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
|
|||
const typeWord = rawWords[2];
|
||||
const xWord = rawWords[4];
|
||||
const yWord = rawWords[5];
|
||||
const zWord = rawWords[3] & 0xff;
|
||||
// Dispatch roots do not carry a Z byte at a well-known offset; they are
|
||||
// treated as floor-plane (Z=0) until a Z field is recovered from the
|
||||
// descriptor's slot0 callback.
|
||||
const zWord = 0;
|
||||
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;
|
||||
}
|
||||
|
|
@ -708,8 +727,7 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
|
|||
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 { screenX, screenY } = projectCtorPlacement(xWord, yWord, zWord);
|
||||
const record = {
|
||||
index: -1,
|
||||
source: 'dispatchRoots',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue