diff --git a/psx-map-exporter/docs/spec.md b/psx-map-exporter/docs/spec.md index dbccc85..f796d09 100644 --- a/psx-map-exporter/docs/spec.md +++ b/psx-map-exporter/docs/spec.md @@ -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. diff --git a/psx-map-exporter/src/bundles.js b/psx-map-exporter/src/bundles.js index 7c8bb16..2d8ffb7 100644 --- a/psx-map-exporter/src/bundles.js +++ b/psx-map-exporter/src/bundles.js @@ -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) { diff --git a/psx-map-exporter/src/cli.js b/psx-map-exporter/src/cli.js index 101a870..23cb793 100644 --- a/psx-map-exporter/src/cli.js +++ b/psx-map-exporter/src/cli.js @@ -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', diff --git a/psx-map-exporter/src/export-map.js b/psx-map-exporter/src/export-map.js index 49a7455..b6a18ac 100644 --- a/psx-map-exporter/src/export-map.js +++ b/psx-map-exporter/src/export-map.js @@ -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; }); diff --git a/psx-map-exporter/src/render.js b/psx-map-exporter/src/render.js index 0b2881e..b163a0c 100644 --- a/psx-map-exporter/src/render.js +++ b/psx-map-exporter/src/render.js @@ -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); } } diff --git a/psx-map-exporter/src/wdl.js b/psx-map-exporter/src/wdl.js index 25b6ecb..1df5ad8 100644 --- a/psx-map-exporter/src/wdl.js +++ b/psx-map-exporter/src/wdl.js @@ -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',