From 93bc6e7a07e40b4d566ae817c422f2e238bed444 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 18 Apr 2026 14:38:40 +0200 Subject: [PATCH 1/4] Fixed palettes --- psx-map-exporter/docs/spec.md | 93 ++++++++++++++++++----- psx-map-exporter/src/bundles.js | 40 ++++++++-- psx-map-exporter/src/cli.js | 10 +-- psx-map-exporter/src/export-map.js | 115 +++++++++++++++++++++++++---- psx-map-exporter/src/render.js | 62 ++++++++++++++-- psx-map-exporter/src/wdl.js | 102 ++++++++++++++----------- 6 files changed, 325 insertions(+), 97 deletions(-) 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', From 2b1f1a0191b22c3fec6548c04f042cbd2742fe95 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 18 Apr 2026 16:34:35 +0200 Subject: [PATCH 2/4] map viewer --- psx-map-exporter/.gitignore | 6 +- psx-map-exporter/scripts/export-all.mjs | 170 +++ psx-map-exporter/src/bundles.js | 8 +- psx-map-exporter/src/cli.js | 45 +- psx-map-exporter/src/export-map.js | 157 ++- psx-map-exporter/src/render.js | 81 +- psx-map-exporter/src/wdl.js | 12 +- psx-map-exporter/viewer/index.html | 12 + psx-map-exporter/viewer/package-lock.json | 1207 +++++++++++++++++++++ psx-map-exporter/viewer/package.json | 18 + psx-map-exporter/viewer/src/App.vue | 424 ++++++++ psx-map-exporter/viewer/src/ItemPanel.vue | 102 ++ psx-map-exporter/viewer/src/main.js | 5 + psx-map-exporter/viewer/src/style.css | 38 + psx-map-exporter/viewer/vite.config.js | 110 ++ 15 files changed, 2355 insertions(+), 40 deletions(-) create mode 100644 psx-map-exporter/scripts/export-all.mjs create mode 100644 psx-map-exporter/viewer/index.html create mode 100644 psx-map-exporter/viewer/package-lock.json create mode 100644 psx-map-exporter/viewer/package.json create mode 100644 psx-map-exporter/viewer/src/App.vue create mode 100644 psx-map-exporter/viewer/src/ItemPanel.vue create mode 100644 psx-map-exporter/viewer/src/main.js create mode 100644 psx-map-exporter/viewer/src/style.css create mode 100644 psx-map-exporter/viewer/vite.config.js diff --git a/psx-map-exporter/.gitignore b/psx-map-exporter/.gitignore index 7d70366..da86674 100644 --- a/psx-map-exporter/.gitignore +++ b/psx-map-exporter/.gitignore @@ -1,3 +1,7 @@ .cache/** .output/** -node_modules/** \ No newline at end of file +.output-render/** +.tmp/** +node_modules/** +viewer/dist/** +viewer/.vite/** \ No newline at end of file diff --git a/psx-map-exporter/scripts/export-all.mjs b/psx-map-exporter/scripts/export-all.mjs new file mode 100644 index 0000000..3735207 --- /dev/null +++ b/psx-map-exporter/scripts/export-all.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// Batch-export every LSET*/L*.WDL found under the PSX disc root into a +// permanent .output-render/// directory tree. Each export +// runs as a separate child process so an OOM or crash on one map cannot kill +// the batch. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..'); +const CLI_PATH = path.join(PROJECT_ROOT, 'src', 'cli.js'); + +const VARIANTS = [ + { label: 'auto', mapSource: 'auto' }, + { label: 'region01', mapSource: 'region01' }, +]; + +function parseArgs(argv) { + const options = { + discRoot: 'E:/emu/psx/Crusader - No Remorse', + outputRoot: path.join(PROJECT_ROOT, '.output-render'), + debugLabels: true, + only: null, + timeoutMs: 300000, + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === '--disc-root') { options.discRoot = path.resolve(next); index += 1; } + else if (arg === '--output-root') { options.outputRoot = path.resolve(next); index += 1; } + else if (arg === '--no-debug-labels') { options.debugLabels = false; } + else if (arg === '--only') { + options.only = String(next).split(',').map((v) => v.trim()).filter(Boolean); + index += 1; + } else if (arg === '--timeout-ms') { + options.timeoutMs = Number(next); index += 1; + } + } + return options; +} + +async function discoverMaps(discRoot) { + const entries = await fs.readdir(discRoot, { withFileTypes: true }); + const maps = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!/^LSET\d+$/i.test(entry.name)) continue; + const dir = path.join(discRoot, entry.name); + const files = await fs.readdir(dir); + for (const file of files) { + if (!/^L\d+\.WDL$/i.test(file)) continue; + maps.push({ + set: entry.name, + name: path.parse(file).name, + wdlPath: path.join(dir, file), + sourceRel: `${entry.name}/${file}`, + }); + } + } + maps.sort((a, b) => { + const an = Number.parseInt(a.name.replace(/^L/i, ''), 10); + const bn = Number.parseInt(b.name.replace(/^L/i, ''), 10); + return an - bn; + }); + return maps; +} + +function runExport(mapEntry, variant, options) { + return new Promise((resolve) => { + const outDir = path.join(options.outputRoot, mapEntry.name, variant.label); + const args = [ + '--max-old-space-size=4096', + CLI_PATH, + '--disc-root', options.discRoot, + '--source', mapEntry.sourceRel, + '--map-source', variant.mapSource, + '--output-root', outDir, + ]; + if (options.debugLabels) args.push('--debug-labels'); + + const started = Date.now(); + const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let killed = false; + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + }, options.timeoutMs); + + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('close', (code, signal) => { + clearTimeout(timer); + const ms = Date.now() - started; + resolve({ outDir, code, signal, killed, ms, stdout, stderr }); + }); + }); +} + +async function main() { + const options = parseArgs(process.argv); + const maps = await discoverMaps(options.discRoot); + const filtered = options.only ? maps.filter((m) => options.only.includes(m.name)) : maps; + + console.log(`Found ${filtered.length} maps under ${options.discRoot}`); + await fs.mkdir(options.outputRoot, { recursive: true }); + + const summary = []; + let okCount = 0; + let failCount = 0; + for (const mapEntry of filtered) { + for (const variant of VARIANTS) { + const tag = `[${mapEntry.set}/${mapEntry.name}] variant=${variant.label}`; + process.stdout.write(`${tag} ... `); + const result = await runExport(mapEntry, variant, options); + if (result.code === 0) { + okCount += 1; + process.stdout.write(`OK (${result.ms}ms)\n`); + summary.push({ + set: mapEntry.set, + map: mapEntry.name, + variant: variant.label, + ms: result.ms, + ok: true, + outDir: path.relative(options.outputRoot, result.outDir), + }); + } else { + failCount += 1; + const reason = result.killed + ? `TIMEOUT after ${result.ms}ms` + : `exit ${result.code}${result.signal ? ' signal ' + result.signal : ''}`; + process.stdout.write(`FAIL (${reason})\n`); + if (result.stderr) { + process.stdout.write(` stderr: ${result.stderr.trim().split(/\r?\n/).slice(-5).join('\n stderr: ')}\n`); + } + summary.push({ + set: mapEntry.set, + map: mapEntry.name, + variant: variant.label, + ms: result.ms, + ok: false, + reason, + stderrTail: result.stderr.trim().split(/\r?\n/).slice(-10).join('\n'), + outDir: path.relative(options.outputRoot, result.outDir), + }); + } + } + } + + const indexPath = path.join(options.outputRoot, 'index.json'); + await fs.writeFile(indexPath, JSON.stringify({ + discRoot: options.discRoot, + generatedAt: new Date().toISOString(), + variants: VARIANTS.map((v) => v.label), + okCount, + failCount, + maps: summary, + }, null, 2)); + console.log(`\nWrote index ${indexPath} (ok=${okCount} fail=${failCount})`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/psx-map-exporter/src/bundles.js b/psx-map-exporter/src/bundles.js index 2d8ffb7..147552e 100644 --- a/psx-map-exporter/src/bundles.js +++ b/psx-map-exporter/src/bundles.js @@ -8,6 +8,10 @@ function readU16LE(buffer, offset) { return buffer.readUInt16LE(offset); } +function readS16LE(buffer, offset) { + return buffer.readInt16LE(offset); +} + function rowByteWidth(width, mode) { return mode === 2 ? Math.ceil(width / 2) : width; } @@ -314,8 +318,8 @@ export function extractBundleFromHeader(payloadBuffer, absoluteHeaderOffset) { const relativeDataOffset = readU32LE(payloadBuffer, entryOffset + 0x08); const frameWidth = readU16LE(payloadBuffer, entryOffset + 0x0c); const frameHeight = readU16LE(payloadBuffer, entryOffset + 0x0e); - const originX = readU16LE(payloadBuffer, entryOffset + 0x10); - const originY = readU16LE(payloadBuffer, entryOffset + 0x12); + const originX = readS16LE(payloadBuffer, entryOffset + 0x10); + const originY = readS16LE(payloadBuffer, entryOffset + 0x12); const dataStart = dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset); const rawSize = rowByteWidth(frameWidth, mode) * frameHeight; diff --git a/psx-map-exporter/src/cli.js b/psx-map-exporter/src/cli.js index 23cb793..661a9f9 100644 --- a/psx-map-exporter/src/cli.js +++ b/psx-map-exporter/src/cli.js @@ -1,21 +1,37 @@ +import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { exportMap } from './export-map.js'; +// Resolve the on-disc PSX asset root. We prefer the ORIGINAL extracted disc +// files so the exporter never depends on a pre-processed cache; the cached +// STATIC_PSX copy in the sibling viewer workspace is kept only as a fallback +// for environments without the disc mounted. +function resolveDefaultDiscRoot(moduleDir) { + const candidates = [ + 'E:/emu/psx/Crusader - No Remorse', + 'E:/emu/psx/Crusader 2 Pre-Pre Alpha', + path.resolve(moduleDir, '..', '..', '..', 'crusader_map_viewer', 'map_renderer', 'STATIC_PSX'), + ]; + for (const candidate of candidates) { + try { + const stat = fs.statSync(candidate); + if (stat.isDirectory()) { + return candidate; + } + } catch { + // try next + } + } + return candidates[candidates.length - 1]; +} + function parseArgs(argv) { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const options = { - discRoot: path.resolve( - moduleDir, - '..', - '..', - '..', - 'crusader_map_viewer', - 'map_renderer', - 'STATIC_PSX' - ), + discRoot: resolveDefaultDiscRoot(moduleDir), gpuRamDump: null, mapSource: 'auto', bindingMode: 'raw', @@ -75,6 +91,15 @@ function parseArgs(argv) { index += 1; continue; } + if (arg === '--output-root') { + options.outputRoot = path.resolve(next); + index += 1; + continue; + } + if (arg === '--no-reset-output') { + options.resetOutputRoot = false; + continue; + } if (arg === '--debug-labels') { options.debugLabels = true; continue; @@ -140,6 +165,8 @@ async function main() { gpuRamDumpPath: options.gpuRamDump, validationBundles: options.validationBundles, outName: options.outName, + outputRoot: options.outputRoot, + resetOutputRoot: options.resetOutputRoot, debugLabels: Boolean(options.debugLabels), }); diff --git a/psx-map-exporter/src/export-map.js b/psx-map-exporter/src/export-map.js index b6a18ac..3cfaa72 100644 --- a/psx-map-exporter/src/export-map.js +++ b/psx-map-exporter/src/export-map.js @@ -431,6 +431,21 @@ function chooseBundleBinding(record, bundles, options = {}) { 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; @@ -1093,7 +1108,11 @@ async function buildSceneItems(region04, records, bundles, options = {}) { // 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]); + // 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({ @@ -1107,7 +1126,62 @@ async function buildSceneItems(region04, records, bundles, options = {}) { const binding = chooseBundleBinding(record, bundles, options); const bundle = binding?.bundle ?? null; - if (!bundle || !binding) { + 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)) { @@ -1171,9 +1245,27 @@ async function buildSceneItems(region04, records, bundles, options = {}) { height: sprite.height, originX: sprite.originX, originY: sprite.originY, - drawX: record.screenX - sprite.originX, + // 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, @@ -1183,18 +1275,23 @@ 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) => { + // 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; @@ -1209,10 +1306,13 @@ async function buildSceneItems(region04, records, bundles, options = {}) { if (left.stage !== right.stage) { return left.stage - right.stage; } - const leftLayer = layerPriority(left); - const rightLayer = layerPriority(right); - if (leftLayer !== rightLayer) { - return leftLayer - rightLayer; + // 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); @@ -1224,6 +1324,11 @@ async function buildSceneItems(region04, records, bundles, options = {}) { 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; }); @@ -1283,13 +1388,19 @@ export async function exportMap(options) { const cacheBaseRoot = path.join(options.projectRoot, '.cache'); const cacheRoot = path.join(options.projectRoot, '.cache', mapStem); const spriteRoot = path.join(cacheRoot, 'sprites'); - const outputRoot = path.join(options.projectRoot, '.output'); + 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); - await resetDirectory(outputRoot); + 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'); @@ -1446,6 +1557,9 @@ export async function exportMap(options) { 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, @@ -1478,6 +1592,7 @@ export async function exportMap(options) { originX: item.originX, originY: item.originY, stage: item.stage, + placeholder: item.placeholder === true, })), }; diff --git a/psx-map-exporter/src/render.js b/psx-map-exporter/src/render.js index b163a0c..44608a1 100644 --- a/psx-map-exporter/src/render.js +++ b/psx-map-exporter/src/render.js @@ -139,12 +139,68 @@ function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped } } +// Magenta diamond marker for placeholder (non-renderable) entities such as +// spawners, triggers, NPCs, and UI panels. Drawn in place of a sprite blit so +// the viewer still surfaces the entity for inspection without guessing wrong +// art. The diamond is 12x12 by default (caller passes the placeholder width). +function drawPlaceholderMarker(canvas, canvasWidth, canvasHeight, dstX, dstY, w, h) { + const half = Math.max(2, Math.floor(Math.min(w, h) / 2)); + const cx = Math.round(dstX + w / 2); + const cy = Math.round(dstY + h / 2); + // Filled diamond by scanline. + for (let dy = -half; dy <= half; dy += 1) { + const span = half - Math.abs(dy); + const y = cy + dy; + if (y < 0 || y >= canvasHeight) continue; + for (let dx = -span; dx <= span; dx += 1) { + const x = cx + dx; + if (x < 0 || x >= canvasWidth) continue; + // Magenta interior with darker outline on the diamond edge. + const onEdge = Math.abs(dx) + Math.abs(dy) === half; + const target = ((y * canvasWidth) + x) * 4; + canvas[target + 0] = onEdge ? 90 : 220; + canvas[target + 1] = onEdge ? 0 : 60; + canvas[target + 2] = onEdge ? 110 : 200; + canvas[target + 3] = 255; + } + } +} + export function renderMap(items, options = {}) { if (items.length === 0) { throw new Error('No renderable scene items were produced.'); } - const bounds = items.reduce( + // Guard against a single bad item (e.g. corrupt sprite origin) inflating the + // canvas to multiple gigabytes. We compute the median centroid of all items + // and drop anything farther than MAX_EXTENT px from it before computing + // bounds. This keeps render output sane even if the parser produces a few + // outliers, while preserving every real authored item (the playfield is + // never larger than a few thousand pixels in either axis). + const MAX_EXTENT = 4096; + const centroids = items.map((it) => ({ x: it.drawX + (it.width ?? 0) / 2, y: it.drawY + (it.height ?? 0) / 2 })); + const sortedX = centroids.map((c) => c.x).sort((a, b) => a - b); + const sortedY = centroids.map((c) => c.y).sort((a, b) => a - b); + const medianX = sortedX[sortedX.length >> 1]; + const medianY = sortedY[sortedY.length >> 1]; + const droppedOutliers = []; + const visibleItems = items.filter((it, idx) => { + const dx = Math.abs(centroids[idx].x - medianX); + const dy = Math.abs(centroids[idx].y - medianY); + if (dx > MAX_EXTENT || dy > MAX_EXTENT) { + droppedOutliers.push({ recordIndex: it.recordIndex, typeWord: it.typeWord, drawX: it.drawX, drawY: it.drawY, dx, dy }); + return false; + } + return true; + }); + if (droppedOutliers.length > 0 && process.env.PSX_TRACE) { + console.error('[renderMap] dropped ' + droppedOutliers.length + ' outlier item(s) beyond ' + MAX_EXTENT + 'px from median; first: ' + JSON.stringify(droppedOutliers[0])); + } + if (visibleItems.length === 0) { + throw new Error('All scene items were classified as outliers; nothing to render.'); + } + + const bounds = visibleItems.reduce( (state, item) => ({ minX: Math.min(state.minX, item.drawX), minY: Math.min(state.minY, item.drawY), @@ -157,9 +213,27 @@ export function renderMap(items, options = {}) { const padding = 16; const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2)); const height = Math.max(1, (bounds.maxY - bounds.minY) + (padding * 2)); + if (process.env.PSX_TRACE) { + console.error('[renderMap] items=' + visibleItems.length + ' bounds=' + JSON.stringify(bounds) + ' size=' + width + 'x' + height); + } const canvas = clearCanvas(width, height, options.background ?? DEFAULT_BACKGROUND); - for (const item of items) { + for (const item of visibleItems) { + if (item.placeholder || !item.sprite) { + // Draw a small magenta diamond marker for non-renderable entities + // (spawners, triggers, NPCs). Keeps them visible and inspectable in + // the viewer overlay without guessing wrong art. + drawPlaceholderMarker( + canvas, + width, + height, + item.drawX - bounds.minX + padding, + item.drawY - bounds.minY + padding, + item.width, + item.height + ); + continue; + } blitRgba( canvas, width, @@ -179,7 +253,7 @@ export function renderMap(items, options = {}) { // on every tile and obscure surrounding art. const labelBudgetPerBundle = 10; const labelTally = new Map(); - for (const item of items) { + for (const item of visibleItems) { const tallyKey = `${item.typeWord ?? '?'}|${item.bundleAbsoluteOffset ?? '?'}`; const used = labelTally.get(tallyKey) ?? 0; if (used >= labelBudgetPerBundle) { @@ -210,6 +284,7 @@ export function renderMap(items, options = {}) { width, height, bounds, + droppedOutlierCount: droppedOutliers.length, png: encodePng(canvas, width, height), }; } diff --git a/psx-map-exporter/src/wdl.js b/psx-map-exporter/src/wdl.js index 1df5ad8..162dedf 100644 --- a/psx-map-exporter/src/wdl.js +++ b/psx-map-exporter/src/wdl.js @@ -714,10 +714,14 @@ export function parseDispatchRootsBlock(block, variant = 'lset') { const typeWord = rawWords[2]; const xWord = rawWords[4]; const yWord = rawWords[5]; - // 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; + // Engine reads Z from the LOW byte of the u16 at +0x06 of the shared + // authored prefix (constructor view), even when the record is iterated + // through the dispatcher view. Confirmed via psx_object_create_compound_record + // @ 0x80024eec (memory note psx-coordinate-decode-2026-04-18.md). The HIGH + // byte of the same u16 is a palette-override token, NOT a Z extension. + // The constructor-view u16 at +0x06 lives at rawWords[3] in this 12-u16 + // expansion of the 24-byte dispatch-root record. + const zWord = rawWords[3] & 0xff; const selectorWord = rawWords[6]; const laneWord = rawWords[7]; if (typeWord < 0x20 || typeWord > 0x1ff) { diff --git a/psx-map-exporter/viewer/index.html b/psx-map-exporter/viewer/index.html new file mode 100644 index 0000000..08251b9 --- /dev/null +++ b/psx-map-exporter/viewer/index.html @@ -0,0 +1,12 @@ + + + + + + PSX Map Debug Viewer + + +
+ + + diff --git a/psx-map-exporter/viewer/package-lock.json b/psx-map-exporter/viewer/package-lock.json new file mode 100644 index 0000000..634f0dc --- /dev/null +++ b/psx-map-exporter/viewer/package-lock.json @@ -0,0 +1,1207 @@ +{ + "name": "psx-map-debug-viewer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "psx-map-debug-viewer", + "version": "0.0.0", + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/psx-map-exporter/viewer/package.json b/psx-map-exporter/viewer/package.json new file mode 100644 index 0000000..61bd95d --- /dev/null +++ b/psx-map-exporter/viewer/package.json @@ -0,0 +1,18 @@ +{ + "name": "psx-map-debug-viewer", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.0" + } +} diff --git a/psx-map-exporter/viewer/src/App.vue b/psx-map-exporter/viewer/src/App.vue new file mode 100644 index 0000000..c6f6a2a --- /dev/null +++ b/psx-map-exporter/viewer/src/App.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/psx-map-exporter/viewer/src/ItemPanel.vue b/psx-map-exporter/viewer/src/ItemPanel.vue new file mode 100644 index 0000000..1c7beca --- /dev/null +++ b/psx-map-exporter/viewer/src/ItemPanel.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/psx-map-exporter/viewer/src/main.js b/psx-map-exporter/viewer/src/main.js new file mode 100644 index 0000000..8dd6bc1 --- /dev/null +++ b/psx-map-exporter/viewer/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './style.css'; + +createApp(App).mount('#app'); diff --git a/psx-map-exporter/viewer/src/style.css b/psx-map-exporter/viewer/src/style.css new file mode 100644 index 0000000..fc66b02 --- /dev/null +++ b/psx-map-exporter/viewer/src/style.css @@ -0,0 +1,38 @@ +:root { + color-scheme: dark; + font-family: ui-sans-serif, system-ui, sans-serif; + --bg: #121212; + --panel: #1c1c1f; + --panel-2: #25252a; + --border: #2f2f36; + --text: #e6e6e6; + --muted: #9aa0a6; + --accent: #4ea1ff; + --warn: #ffb24a; +} + +* { box-sizing: border-box; } + +html, body, #app { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); +} + +button, select { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font: inherit; + cursor: pointer; +} + +button:hover, select:hover { border-color: var(--accent); } + +input[type="checkbox"] { accent-color: var(--accent); } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } diff --git a/psx-map-exporter/viewer/vite.config.js b/psx-map-exporter/viewer/vite.config.js new file mode 100644 index 0000000..28f4ac7 --- /dev/null +++ b/psx-map-exporter/viewer/vite.config.js @@ -0,0 +1,110 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +const RENDER_ROOT = path.resolve(__dirname, '..', '.output-render'); +const CACHE_ROOT = path.resolve(__dirname, '..', '.cache'); + +// Tiny dev plugin that exposes the .output-render directory at /render and +// serves a /api/index endpoint enumerating maps and variants. Keeps the app +// dependency-free of any external server. +function renderRootPlugin() { + return { + name: 'psx-render-root', + configureServer(server) { + server.middlewares.use('/render', async (req, res, next) => { + try { + const url = decodeURIComponent(req.url.split('?')[0]); + const filePath = path.join(RENDER_ROOT, url); + if (!filePath.startsWith(RENDER_ROOT)) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + const entries = await fs.readdir(filePath, { withFileTypes: true }); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(entries.map((e) => ({ name: e.name, isDir: e.isDirectory() })))); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const mime = ext === '.png' ? 'image/png' + : ext === '.json' ? 'application/json' + : ext === '.log' ? 'text/plain' + : 'application/octet-stream'; + res.setHeader('Content-Type', mime); + res.setHeader('Cache-Control', 'no-cache'); + const data = await fs.readFile(filePath); + res.end(data); + } catch (error) { + if (error.code === 'ENOENT') { + res.statusCode = 404; + res.end('Not found'); + return; + } + next(error); + } + }); + + server.middlewares.use('/api/index', async (req, res) => { + try { + const indexPath = path.join(RENDER_ROOT, 'index.json'); + const data = await fs.readFile(indexPath, 'utf8'); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(data); + } catch (error) { + res.statusCode = 500; + res.end(JSON.stringify({ error: error.message })); + } + }); + + // Serve per-map sprite cache: /sprites//bundle_/frame_NNN.png + // The exporter writes individual decoded sprite PNGs into + // /.cache//sprites/, sharing the cache between + // both the auto and region01 variants of the same map (bundle decoding + // is invariant to the record-set choice). + server.middlewares.use('/sprites', async (req, res, next) => { + try { + const url = decodeURIComponent(req.url.split('?')[0]); + // url like "/L2/bundle_00085c40/frame_000.png" + // map to "/L2/sprites/bundle_00085c40/frame_000.png" + const segments = url.split('/').filter(Boolean); + if (segments.length < 2) { + res.statusCode = 404; + res.end('Not found'); + return; + } + const [mapStem, ...rest] = segments; + const filePath = path.join(CACHE_ROOT, mapStem, 'sprites', ...rest); + if (!filePath.startsWith(CACHE_ROOT)) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + const data = await fs.readFile(filePath); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(data); + } catch (error) { + if (error.code === 'ENOENT') { + res.statusCode = 404; + res.end('Not found'); + return; + } + next(error); + } + }); + }, + }; +} + +export default defineConfig({ + plugins: [vue(), renderRootPlugin()], + server: { + port: 5180, + strictPort: false, + }, +}); From 399017ab45f7b7981d2d665013209c5cbeeba31c Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 18 Apr 2026 16:53:43 +0200 Subject: [PATCH 3/4] Dynamic map viewer psx --- .gitignore | 9 +- check_type.cjs | 4 + inspect_l0.cjs | 15 ++ psx-map-exporter/viewer/src/App.vue | 236 ++++++++++++++++++++-------- temp_head.json | 0 5 files changed, 201 insertions(+), 63 deletions(-) create mode 100644 check_type.cjs create mode 100644 inspect_l0.cjs create mode 100644 temp_head.json diff --git a/.gitignore b/.gitignore index f1d1d90..ca901c9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,11 @@ bin/** USECODE/REGRET/REGRET_USECODE_extracted/chunks/** exports/** out/** -binary/** \ No newline at end of file +binary/** +psx-map-exporter/.output-render/** + +# JavaScript / Node +**/node_modules/** +**/dist/** +**/.vite/** +*.log diff --git a/check_type.cjs b/check_type.cjs new file mode 100644 index 0000000..65d1a48 --- /dev/null +++ b/check_type.cjs @@ -0,0 +1,4 @@ +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync('psx-map-exporter/.output-render/L0/auto/L0.json', 'utf8')); +console.log('Type of data:', Array.isArray(data) ? 'Array' : typeof data); +if (!Array.isArray(data)) console.log('Keys:', Object.keys(data)); diff --git a/inspect_l0.cjs b/inspect_l0.cjs new file mode 100644 index 0000000..d1b7495 --- /dev/null +++ b/inspect_l0.cjs @@ -0,0 +1,15 @@ +const fs = require('fs'); +const path = require('path'); +const jsonPath = 'psx-map-exporter/.output-render/L0/auto/L0.json'; +const cacheDir = 'psx-map-exporter/.cache/L0/sprites'; +const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); +const items = (data.items || []).filter(item => item.bundleAbsoluteOffset !== undefined && item.frameIndex !== undefined && item.width > 0).slice(0, 5); +items.forEach(item => { + const bundleHex = item.bundleAbsoluteOffset.toString(16).padStart(8, '0'); + const frameIdx = item.frameIndex.toString().padStart(3, '0'); + const fileName = `bundle_${bundleHex}/frame_${frameIdx}.png`; + const fullPath = path.join(cacheDir, fileName); + const exists = fs.existsSync(fullPath); + console.log(`recordIndex: ${item.recordIndex}, bundleAbsoluteOffset: ${item.bundleAbsoluteOffset}, frameIndex: ${item.frameIndex}, width: ${item.width}, height: ${item.height}`); + console.log(`File: ${fileName}, Exists: ${exists}`); +}); diff --git a/psx-map-exporter/viewer/src/App.vue b/psx-map-exporter/viewer/src/App.vue index c6f6a2a..1528afb 100644 --- a/psx-map-exporter/viewer/src/App.vue +++ b/psx-map-exporter/viewer/src/App.vue @@ -1,8 +1,12 @@