map viewer

This commit is contained in:
MaddoScientisto 2026-04-18 16:34:35 +02:00
commit 2b1f1a0191
15 changed files with 2355 additions and 40 deletions

View file

@ -1,3 +1,7 @@
.cache/**
.output/**
node_modules/**
.output-render/**
.tmp/**
node_modules/**
viewer/dist/**
viewer/.vite/**

View file

@ -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/<mapStem>/<variant>/ 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;
});

View file

@ -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;

View file

@ -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),
});

View file

@ -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,
})),
};

View file

@ -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),
};
}

View file

@ -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) {

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PSX Map Debug Viewer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1207
psx-map-exporter/viewer/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -0,0 +1,424 @@
<script setup>
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import ItemPanel from './ItemPanel.vue';
const PADDING = 16;
const indexData = ref(null);
const indexError = ref(null);
const selectedMap = ref(null);
const selectedVariant = ref(null);
const scene = shallowRef(null);
const sceneError = ref(null);
const loading = ref(false);
const imageRef = ref(null);
const containerRef = ref(null);
const imageNaturalWidth = ref(0);
const imageNaturalHeight = ref(0);
const zoom = ref(1);
const pan = ref({ x: 0, y: 0 });
const dragging = ref(null);
const showHitboxes = ref(true);
const showOnlyLayer = ref('all');
const filterTypeHex = ref('');
const filterBundleHex = ref('');
const hovered = shallowRef(null);
const selected = shallowRef(null);
// Set of recordIndex values the user has manually hidden via shift+click. We
// rebuild the visible item list against this set so painted sprites and SVG
// hitboxes both disappear together. Hidden items are restored via the
// "Show hidden" button or by selecting them in the unfiltered "Hidden list".
const hiddenIds = ref(new Set());
const hiddenList = computed(() => {
if (!scene.value || hiddenIds.value.size === 0) return [];
return scene.value.json.items.filter((it) => hiddenIds.value.has(it.recordIndex));
});
function hideItem(item) {
if (!item || !Number.isInteger(item.recordIndex)) return;
const next = new Set(hiddenIds.value);
next.add(item.recordIndex);
hiddenIds.value = next;
if (selected.value?.recordIndex === item.recordIndex) selected.value = null;
}
function unhideItem(item) {
if (!item || !Number.isInteger(item.recordIndex)) return;
const next = new Set(hiddenIds.value);
next.delete(item.recordIndex);
hiddenIds.value = next;
}
function clearHidden() { hiddenIds.value = new Set(); }
async function loadIndex() {
try {
const res = await fetch('/api/index');
if (!res.ok) throw new Error('Index HTTP ' + res.status);
indexData.value = await res.json();
} catch (error) {
indexError.value = error.message;
}
}
const mapList = computed(() => {
if (!indexData.value) return [];
const seen = new Set();
const list = [];
for (const entry of indexData.value.maps) {
if (entry.error) continue;
if (seen.has(entry.map)) continue;
seen.add(entry.map);
list.push(entry.map);
}
return list;
});
const variantList = computed(() => indexData.value?.variants ?? []);
watch(mapList, (list) => {
if (!selectedMap.value && list.length) selectedMap.value = list[0];
});
watch(variantList, (list) => {
if (!selectedVariant.value && list.length) selectedVariant.value = list[0];
});
async function loadScene() {
if (!selectedMap.value || !selectedVariant.value) return;
loading.value = true;
sceneError.value = null;
scene.value = null;
selected.value = null;
hovered.value = null;
try {
const base = `/render/${selectedMap.value}/${selectedVariant.value}/${selectedMap.value}`;
const json = await fetch(`${base}.json`).then((r) => {
if (!r.ok) throw new Error('Scene JSON HTTP ' + r.status);
return r.json();
});
scene.value = {
json,
pngUrl: `${base}.png`,
labelsPngUrl: `${base}_labels.png`,
};
} catch (error) {
sceneError.value = error.message;
} finally {
loading.value = false;
}
}
watch([selectedMap, selectedVariant], loadScene);
function onImageLoad() {
if (!imageRef.value) return;
imageNaturalWidth.value = imageRef.value.naturalWidth;
imageNaturalHeight.value = imageRef.value.naturalHeight;
fitToContainer();
}
function fitToContainer() {
if (!containerRef.value || !imageNaturalWidth.value) return;
const c = containerRef.value.getBoundingClientRect();
const sx = c.width / imageNaturalWidth.value;
const sy = c.height / imageNaturalHeight.value;
const z = Math.min(sx, sy) * 0.95;
pan.value = {
x: (c.width - imageNaturalWidth.value * z) / 2,
y: (c.height - imageNaturalHeight.value * z) / 2,
};
zoom.value = z;
}
function onWheel(event) {
if (!containerRef.value) return;
event.preventDefault();
const rect = containerRef.value.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
const newZoom = Math.max(0.05, Math.min(16, zoom.value * factor));
pan.value = {
x: mx - (mx - pan.value.x) * (newZoom / zoom.value),
y: my - (my - pan.value.y) * (newZoom / zoom.value),
};
zoom.value = newZoom;
}
function onMouseDown(event) {
if (event.button !== 0 && event.button !== 1) return;
dragging.value = { x: event.clientX, y: event.clientY, startPan: { ...pan.value }, moved: false };
}
function onMouseMove(event) {
if (dragging.value) {
const dx = event.clientX - dragging.value.x;
const dy = event.clientY - dragging.value.y;
if (Math.abs(dx) + Math.abs(dy) > 3) dragging.value.moved = true;
pan.value = {
x: dragging.value.startPan.x + dx,
y: dragging.value.startPan.y + dy,
};
return;
}
if (!scene.value || !containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const px = (event.clientX - rect.left - pan.value.x) / zoom.value;
const py = (event.clientY - rect.top - pan.value.y) / zoom.value;
hovered.value = pickItem(px, py);
}
function onMouseUp() { dragging.value = null; }
function onMouseLeave() { dragging.value = null; hovered.value = null; }
function onClick(event) {
if (dragging.value?.moved) return;
if (!scene.value || !containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const px = (event.clientX - rect.left - pan.value.x) / zoom.value;
const py = (event.clientY - rect.top - pan.value.y) / zoom.value;
const item = pickItem(px, py);
// Shift+click hides the picked item (overlay rect AND painted sprite still
// shows since the .png is baked, but the SVG hitbox vanishes so you can
// click through to whatever is underneath; combine with the hidden list
// panel to restore visibility).
if (event.shiftKey && item) {
hideItem(item);
return;
}
selected.value = item;
}
function itemPixelRect(item) {
const b = scene.value.json.bounds;
return {
x: (item.drawX - b.minX) + PADDING,
y: (item.drawY - b.minY) + PADDING,
w: item.width,
h: item.height,
};
}
function visibleItems() {
if (!scene.value) return [];
let items = scene.value.json.items;
if (hiddenIds.value.size) {
items = items.filter((it) => !hiddenIds.value.has(it.recordIndex));
}
if (showOnlyLayer.value !== 'all') {
items = items.filter((it) => (it.authoredLayer ?? 'unknown') === showOnlyLayer.value);
}
if (filterTypeHex.value.trim()) {
const t = parseInt(filterTypeHex.value, 16);
if (Number.isFinite(t)) items = items.filter((it) => it.typeWord === t);
}
if (filterBundleHex.value.trim()) {
const b = parseInt(filterBundleHex.value, 16);
if (Number.isFinite(b)) items = items.filter((it) => it.bundleAbsoluteOffset === b);
}
return items;
}
function pickItem(px, py) {
if (!scene.value) return null;
const items = visibleItems();
for (let i = items.length - 1; i >= 0; i -= 1) {
const it = items[i];
const r = itemPixelRect(it);
if (px >= r.x && py >= r.y && px <= r.x + r.w && py <= r.y + r.h) {
return it;
}
}
return null;
}
const layerOptions = computed(() => {
if (!scene.value) return ['all'];
const set = new Set(['all']);
for (const it of scene.value.json.items) set.add(it.authoredLayer ?? 'unknown');
return [...set];
});
const visibleCount = computed(() => visibleItems().length);
const transformStyle = computed(() => ({
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
transformOrigin: '0 0',
}));
const overlayBoxes = computed(() => {
if (!scene.value || !showHitboxes.value) return [];
return visibleItems().map((it) => ({ item: it, rect: itemPixelRect(it) }));
});
// Black-out rects for shift+click-hidden items: the rendered .png is baked,
// so we paint an opaque rectangle over each hidden sprite using the same
// background colour the renderer used (DEFAULT_BACKGROUND in render.js =
// rgb(18,18,18)). This lets the user see whatever real sprite lives behind
// the obscuring one without re-running the exporter.
const hiddenMasks = computed(() => {
if (!scene.value || hiddenIds.value.size === 0) return [];
return scene.value.json.items
.filter((it) => hiddenIds.value.has(it.recordIndex))
.map((it) => ({ item: it, rect: itemPixelRect(it) }));
});
function strokeFor(item) {
if (item === selected.value) return '#4ea1ff';
if (item === hovered.value) return '#ffb24a';
if (item.placeholder) return 'rgba(220, 80, 220, 0.6)';
return 'rgba(255,255,255,0.13)';
}
function strokeWidthFor(item) {
return (item === selected.value || item === hovered.value) ? 2 : 1;
}
function reset11() { zoom.value = 1; pan.value = { x: 0, y: 0 }; }
onMounted(loadIndex);
</script>
<template>
<div class="app">
<header class="topbar">
<div class="title">PSX Map Debug Viewer</div>
<label>Map
<select v-model="selectedMap">
<option v-for="m in mapList" :key="m" :value="m">{{ m }}</option>
</select>
</label>
<label>Variant
<select v-model="selectedVariant">
<option v-for="v in variantList" :key="v" :value="v">{{ v }}</option>
</select>
</label>
<label>Layer
<select v-model="showOnlyLayer">
<option v-for="l in layerOptions" :key="l" :value="l">{{ l }}</option>
</select>
</label>
<label>typeWord <input v-model="filterTypeHex" placeholder="hex" size="6" /></label>
<label>bundle <input v-model="filterBundleHex" placeholder="hex" size="8" /></label>
<label class="check"><input type="checkbox" v-model="showHitboxes" /> hitboxes</label>
<button @click="fitToContainer">Fit</button>
<button @click="reset11">1:1</button>
<button v-if="hiddenIds.size" @click="clearHidden" title="Restore all shift+click-hidden items">Show hidden ({{ hiddenIds.size }})</button>
<span class="muted">visible: {{ visibleCount }} · zoom {{ zoom.toFixed(2) }}× · shift+click to hide</span>
</header>
<main class="layout">
<section
class="canvas"
ref="containerRef"
@wheel="onWheel"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseLeave"
@click="onClick"
>
<div v-if="indexError" class="error">Index error: {{ indexError }}</div>
<div v-else-if="sceneError" class="error">{{ sceneError }}</div>
<div v-else-if="loading" class="muted center">Loading</div>
<div v-else-if="scene" class="stage" :style="transformStyle">
<img
ref="imageRef"
:src="scene.pngUrl"
@load="onImageLoad"
draggable="false"
class="map-img"
/>
<svg
v-if="imageNaturalWidth"
class="overlay"
:width="imageNaturalWidth"
:height="imageNaturalHeight"
:viewBox="`0 0 ${imageNaturalWidth} ${imageNaturalHeight}`"
>
<rect
v-for="entry in hiddenMasks"
:key="'mask-' + entry.item.recordIndex"
:x="entry.rect.x"
:y="entry.rect.y"
:width="entry.rect.w"
:height="entry.rect.h"
fill="rgb(18,18,18)"
stroke="rgba(255, 80, 80, 0.6)"
stroke-width="1"
vector-effect="non-scaling-stroke"
stroke-dasharray="4 2"
/>
<rect
v-for="entry in overlayBoxes"
:key="entry.item.recordIndex"
:x="entry.rect.x"
:y="entry.rect.y"
:width="entry.rect.w"
:height="entry.rect.h"
fill="none"
:stroke="strokeFor(entry.item)"
:stroke-width="strokeWidthFor(entry.item)"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
</section>
<aside class="sidebar">
<h3>Hover</h3>
<ItemPanel :item="hovered" :map-stem="selectedMap" />
<h3>Selected
<button v-if="selected" class="mini" @click="hideItem(selected)">hide</button>
</h3>
<ItemPanel :item="selected" :map-stem="selectedMap" :show-copy="true" />
<template v-if="hiddenList.length">
<h3>Hidden ({{ hiddenList.length }})
<button class="mini" @click="clearHidden">restore all</button>
</h3>
<ul class="hidden-list">
<li v-for="it in hiddenList" :key="it.recordIndex">
#{{ it.recordIndex }} t0x{{ it.typeWord.toString(16) }} {{ it.authoredLayer }}
<button class="mini" @click="unhideItem(it)">show</button>
</li>
</ul>
</template>
</aside>
</main>
</div>
</template>
<style scoped>
.app { display: flex; flex-direction: column; height: 100vh; }
.topbar {
display: flex; gap: 12px; align-items: center;
padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.topbar label { display: flex; gap: 4px; align-items: center; font-size: 13px; color: var(--muted); }
.topbar label.check { color: var(--text); }
.topbar input { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; }
.title { font-weight: bold; margin-right: 12px; }
.muted { color: var(--muted); font-size: 12px; }
.layout { display: flex; flex: 1; min-height: 0; }
.canvas {
flex: 1; position: relative; overflow: hidden;
background: #0a0a0a;
cursor: grab;
}
.canvas:active { cursor: grabbing; }
.stage { position: absolute; top: 0; left: 0; }
.map-img { display: block; image-rendering: pixelated; user-select: none; -webkit-user-drag: none; }
.overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
.sidebar {
width: 380px; background: var(--panel); border-left: 1px solid var(--border);
overflow: auto; padding: 12px;
}
.sidebar h3 { margin: 8px 0 4px; font-size: 12px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.sidebar .mini { font-size: 10px; padding: 1px 6px; text-transform: none; }
.hidden-list { list-style: none; padding: 0; margin: 0 0 8px; font-size: 11px; }
.hidden-list li { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px; border-bottom: 1px solid var(--border); }
.error { color: #ff6b6b; padding: 16px; }
.center { padding: 16px; }
</style>

View file

@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
item: { type: Object, default: null },
mapStem: { type: String, default: null },
showCopy: { type: Boolean, default: false },
});
function fmtHex(value, pad = 0) {
if (value === null || value === undefined) return '—';
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return '0x' + n.toString(16).padStart(pad, '0');
}
function copyJson() {
if (!props.item) return;
navigator.clipboard?.writeText(JSON.stringify(props.item, null, 2));
}
const rawWordsHex = computed(() => {
if (!props.item?.rawWords) return null;
return props.item.rawWords.map((w) => w.toString(16).padStart(4, '0')).join(' ');
});
// Sprite preview URL: matches the layout written by writeBundleCache and
// served by vite.config.js /sprites middleware:
// /sprites/<mapStem>/bundle_<absoluteOffset:8x>/frame_<frameIndex:3d>.png
const spriteUrl = computed(() => {
if (!props.item || !props.mapStem) return null;
const off = props.item.bundleAbsoluteOffset;
const frame = props.item.frameIndex;
if (!Number.isFinite(off) || !Number.isInteger(frame)) return null;
const offHex = off.toString(16).padStart(8, '0');
const frameStr = String(frame).padStart(3, '0');
return `/sprites/${props.mapStem}/bundle_${offHex}/frame_${frameStr}.png`;
});
</script>
<template>
<div v-if="!item" class="muted"> nothing </div>
<template v-else>
<div v-if="spriteUrl" class="preview">
<img :src="spriteUrl" :alt="`bundle ${fmtHex(item.bundleAbsoluteOffset, 8)} frame ${item.frameIndex}`" />
<div class="preview-caption">
bundle {{ fmtHex(item.bundleAbsoluteOffset, 8) }} · frame {{ item.frameIndex }}
· {{ item.width }}×{{ item.height }} (anchor {{ item.originX }},{{ item.originY }})
</div>
</div>
<table class="kv">
<tbody>
<tr><th>recordIndex</th><td>{{ item.recordIndex }}</td></tr>
<tr><th>instanceId</th><td>{{ item.instanceId }}</td></tr>
<tr><th>typeWord</th><td>{{ fmtHex(item.typeWord, 2) }} ({{ item.typeWord }})</td></tr>
<tr><th>laneWord</th><td>{{ fmtHex(item.laneWord, 4) }}</td></tr>
<tr><th>bundle abs offset</th><td>{{ fmtHex(item.bundleAbsoluteOffset, 8) }}</td></tr>
<tr><th>bundleSlot</th><td>{{ item.bundleSlot }}</td></tr>
<tr><th>bundleSource</th><td>{{ item.bundleSource }}</td></tr>
<tr><th>frameIndex</th><td>{{ item.frameIndex }} / req {{ item.requestedFrameIndex }}</td></tr>
<tr><th>palette</th><td>def {{ item.defaultPaletteIndex }} res {{ item.resolvedPaletteIndex }} <span class="muted">({{ item.paletteFormula }})</span></td></tr>
<tr><th>mappingSource</th><td>{{ item.mappingSource }}</td></tr>
<tr><th>authoredLayer</th><td>{{ item.authoredLayer }}</td></tr>
<tr><th>sourceFamily</th><td>{{ item.sourceFamily }}</td></tr>
<tr><th>screen X,Y</th><td>{{ item.screenX }}, {{ item.screenY }}</td></tr>
<tr><th>world X,Y,Z</th><td>{{ item.worldX }}, {{ item.worldY }}, {{ item.worldZ }}</td></tr>
<tr><th>draw X,Y</th><td>{{ item.drawX }}, {{ item.drawY }}</td></tr>
<tr><th>size</th><td>{{ item.width }} × {{ item.height }} (origin {{ item.originX }},{{ item.originY }})</td></tr>
<tr><th>flipped</th><td>{{ item.flipped }}</td></tr>
<tr><th>stage</th><td>{{ item.stage }}</td></tr>
<tr v-if="rawWordsHex"><th>rawWords</th><td><code>{{ rawWordsHex }}</code></td></tr>
</tbody>
</table>
<button v-if="showCopy" @click="copyJson">Copy JSON</button>
</template>
</template>
<style scoped>
.kv { width: 100%; border-collapse: collapse; }
.kv th { text-align: left; color: var(--muted); font-weight: normal; padding: 2px 6px; vertical-align: top; width: 130px; font-size: 12px; }
.kv td { padding: 2px 6px; word-break: break-all; font-size: 12px; }
.kv code { font-size: 11px; }
.muted { color: var(--muted); font-size: 12px; }
button { margin-top: 6px; }
.preview {
margin-bottom: 8px;
padding: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
text-align: center;
}
.preview img {
max-width: 100%;
max-height: 200px;
image-rendering: pixelated;
background:
linear-gradient(45deg, rgba(255,255,255,0.06) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.06) 75%) 0 0 / 16px 16px,
linear-gradient(45deg, rgba(255,255,255,0.06) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.06) 75%) 8px 8px / 16px 16px;
}
.preview-caption { font-size: 11px; color: var(--muted); margin-top: 4px; }
</style>

View file

@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View file

@ -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; }

View file

@ -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/<map>/bundle_<hex>/frame_NNN.png
// The exporter writes individual decoded sprite PNGs into
// <projectRoot>/.cache/<mapStem>/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 "<CACHE_ROOT>/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,
},
});