From 2b1f1a0191b22c3fec6548c04f042cbd2742fe95 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 18 Apr 2026 16:34:35 +0200 Subject: [PATCH] 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, + }, +});