Compare commits
4 commits
9fe261610f
...
6ca3b18c3e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ca3b18c3e | |||
| 399017ab45 | |||
| 2b1f1a0191 | |||
| 93bc6e7a07 |
25 changed files with 2799 additions and 118 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -44,4 +44,11 @@ bin/**
|
||||||
USECODE/REGRET/REGRET_USECODE_extracted/chunks/**
|
USECODE/REGRET/REGRET_USECODE_extracted/chunks/**
|
||||||
exports/**
|
exports/**
|
||||||
out/**
|
out/**
|
||||||
binary/**
|
binary/**
|
||||||
|
psx-map-exporter/.output-render/**
|
||||||
|
|
||||||
|
# JavaScript / Node
|
||||||
|
**/node_modules/**
|
||||||
|
**/dist/**
|
||||||
|
**/.vite/**
|
||||||
|
*.log
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
check_type.cjs
Normal file
4
check_type.cjs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const data = JSON.parse(fs.readFileSync('psx-map-exporter/.output-render/L0/auto/L0.json', 'utf8'));
|
||||||
|
console.log('Type of data:', Array.isArray(data) ? 'Array' : typeof data);
|
||||||
|
if (!Array.isArray(data)) console.log('Keys:', Object.keys(data));
|
||||||
15
inspect_l0.cjs
Normal file
15
inspect_l0.cjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const jsonPath = 'psx-map-exporter/.output-render/L0/auto/L0.json';
|
||||||
|
const cacheDir = 'psx-map-exporter/.cache/L0/sprites';
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
const items = (data.items || []).filter(item => item.bundleAbsoluteOffset !== undefined && item.frameIndex !== undefined && item.width > 0).slice(0, 5);
|
||||||
|
items.forEach(item => {
|
||||||
|
const bundleHex = item.bundleAbsoluteOffset.toString(16).padStart(8, '0');
|
||||||
|
const frameIdx = item.frameIndex.toString().padStart(3, '0');
|
||||||
|
const fileName = `bundle_${bundleHex}/frame_${frameIdx}.png`;
|
||||||
|
const fullPath = path.join(cacheDir, fileName);
|
||||||
|
const exists = fs.existsSync(fullPath);
|
||||||
|
console.log(`recordIndex: ${item.recordIndex}, bundleAbsoluteOffset: ${item.bundleAbsoluteOffset}, frameIndex: ${item.frameIndex}, width: ${item.width}, height: ${item.height}`);
|
||||||
|
console.log(`File: ${fileName}, Exists: ${exists}`);
|
||||||
|
});
|
||||||
6
psx-map-exporter/.gitignore
vendored
6
psx-map-exporter/.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
||||||
.cache/**
|
.cache/**
|
||||||
.output/**
|
.output/**
|
||||||
node_modules/**
|
.output-render/**
|
||||||
|
.tmp/**
|
||||||
|
node_modules/**
|
||||||
|
viewer/dist/**
|
||||||
|
viewer/.vite/**
|
||||||
|
|
@ -201,27 +201,81 @@ For each record:
|
||||||
- `screenX - originX`
|
- `screenX - originX`
|
||||||
- `screenY - originY`
|
- `screenY - originY`
|
||||||
|
|
||||||
Current draw order is conservative:
|
### Projection sign convention
|
||||||
|
|
||||||
- main-visible before special-visible
|
`psx_project_object_main_visible @ 0x80040d44` writes `proj_x = Y - X` and
|
||||||
- then ascending `screenY`
|
`proj_y = 2*Z - (X + Y) / 2` to `obj+0x78/0x7c`. The engine's draw step at
|
||||||
- then ascending `screenX`
|
`0x80040e3c/0x80040e5c` then computes `screen = (cam - proj) - origin`, which
|
||||||
|
flips the sign of the projection relative to canvas Y-down space. For a
|
||||||
|
camera-less full-map export the exporter bakes that flip into
|
||||||
|
`projectCtorPlacement`, so higher world-Z and smaller (X+Y) sit visibly higher
|
||||||
|
on the output PNG. The same projection is applied to both constructor
|
||||||
|
placements and dispatch-root records; dispatch-root X/Y fields are in world
|
||||||
|
coordinates, not pre-projected screen coordinates, despite the runtime
|
||||||
|
`camera +/- 0x140` cull comparing against them directly.
|
||||||
|
|
||||||
This is a probe approximation. The later graph-based stage-1 ordering still belongs to a future pass.
|
### Authored layer semantics
|
||||||
|
|
||||||
The rendered PNG uses a neutral opaque background by default so probe silhouettes are legible without relying on transparency.
|
The two authored lanes carry different responsibilities:
|
||||||
|
|
||||||
|
- `constructors`: static level geometry — walls, floors, architecture placed
|
||||||
|
by `psx_dispatch_section0_constructor_placements`.
|
||||||
|
- `roots`: interactive / dynamic objects — crates, terminals, doors, pickups
|
||||||
|
placed by `psx_dispatch_section0_dispatch_roots`.
|
||||||
|
|
||||||
|
Despite the dispatcher name, the `roots` lane is not the map background; it is
|
||||||
|
the live-object seed list. For the exporter, "constructors" is the geometry
|
||||||
|
layer and "roots" is the object layer.
|
||||||
|
|
||||||
|
### Painter's order
|
||||||
|
|
||||||
|
The exporter sorts items before blitting using the following keys, in order:
|
||||||
|
|
||||||
|
1. `stage` ascending. `stage = 1` when `typeWord === 4` or `laneWord & 0x0400`
|
||||||
|
is set; those overlays draw last.
|
||||||
|
2. Authored-layer priority: `constructors` (0) before `roots` (1). Static
|
||||||
|
geometry draws first so interactive props in the same room do not get
|
||||||
|
hidden behind the floor or wall pieces that occupy the same cell.
|
||||||
|
3. Isometric depth ascending: back-to-front by world `X + Y` (isometric
|
||||||
|
ground-depth axis). Falls back to projected `screenY` when world
|
||||||
|
coordinates are unavailable.
|
||||||
|
4. World `Z` ascending within the same ground cell so lower elevations draw
|
||||||
|
before taller objects sharing the same footprint.
|
||||||
|
5. `screenX` ascending as a stable tie-breaker.
|
||||||
|
|
||||||
|
This is still an approximation of the engine's stage-1 graph order but is
|
||||||
|
closer to what an isometric painter's algorithm would produce than the earlier
|
||||||
|
screenY-only sort.
|
||||||
|
|
||||||
|
The rendered PNG uses a neutral opaque background by default so probe
|
||||||
|
silhouettes are legible without relying on transparency.
|
||||||
|
|
||||||
## Color Rule
|
## Color Rule
|
||||||
|
|
||||||
`v0` emits grayscale art from raw pixel indices.
|
The exporter resolves palettes entirely from the WDL contents. It does not
|
||||||
|
require any RAM or VRAM dump; those paths are now optional research overrides.
|
||||||
|
|
||||||
Reason:
|
The map-local palette blob lives at `headerWords[2] .. headerWords[2] + 0x1000`
|
||||||
|
(4096 bytes = 2048 colors = 128 × 16-entry CLUTs). The blob is what the engine
|
||||||
|
uploads to VRAM rows `0xf0 .. 0xf7` on map load; each VRAM row is 16 CLUTs
|
||||||
|
wide so the 128 CLUTs tile exactly 8 rows of 16 CLUTs.
|
||||||
|
|
||||||
- bundle frame decode is already well constrained
|
Resolution rules by bundle mode:
|
||||||
- full CLUT parity is not
|
|
||||||
- grayscale preserves shape/variant evidence without pretending the palette problem is solved
|
|
||||||
|
|
||||||
Transparent index `0` stays transparent.
|
- **Mode 2 (4bpp)**: the bundle header's `paletteIndex` at `+0x14` is the
|
||||||
|
16-entry CLUT index into the WDL `palettes16` bank. When that index points
|
||||||
|
at a sparse/empty CLUT the exporter falls back to a per-bundle palette sweep
|
||||||
|
that picks a CLUT covering the pixel-index set used by the bundle frames.
|
||||||
|
- **Mode 1 (8bpp)**: the 256-color CLUT is the concatenation of 16 consecutive
|
||||||
|
16-entry CLUTs from the WDL bank. The bundle's `paletteIndex` is treated as
|
||||||
|
the starting CLUT index. For the current L0 dataset every mode-1 bundle
|
||||||
|
stores `paletteIndex = 0`, which is the top-left 256-color bank. Mode-1
|
||||||
|
color fidelity is therefore approximate until the level-specific 256-CLUT
|
||||||
|
source (suspected to live in the `stateBank` block) is decoded — tracked as
|
||||||
|
a follow-up.
|
||||||
|
|
||||||
|
Transparent pixel index `0` stays transparent during blit regardless of the
|
||||||
|
color value stored at CLUT index 0.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|
@ -254,8 +308,13 @@ Supported options:
|
||||||
|
|
||||||
## Planned Follow-Ups
|
## Planned Follow-Ups
|
||||||
|
|
||||||
- extend `sceneInterpretation` so it reflects the landed loader-faithful binding instead of the older repeated-wrong-art warning
|
- decode the `stateBank` and `stateBank2` blocks to recover the level-specific
|
||||||
- identify and parse the separate static-world or subordinate level substrate that complements the constructor-fed live-object lane, instead of treating section-0 constructor placements as the whole map
|
256-color CLUT used by mode-1 sprites. Current mode-1 palettes default to
|
||||||
- add palette/CLUT reconstruction
|
CLUT-bank start 0, which produces plausible colors for some sprites but
|
||||||
- add stage-1 graph ordering recovery
|
renders many indoor floor tiles as solid green plates.
|
||||||
- compare the probe scene against fixed live samples such as `map 104` without reintroducing viewer-side donor assumptions
|
- extend `sceneInterpretation` so it reflects the landed loader-faithful
|
||||||
|
binding instead of the older repeated-wrong-art warning.
|
||||||
|
- recover the engine's stage-1 graph ordering instead of approximating with
|
||||||
|
isometric `(X + Y, Z, screenX)` sort keys.
|
||||||
|
- compare the probe scene against fixed live samples such as `map 104` without
|
||||||
|
reintroducing viewer-side donor assumptions.
|
||||||
|
|
|
||||||
170
psx-map-exporter/scripts/export-all.mjs
Normal file
170
psx-map-exporter/scripts/export-all.mjs
Normal 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;
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,10 @@ function readU16LE(buffer, offset) {
|
||||||
return buffer.readUInt16LE(offset);
|
return buffer.readUInt16LE(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readS16LE(buffer, offset) {
|
||||||
|
return buffer.readInt16LE(offset);
|
||||||
|
}
|
||||||
|
|
||||||
function rowByteWidth(width, mode) {
|
function rowByteWidth(width, mode) {
|
||||||
return mode === 2 ? Math.ceil(width / 2) : width;
|
return mode === 2 ? Math.ceil(width / 2) : width;
|
||||||
}
|
}
|
||||||
|
|
@ -26,17 +30,45 @@ function psx555ToRgba(color) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractPaletteSets(buffer, headerWords) {
|
export function extractPaletteSets(buffer, headerWords) {
|
||||||
if (!Array.isArray(headerWords) || headerWords.length < 4) {
|
if (!Array.isArray(headerWords) || headerWords.length < 9) {
|
||||||
return { palettes16: [], palettes256: [] };
|
return { palettes16: [], palettes256: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const paletteOffset = headerWords[2];
|
// The runtime CLUT table is the LAST chunk of the level "section pack" that
|
||||||
const paletteSize = headerWords[3];
|
// wdl_resource_bundle_load_by_index @ 0x80039918 reads from the per-map .WDL
|
||||||
if (paletteSize !== 0x1000 || paletteOffset < 0 || paletteOffset + paletteSize > buffer.length) {
|
// file. The 0x38-byte header is an array of 14 u32 sizes:
|
||||||
|
// [c8, c4, c0, bc, b8, b4, b0, ac, a8, a4, a0, 9c, 98, 94]
|
||||||
|
// The section pack covers the first sum(headerWords[0..9]) bytes after the
|
||||||
|
// header. Within it, the engine derives section pointers as:
|
||||||
|
// dispatch_roots @ +headerWords[0]
|
||||||
|
// ctor_placement_records @ +headerWords[1]
|
||||||
|
// ctor_placement_section @ +headerWords[2]
|
||||||
|
// (local_bc scratch gap) @ headerWords[3] <-- SKIPPED in pointer math
|
||||||
|
// section_pack_base @ +headerWords[4]
|
||||||
|
// type_policy_table @ +headerWords[5]
|
||||||
|
// 8006754c table @ +headerWords[6]
|
||||||
|
// control_opcode_stream @ +headerWords[7]
|
||||||
|
// psx_level_clut_table @ +headerWords[8] <-- CLUT starts here
|
||||||
|
// Note that the engine's chain skips headerWords[3] (`local_bc`); that field
|
||||||
|
// is a separate scratch reservation released by psx_level_heap_rewind after
|
||||||
|
// level_palette_header_apply returns. The CLUT absolute file offset is
|
||||||
|
// therefore 0x38 plus the sum of headerWords[0..2, 4..8].
|
||||||
|
// The CLUT spans 0x1000 bytes (128 x 32-byte 16-color CLUTs) which matches
|
||||||
|
// the engine's psx_clut_table_by_resource_bank layout (8 rows x 16 cols).
|
||||||
|
// The exporter previously used headerWords[2] / headerWords[3] which are
|
||||||
|
// actually section sizes, not a palette offset; that mismatch produced the
|
||||||
|
// wrong colors (e.g. neon-green floors instead of grey concrete).
|
||||||
|
let clutOffset = 0x38;
|
||||||
|
for (let i = 0; i < 9 && i < headerWords.length; i += 1) {
|
||||||
|
if (i === 3) continue; // skip local_bc scratch reservation
|
||||||
|
clutOffset += headerWords[i] >>> 0;
|
||||||
|
}
|
||||||
|
const paletteSize = 0x1000;
|
||||||
|
if (clutOffset < 0 || clutOffset + paletteSize > buffer.length) {
|
||||||
return { palettes16: [], palettes256: [] };
|
return { palettes16: [], palettes256: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = buffer.subarray(paletteOffset, paletteOffset + paletteSize);
|
const blob = buffer.subarray(clutOffset, clutOffset + paletteSize);
|
||||||
const palettes16 = [];
|
const palettes16 = [];
|
||||||
const palettes256 = [];
|
const palettes256 = [];
|
||||||
|
|
||||||
|
|
@ -56,7 +88,7 @@ export function extractPaletteSets(buffer, headerWords) {
|
||||||
palettes256.push(palette);
|
palettes256.push(palette);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { palettes16, palettes256 };
|
return { palettes16, palettes256, clutOffset };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
|
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
|
||||||
|
|
@ -286,8 +318,8 @@ export function extractBundleFromHeader(payloadBuffer, absoluteHeaderOffset) {
|
||||||
const relativeDataOffset = readU32LE(payloadBuffer, entryOffset + 0x08);
|
const relativeDataOffset = readU32LE(payloadBuffer, entryOffset + 0x08);
|
||||||
const frameWidth = readU16LE(payloadBuffer, entryOffset + 0x0c);
|
const frameWidth = readU16LE(payloadBuffer, entryOffset + 0x0c);
|
||||||
const frameHeight = readU16LE(payloadBuffer, entryOffset + 0x0e);
|
const frameHeight = readU16LE(payloadBuffer, entryOffset + 0x0e);
|
||||||
const originX = readU16LE(payloadBuffer, entryOffset + 0x10);
|
const originX = readS16LE(payloadBuffer, entryOffset + 0x10);
|
||||||
const originY = readU16LE(payloadBuffer, entryOffset + 0x12);
|
const originY = readS16LE(payloadBuffer, entryOffset + 0x12);
|
||||||
|
|
||||||
const dataStart = dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
|
const dataStart = dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
|
||||||
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
|
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,38 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { exportMap } from './export-map.js';
|
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) {
|
function parseArgs(argv) {
|
||||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const options = {
|
const options = {
|
||||||
discRoot: path.resolve(
|
discRoot: resolveDefaultDiscRoot(moduleDir),
|
||||||
moduleDir,
|
gpuRamDump: null,
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'Crusader-Map-Viewer',
|
|
||||||
'map_renderer',
|
|
||||||
'STATIC_PSX'
|
|
||||||
),
|
|
||||||
gpuRamDump: path.resolve(
|
|
||||||
moduleDir,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'binary',
|
|
||||||
'Crusader - No Remorse (USA) GPU RAM 2.bin'
|
|
||||||
),
|
|
||||||
mapSource: 'auto',
|
mapSource: 'auto',
|
||||||
bindingMode: 'raw',
|
bindingMode: 'raw',
|
||||||
sceneScope: 'probe',
|
sceneScope: 'probe',
|
||||||
|
|
@ -81,6 +91,15 @@ function parseArgs(argv) {
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
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') {
|
if (arg === '--debug-labels') {
|
||||||
options.debugLabels = true;
|
options.debugLabels = true;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -146,6 +165,8 @@ async function main() {
|
||||||
gpuRamDumpPath: options.gpuRamDump,
|
gpuRamDumpPath: options.gpuRamDump,
|
||||||
validationBundles: options.validationBundles,
|
validationBundles: options.validationBundles,
|
||||||
outName: options.outName,
|
outName: options.outName,
|
||||||
|
outputRoot: options.outputRoot,
|
||||||
|
resetOutputRoot: options.resetOutputRoot,
|
||||||
debugLabels: Boolean(options.debugLabels),
|
debugLabels: Boolean(options.debugLabels),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,21 @@ function chooseBundleBinding(record, bundles, options = {}) {
|
||||||
return null;
|
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);
|
const rawTypeBundle = chooseBundleForType(bundles, record.typeWord);
|
||||||
if (!rawTypeBundle) {
|
if (!rawTypeBundle) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -525,7 +540,15 @@ function summarizeRenderedLayers(items) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function derivePaletteDiagnostics(record, bundle) {
|
function derivePaletteDiagnostics(record, bundle) {
|
||||||
const rawWords = Array.isArray(record.rawWords) ? record.rawWords : [];
|
// For dispatch-root records the meaningful per-record palette token lives
|
||||||
|
// in the full 24-byte raw row (12 u16 words), not the projected 6-word
|
||||||
|
// record we keep for shared scene math. Constructor placements only have
|
||||||
|
// the 12-byte form, so falling back to record.rawWords is correct there.
|
||||||
|
const rawWords = Array.isArray(record.dispatchRootRawWords)
|
||||||
|
? record.dispatchRootRawWords
|
||||||
|
: Array.isArray(record.rawWords)
|
||||||
|
? record.rawWords
|
||||||
|
: [];
|
||||||
const token06HighByte = rawWords.length >= 4 ? ((rawWords[3] >>> 8) & 0xff) : null;
|
const token06HighByte = rawWords.length >= 4 ? ((rawWords[3] >>> 8) & 0xff) : null;
|
||||||
const token0cHighByte = rawWords.length >= 7 ? ((rawWords[6] >>> 8) & 0xff) : null;
|
const token0cHighByte = rawWords.length >= 7 ? ((rawWords[6] >>> 8) & 0xff) : null;
|
||||||
|
|
||||||
|
|
@ -1008,29 +1031,57 @@ function resolveBundlePalettes(bundles, paletteSets, options = {}) {
|
||||||
let paletteFormula = null;
|
let paletteFormula = null;
|
||||||
|
|
||||||
if (bundle.mode === 2) {
|
if (bundle.mode === 2) {
|
||||||
if (!Number.isInteger(resolvedPaletteIndex) || resolvedPaletteIndex < 0 || resolvedPaletteIndex >= paletteSets.palettes16.length) {
|
// Mode 2 (4bpp) descriptor binder at psx_resource_bind_single_image_vram_slot
|
||||||
resolvedPaletteIndex = choosePalette(paletteSets.palettes16, bundle.frames, bundle.mode);
|
// (0x800444e4) takes the bundle's `paletteIndex` from descriptor+0x14
|
||||||
|
// and ADDS 0x10 (= 16) before storing it as the resource's CLUT bank
|
||||||
|
// index at resource+0x08. The submitters then read
|
||||||
|
// psx_clut_table_by_resource_bank[resource+8] which is identical to
|
||||||
|
// palettes16[paletteIndex + 16]. The exporter therefore offsets the
|
||||||
|
// bundle index by +16 for mode-2 art.
|
||||||
|
const baseIndex = Number.isInteger(bundle.paletteIndex) ? bundle.paletteIndex : null;
|
||||||
|
const adjustedIndex = baseIndex !== null ? baseIndex + 16 : null;
|
||||||
|
if (adjustedIndex !== null && adjustedIndex >= 0 && adjustedIndex < paletteSets.palettes16.length) {
|
||||||
|
resolvedPaletteIndex = adjustedIndex;
|
||||||
|
palette = paletteSets.palettes16[adjustedIndex];
|
||||||
|
paletteFormula = 'mode2-bundle-index-plus-16';
|
||||||
}
|
}
|
||||||
if (Number.isInteger(resolvedPaletteIndex) && resolvedPaletteIndex >= 0 && resolvedPaletteIndex < paletteSets.palettes16.length) {
|
if (!palette) {
|
||||||
palette = paletteSets.palettes16[resolvedPaletteIndex];
|
if (!Number.isInteger(resolvedPaletteIndex) || resolvedPaletteIndex < 0 || resolvedPaletteIndex >= paletteSets.palettes16.length) {
|
||||||
paletteFormula = 'mode2-bundle-or-usage-index';
|
resolvedPaletteIndex = choosePalette(paletteSets.palettes16, bundle.frames, bundle.mode);
|
||||||
|
}
|
||||||
|
if (Number.isInteger(resolvedPaletteIndex) && resolvedPaletteIndex >= 0 && resolvedPaletteIndex < paletteSets.palettes16.length) {
|
||||||
|
palette = paletteSets.palettes16[resolvedPaletteIndex];
|
||||||
|
paletteFormula = 'mode2-bundle-or-usage-index-fallback';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (bundle.mode === 1) {
|
} else if (bundle.mode === 1) {
|
||||||
if (options.mode1RuntimePalette?.length === 256) {
|
// Mode 1 is an 8bpp image with a 256-entry CLUT. In this engine the
|
||||||
|
// 256-color CLUT is NOT a dedicated palette-256 block — it is the
|
||||||
|
// concatenation of 16 consecutive 16-entry CLUTs taken from the
|
||||||
|
// `palettes16` bank. The bundle header's `paletteIndex` is the starting
|
||||||
|
// CLUT index into `palettes16`. Falling back to `0` matches the legacy
|
||||||
|
// behavior but is almost always wrong for mode-1 art; the per-bundle
|
||||||
|
// index is the real engine-equivalent rule.
|
||||||
|
const bankStart = Number.isInteger(bundle.paletteIndex) && bundle.paletteIndex >= 0
|
||||||
|
? bundle.paletteIndex
|
||||||
|
: 0;
|
||||||
|
const fromBundleIndex = mode1PaletteBank[bankStart];
|
||||||
|
if (fromBundleIndex?.length === 256) {
|
||||||
|
resolvedPaletteIndex = bankStart;
|
||||||
|
palette = fromBundleIndex;
|
||||||
|
paletteFormula = 'mode1-palette16-bank-start-index-bundle';
|
||||||
|
} else if (options.mode1RuntimePalette?.length === 256) {
|
||||||
resolvedPaletteIndex = 0;
|
resolvedPaletteIndex = 0;
|
||||||
palette = options.mode1RuntimePalette;
|
palette = options.mode1RuntimePalette;
|
||||||
|
paletteFormula = 'mode1-live-gpu-ram-row-f0-x0';
|
||||||
} else if (mode1PaletteBank[0]?.length) {
|
} else if (mode1PaletteBank[0]?.length) {
|
||||||
resolvedPaletteIndex = 0;
|
resolvedPaletteIndex = 0;
|
||||||
palette = mode1PaletteBank[0];
|
palette = mode1PaletteBank[0];
|
||||||
|
paletteFormula = 'mode1-palette16-bank-start-index-0-fallback';
|
||||||
} else {
|
} else {
|
||||||
resolvedPaletteIndex = null;
|
resolvedPaletteIndex = null;
|
||||||
palette = mode1RuntimePalette;
|
palette = mode1RuntimePalette;
|
||||||
}
|
paletteFormula = palette ? 'mode1-runtime-clut-band-start-index-0' : null;
|
||||||
|
|
||||||
if (palette) {
|
|
||||||
paletteFormula = options.mode1RuntimePalette?.length === 256
|
|
||||||
? 'mode1-live-gpu-ram-row-f0-x0'
|
|
||||||
: 'mode1-runtime-clut-band-start-index-0';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1050,7 +1101,18 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
|
||||||
const skippedRecords = [];
|
const skippedRecords = [];
|
||||||
|
|
||||||
const nonMapFacingRootTypes = new Set([0x42, 0x49]);
|
const nonMapFacingRootTypes = new Set([0x42, 0x49]);
|
||||||
const nonMapFacingBundleOffsets = new Set([0x000d84f4]);
|
// Bundles that bind via dispatch_roots but are HUD/UI artwork, not authored
|
||||||
|
// map placements. They surface in the scene because they share the dispatch
|
||||||
|
// table format with world objects, but rendering them produces a stray UI
|
||||||
|
// panel floating in the middle of an empty area.
|
||||||
|
// 0x000d84f4 - portrait/talk panel (already known)
|
||||||
|
// 0x00074f44 - type 0xAC (172) full-screen UI panel, 216x126, kind-4
|
||||||
|
// mode-2; user-confirmed as the lone "TAC:b4f44" leak.
|
||||||
|
// 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) {
|
for (const record of records) {
|
||||||
if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) {
|
if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) {
|
||||||
skippedRecords.push({
|
skippedRecords.push({
|
||||||
|
|
@ -1064,7 +1126,62 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
|
||||||
|
|
||||||
const binding = chooseBundleBinding(record, bundles, options);
|
const binding = chooseBundleBinding(record, bundles, options);
|
||||||
const bundle = binding?.bundle ?? null;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) {
|
if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) {
|
||||||
|
|
@ -1102,6 +1219,9 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
|
||||||
rowIndex: record.rowIndex,
|
rowIndex: record.rowIndex,
|
||||||
typeWord: record.typeWord,
|
typeWord: record.typeWord,
|
||||||
laneWord: record.laneWord,
|
laneWord: record.laneWord,
|
||||||
|
worldX: record.xWord ?? null,
|
||||||
|
worldY: record.yWord ?? null,
|
||||||
|
worldZ: record.zWord ?? null,
|
||||||
screenX: record.screenX,
|
screenX: record.screenX,
|
||||||
screenY: record.screenY,
|
screenY: record.screenY,
|
||||||
bundleSlot: bundle.slot,
|
bundleSlot: bundle.slot,
|
||||||
|
|
@ -1125,21 +1245,89 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
|
||||||
height: sprite.height,
|
height: sprite.height,
|
||||||
originX: sprite.originX,
|
originX: sprite.originX,
|
||||||
originY: sprite.originY,
|
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,
|
drawY: record.screenY - sprite.originY,
|
||||||
stage: record.typeWord === 4 || (record.laneWord & 0x0400) !== 0 ? 1 : 0,
|
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,
|
resourceKey,
|
||||||
paletteDiagnostics: derivePaletteDiagnostics(record, bundle),
|
paletteDiagnostics: derivePaletteDiagnostics(record, bundle),
|
||||||
sprite,
|
sprite,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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. 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;
|
||||||
|
};
|
||||||
|
const depthKey = (item) => {
|
||||||
|
if (Number.isFinite(item.worldX) && Number.isFinite(item.worldY)) {
|
||||||
|
return item.worldX + item.worldY;
|
||||||
|
}
|
||||||
|
return item.screenY;
|
||||||
|
};
|
||||||
items.sort((left, right) => {
|
items.sort((left, right) => {
|
||||||
if (left.stage !== right.stage) {
|
if (left.stage !== right.stage) {
|
||||||
return left.stage - right.stage;
|
return left.stage - right.stage;
|
||||||
}
|
}
|
||||||
if (left.screenY !== right.screenY) {
|
// Two-pass painter: ALL flat ground decals first (back-to-front by depth),
|
||||||
return left.screenY - right.screenY;
|
// 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);
|
||||||
|
if (leftDepth !== rightDepth) {
|
||||||
|
return leftDepth - rightDepth;
|
||||||
|
}
|
||||||
|
const leftZ = Number.isFinite(left.worldZ) ? left.worldZ : 0;
|
||||||
|
const rightZ = Number.isFinite(right.worldZ) ? right.worldZ : 0;
|
||||||
|
if (leftZ !== rightZ) {
|
||||||
|
return leftZ - rightZ;
|
||||||
|
}
|
||||||
|
const leftLayer = layerTieBreak(left);
|
||||||
|
const rightLayer = layerTieBreak(right);
|
||||||
|
if (leftLayer !== rightLayer) {
|
||||||
|
return leftLayer - rightLayer;
|
||||||
}
|
}
|
||||||
return left.screenX - right.screenX;
|
return left.screenX - right.screenX;
|
||||||
});
|
});
|
||||||
|
|
@ -1200,13 +1388,19 @@ export async function exportMap(options) {
|
||||||
const cacheBaseRoot = path.join(options.projectRoot, '.cache');
|
const cacheBaseRoot = path.join(options.projectRoot, '.cache');
|
||||||
const cacheRoot = path.join(options.projectRoot, '.cache', mapStem);
|
const cacheRoot = path.join(options.projectRoot, '.cache', mapStem);
|
||||||
const spriteRoot = path.join(cacheRoot, 'sprites');
|
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 ensureDirectory(cacheBaseRoot);
|
||||||
await resetDirectory(cacheRoot);
|
await resetDirectory(cacheRoot);
|
||||||
await ensureDirectory(cacheRoot);
|
await ensureDirectory(cacheRoot);
|
||||||
await ensureDirectory(spriteRoot);
|
await ensureDirectory(spriteRoot);
|
||||||
await resetDirectory(outputRoot);
|
if (options.resetOutputRoot === false) {
|
||||||
|
await ensureDirectory(outputRoot);
|
||||||
|
} else {
|
||||||
|
await resetDirectory(outputRoot);
|
||||||
|
}
|
||||||
|
|
||||||
const recordSet = chooseRecordSet(wdl, options.mapSource);
|
const recordSet = chooseRecordSet(wdl, options.mapSource);
|
||||||
const region04 = wdl.regions.find((region) => region.name === 'post_audio_region_04');
|
const region04 = wdl.regions.find((region) => region.name === 'post_audio_region_04');
|
||||||
|
|
@ -1363,6 +1557,9 @@ export async function exportMap(options) {
|
||||||
rawWords: item.rawWords,
|
rawWords: item.rawWords,
|
||||||
typeWord: item.typeWord,
|
typeWord: item.typeWord,
|
||||||
laneWord: item.laneWord,
|
laneWord: item.laneWord,
|
||||||
|
worldX: item.worldX ?? null,
|
||||||
|
worldY: item.worldY ?? null,
|
||||||
|
worldZ: item.worldZ ?? null,
|
||||||
screenX: item.screenX,
|
screenX: item.screenX,
|
||||||
screenY: item.screenY,
|
screenY: item.screenY,
|
||||||
bundleSlot: item.bundleSlot,
|
bundleSlot: item.bundleSlot,
|
||||||
|
|
@ -1395,6 +1592,7 @@ export async function exportMap(options) {
|
||||||
originX: item.originX,
|
originX: item.originX,
|
||||||
originY: item.originY,
|
originY: item.originY,
|
||||||
stage: item.stage,
|
stage: item.stage,
|
||||||
|
placeholder: item.placeholder === true,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,18 @@ const GLYPHS = {
|
||||||
'7': ['111', '001', '001', '001', '001'],
|
'7': ['111', '001', '001', '001', '001'],
|
||||||
'8': ['111', '101', '111', '101', '111'],
|
'8': ['111', '101', '111', '101', '111'],
|
||||||
'9': ['111', '101', '111', '001', '111'],
|
'9': ['111', '101', '111', '001', '111'],
|
||||||
|
'a': ['010', '101', '111', '101', '101'],
|
||||||
|
'b': ['110', '101', '110', '101', '110'],
|
||||||
|
'c': ['111', '100', '100', '100', '111'],
|
||||||
|
'd': ['110', '101', '101', '101', '110'],
|
||||||
|
'e': ['111', '100', '110', '100', '111'],
|
||||||
|
'f': ['111', '100', '110', '100', '100'],
|
||||||
|
'p': ['110', '101', '110', '100', '100'],
|
||||||
|
't': ['111', '010', '010', '010', '010'],
|
||||||
|
'#': ['101', '111', '101', '111', '101'],
|
||||||
|
':': ['000', '010', '000', '010', '000'],
|
||||||
|
'/': ['001', '001', '010', '100', '100'],
|
||||||
|
'-': ['000', '000', '111', '000', '000'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function clearCanvas(width, height, background = null) {
|
function clearCanvas(width, height, background = null) {
|
||||||
|
|
@ -87,6 +99,18 @@ function drawLabel(canvas, canvasWidth, canvasHeight, text, x, y) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawBoundingBox(canvas, canvasWidth, canvasHeight, x, y, width, height, alpha = 220) {
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Top + bottom edges
|
||||||
|
fillRect(canvas, canvasWidth, canvasHeight, x, y, width, 1, 255, 255, 255, alpha);
|
||||||
|
fillRect(canvas, canvasWidth, canvasHeight, x, y + height - 1, width, 1, 255, 255, 255, alpha);
|
||||||
|
// Left + right edges
|
||||||
|
fillRect(canvas, canvasWidth, canvasHeight, x, y, 1, height, 255, 255, 255, alpha);
|
||||||
|
fillRect(canvas, canvasWidth, canvasHeight, x + width - 1, y, 1, height, 255, 255, 255, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
|
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
|
||||||
for (let y = 0; y < sprite.height; y += 1) {
|
for (let y = 0; y < sprite.height; y += 1) {
|
||||||
const canvasY = dstY + y;
|
const canvasY = dstY + y;
|
||||||
|
|
@ -115,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 = {}) {
|
export function renderMap(items, options = {}) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
throw new Error('No renderable scene items were produced.');
|
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) => ({
|
(state, item) => ({
|
||||||
minX: Math.min(state.minX, item.drawX),
|
minX: Math.min(state.minX, item.drawX),
|
||||||
minY: Math.min(state.minY, item.drawY),
|
minY: Math.min(state.minY, item.drawY),
|
||||||
|
|
@ -133,9 +213,27 @@ export function renderMap(items, options = {}) {
|
||||||
const padding = 16;
|
const padding = 16;
|
||||||
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
|
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
|
||||||
const height = Math.max(1, (bounds.maxY - bounds.minY) + (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);
|
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(
|
blitRgba(
|
||||||
canvas,
|
canvas,
|
||||||
width,
|
width,
|
||||||
|
|
@ -148,15 +246,37 @@ export function renderMap(items, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.drawLabels) {
|
if (options.drawLabels) {
|
||||||
for (const item of items) {
|
// Cap labels at 10 instances per (typeWord, bundleAbsoluteOffset) pair so
|
||||||
drawLabel(
|
// dense root layers (e.g. floor tiles) do not bury the labels we actually
|
||||||
canvas,
|
// need to read. The bounding box is also suppressed once the label budget
|
||||||
width,
|
// is exhausted so duplicate-instance clusters do not redraw white outlines
|
||||||
height,
|
// on every tile and obscure surrounding art.
|
||||||
item.labelId ?? item.id,
|
const labelBudgetPerBundle = 10;
|
||||||
item.drawX - bounds.minX + padding,
|
const labelTally = new Map();
|
||||||
item.drawY - bounds.minY + padding
|
for (const item of visibleItems) {
|
||||||
);
|
const tallyKey = `${item.typeWord ?? '?'}|${item.bundleAbsoluteOffset ?? '?'}`;
|
||||||
|
const used = labelTally.get(tallyKey) ?? 0;
|
||||||
|
if (used >= labelBudgetPerBundle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
labelTally.set(tallyKey, used + 1);
|
||||||
|
const boxX = item.drawX - bounds.minX + padding;
|
||||||
|
const boxY = item.drawY - bounds.minY + padding;
|
||||||
|
const boxW = item.width ?? item.sprite?.width ?? 0;
|
||||||
|
const boxH = item.height ?? item.sprite?.height ?? 0;
|
||||||
|
drawBoundingBox(canvas, width, height, boxX, boxY, boxW, boxH);
|
||||||
|
// Compose a useful label: typeWord (hex) + bundle absolute offset (last 4 hex)
|
||||||
|
const labelParts = [];
|
||||||
|
if (Number.isInteger(item.typeWord)) {
|
||||||
|
labelParts.push('t' + item.typeWord.toString(16));
|
||||||
|
}
|
||||||
|
if (Number.isInteger(item.bundleAbsoluteOffset)) {
|
||||||
|
labelParts.push('b' + (item.bundleAbsoluteOffset & 0xffff).toString(16).padStart(4, '0'));
|
||||||
|
}
|
||||||
|
if (labelParts.length === 0 && (item.labelId ?? item.id) !== undefined) {
|
||||||
|
labelParts.push('#' + (item.labelId ?? item.id));
|
||||||
|
}
|
||||||
|
drawLabel(canvas, width, height, labelParts.join(':'), boxX, boxY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +284,7 @@ export function renderMap(items, options = {}) {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bounds,
|
bounds,
|
||||||
|
droppedOutlierCount: droppedOutliers.length,
|
||||||
png: encodePng(canvas, width, height),
|
png: encodePng(canvas, width, height),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -286,14 +286,34 @@ function isStructuredCandidate(words) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectCtorPlacement(xWord, yWord, zWord) {
|
||||||
|
// Engine-accurate projection, matching `psx_project_object_main_visible`
|
||||||
|
// (0x80040d44) followed by the camera subtraction at 0x80040e3c/0x80040e5c.
|
||||||
|
//
|
||||||
|
// The projection step writes to obj+0x78/0x7c:
|
||||||
|
// proj_x = Y - X
|
||||||
|
// proj_y = 2*Z - (X+Y)/2
|
||||||
|
// The draw step then writes to obj+0x20/0x22:
|
||||||
|
// screen_x = (cam_X - proj_x) - origin_X
|
||||||
|
// screen_y = (cam_Y - proj_y) - origin_Y
|
||||||
|
// i.e. the engine renders the world with the sign flipped relative to the
|
||||||
|
// raw projection. For a camera-less full-map export we fold that flip into
|
||||||
|
// the projection so higher world Z appears higher on the canvas (Y-down).
|
||||||
|
const projX = yWord - xWord;
|
||||||
|
const projY = (2 * zWord) - Math.floor((xWord + yWord) / 2);
|
||||||
|
return {
|
||||||
|
screenX: -projX * PSX_SCREEN_SCALE,
|
||||||
|
screenY: -projY * PSX_SCREEN_SCALE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildRecord(words, source, offset, rawWords = words) {
|
function buildRecord(words, source, offset, rawWords = words) {
|
||||||
if (!isPlausibleRecord(words)) {
|
if (!isPlausibleRecord(words)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||||
const screenX = (yWord - xWord) * PSX_SCREEN_SCALE;
|
const { screenX, screenY } = projectCtorPlacement(xWord, yWord, zWord);
|
||||||
const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
index: -1,
|
index: -1,
|
||||||
|
|
@ -595,11 +615,17 @@ export function parseRegion01Records(region) {
|
||||||
|
|
||||||
// Loader-faithful 12-byte constructor-placement records straight out of the
|
// Loader-faithful 12-byte constructor-placement records straight out of the
|
||||||
// `ctorPlacements` subrange of the section pack. Layout per
|
// `ctorPlacements` subrange of the section pack. Layout per
|
||||||
// `psx_dispatch_section0_constructor_placements @ 0x800258cc`:
|
// `psx_object_create_compound_record @ 0x80025040`:
|
||||||
// [u32 count][count * { u16 typeWord; u16 X; u16 Y; u16 Z; u16 selector;
|
// +0x00 u16 typeWord -> descriptor_table index
|
||||||
// u16 flags }]
|
// +0x02 u16 X -> obj world X (<<16 seed)
|
||||||
// Each record is called as `descriptor_table[typeWord].slot0(record, 0)` and
|
// +0x04 u16 Y -> obj world Y (<<16 seed)
|
||||||
// `psx_object_create_compound_record` then reads exactly those 6 u16 fields.
|
// +0x06 u8 Z (low byte only!)-> obj world Z (<<16 seed). The high byte at
|
||||||
|
// +0x07 is not read as part of Z.
|
||||||
|
// +0x08 u8 selector -> state-script seed
|
||||||
|
// +0x09 u8 (padding/unknown)
|
||||||
|
// +0x0a u16 laneWord -> OR'd into obj+0x1c (flags/lane)
|
||||||
|
// The previous implementation decoded Z as a u16 from +0x06, which mixed the
|
||||||
|
// selector byte into Z and produced large bogus elevations.
|
||||||
export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
||||||
if (!block || !block.buffer || block.size < 4) {
|
if (!block || !block.buffer || block.size < 4) {
|
||||||
return { source: 'ctorPlacements', records: [], count: 0 };
|
return { source: 'ctorPlacements', records: [], count: 0 };
|
||||||
|
|
@ -614,11 +640,17 @@ export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
||||||
if (recordOffset + 12 > block.buffer.length) {
|
if (recordOffset + 12 > block.buffer.length) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const words = [];
|
const typeWord = readU16LE(block.buffer, recordOffset + 0x00);
|
||||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
const xWord = readU16LE(block.buffer, recordOffset + 0x02);
|
||||||
words.push(readU16LE(block.buffer, recordOffset + cursor));
|
const yWord = readU16LE(block.buffer, recordOffset + 0x04);
|
||||||
}
|
const zWord = block.buffer[recordOffset + 0x06];
|
||||||
const record = buildRecord(words, 'ctorPlacements', recordOffset, words);
|
const z2Byte = block.buffer[recordOffset + 0x07];
|
||||||
|
const selectorWord = block.buffer[recordOffset + 0x08];
|
||||||
|
const selectorHighByte = block.buffer[recordOffset + 0x09];
|
||||||
|
const laneWord = readU16LE(block.buffer, recordOffset + 0x0a);
|
||||||
|
const rawWords = [typeWord, xWord, yWord, (z2Byte << 8) | zWord, (selectorHighByte << 8) | selectorWord, laneWord];
|
||||||
|
const words = [typeWord, xWord, yWord, zWord, selectorWord, laneWord];
|
||||||
|
const record = buildRecord(words, 'ctorPlacements', recordOffset, rawWords);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -646,17 +678,17 @@ export function parseCtorPlacementsBlock(block, variant = 'lset') {
|
||||||
// `psx_dispatch_section0_dispatch_roots @ 0x800256b0`:
|
// `psx_dispatch_section0_dispatch_roots @ 0x800256b0`:
|
||||||
// [u32 count][count * 24 bytes]
|
// [u32 count][count * 24 bytes]
|
||||||
// Within each record the dispatcher reads:
|
// Within each record the dispatcher reads:
|
||||||
// +4 u16 typeId (argument to descriptor_table[typeId])
|
// +0x04 u16 typeId (descriptor_table index)
|
||||||
// +8 u16 screenX (for +/-0x140 cull)
|
// +0x08 u16 worldX (compared to cam+0x3e +/- 0x140 — cam+0x3e is the
|
||||||
// +10 u16 screenY (for +/-0x140 cull)
|
// camera's world-X short, not the projected integer
|
||||||
// +16 u16 flags (bit 3 = skip this record)
|
// part; proof: root worldX values overlap constructor
|
||||||
// Z, selector and lane are not universally used by the dispatcher. The empirical
|
// placement worldX values in the same map)
|
||||||
// best mapping that matches constructor-placement convention is:
|
// +0x0a u16 worldY (compared to cam+0x42 +/- 0x140)
|
||||||
// +6 u16 zeta (often 0)
|
// +0x10 u16 flags (bit 3 = skip this record)
|
||||||
// +12 u16 selector/rotation
|
// We therefore project root worldX/worldY through the same projection as
|
||||||
// +14 u16 lane/flags-lo
|
// constructor placements (with Z=0 since dispatch roots do not carry a Z byte
|
||||||
// We keep them in rawWords so downstream consumers can probe further, but
|
// at a known offset) and render them on the same canvas. The trailing raw
|
||||||
// buildRecord uses the cull-verified X/Y/typeId for positioning.
|
// fields are preserved as `dispatchRootRawWords` for future probes.
|
||||||
export function parseDispatchRootsBlock(block, variant = 'lset') {
|
export function parseDispatchRootsBlock(block, variant = 'lset') {
|
||||||
if (!block || !block.buffer || block.size < 4) {
|
if (!block || !block.buffer || block.size < 4) {
|
||||||
return { source: 'dispatchRoots', records: [], count: 0 };
|
return { source: 'dispatchRoots', records: [], count: 0 };
|
||||||
|
|
@ -675,17 +707,6 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
|
||||||
for (let cursor = 0; cursor < 24; cursor += 2) {
|
for (let cursor = 0; cursor < 24; cursor += 2) {
|
||||||
rawWords.push(readU16LE(block.buffer, recordOffset + cursor));
|
rawWords.push(readU16LE(block.buffer, recordOffset + cursor));
|
||||||
}
|
}
|
||||||
// Project into buildRecord's 6-word ctor-placement shape using the fields
|
|
||||||
// the live dispatcher reads. rawWords indices:
|
|
||||||
// [0..1] (+0..+3) prefix
|
|
||||||
// [2] (+4) typeId (dispatch)
|
|
||||||
// [3] (+6) zeta / z-ish
|
|
||||||
// [4] (+8) X (cull)
|
|
||||||
// [5] (+10) Y (cull)
|
|
||||||
// [6] (+12) selector-ish
|
|
||||||
// [7] (+14) lane-ish
|
|
||||||
// [8] (+16) flags (bit 3 skip)
|
|
||||||
// [9..11] trailing
|
|
||||||
const flags = rawWords[8];
|
const flags = rawWords[8];
|
||||||
if ((flags & 0x8) !== 0) {
|
if ((flags & 0x8) !== 0) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -693,14 +714,16 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
|
||||||
const typeWord = rawWords[2];
|
const typeWord = rawWords[2];
|
||||||
const xWord = rawWords[4];
|
const xWord = rawWords[4];
|
||||||
const yWord = rawWords[5];
|
const yWord = rawWords[5];
|
||||||
|
// 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 zWord = rawWords[3] & 0xff;
|
||||||
const selectorWord = rawWords[6];
|
const selectorWord = rawWords[6];
|
||||||
const laneWord = rawWords[7];
|
const laneWord = rawWords[7];
|
||||||
// Relaxed plausibility: dispatch-root records can have lane==0 or large
|
|
||||||
// selectors (e.g. behavior opcodes) because the dispatcher only reads
|
|
||||||
// typeId, X, Y, and the +0x10 skip flag. We only require typeId in the
|
|
||||||
// scene-relevant range and X/Y not both zero. This is looser than
|
|
||||||
// `isPlausibleRecord` on purpose.
|
|
||||||
if (typeWord < 0x20 || typeWord > 0x1ff) {
|
if (typeWord < 0x20 || typeWord > 0x1ff) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -708,8 +731,7 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const words = [typeWord, xWord, yWord, zWord, selectorWord, laneWord];
|
const words = [typeWord, xWord, yWord, zWord, selectorWord, laneWord];
|
||||||
const screenX = (yWord - xWord) * PSX_SCREEN_SCALE;
|
const { screenX, screenY } = projectCtorPlacement(xWord, yWord, zWord);
|
||||||
const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE;
|
|
||||||
const record = {
|
const record = {
|
||||||
index: -1,
|
index: -1,
|
||||||
source: 'dispatchRoots',
|
source: 'dispatchRoots',
|
||||||
|
|
|
||||||
12
psx-map-exporter/viewer/index.html
Normal file
12
psx-map-exporter/viewer/index.html
Normal 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
1207
psx-map-exporter/viewer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
psx-map-exporter/viewer/package.json
Normal file
18
psx-map-exporter/viewer/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
536
psx-map-exporter/viewer/src/App.vue
Normal file
536
psx-map-exporter/viewer/src/App.vue
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
|
import ItemPanel from './ItemPanel.vue';
|
||||||
|
|
||||||
|
const PADDING = 16;
|
||||||
|
// Match psx-map-exporter/src/render.js DEFAULT_BACKGROUND. The exporter still
|
||||||
|
// produces a baked .png for offline reference, but the viewer composites each
|
||||||
|
// sprite live so hide/filter operations no longer carve holes out of the map.
|
||||||
|
const BACKGROUND = 'rgb(18, 18, 18)';
|
||||||
|
|
||||||
|
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 canvasRef = ref(null);
|
||||||
|
const containerRef = ref(null);
|
||||||
|
// Sprite image cache shared across redraws. Keyed by mapStem|bundleAbsHex|frameIdx
|
||||||
|
// so swapping maps does not invalidate sprites still bound by other items.
|
||||||
|
const spriteCache = new Map();
|
||||||
|
|
||||||
|
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.
|
||||||
|
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;
|
||||||
|
hiddenIds.value = new Set();
|
||||||
|
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, mapStem: selectedMap.value };
|
||||||
|
// Eagerly preload all unique sprite frames referenced by this scene so
|
||||||
|
// the first canvas paint shows the full map instead of a half-loaded
|
||||||
|
// jumble. drawScene() is then called once all are settled.
|
||||||
|
await preloadSprites(json, selectedMap.value);
|
||||||
|
} catch (error) {
|
||||||
|
sceneError.value = error.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([selectedMap, selectedVariant], loadScene);
|
||||||
|
|
||||||
|
function spriteKey(mapStem, bundleAbsoluteOffset, frameIndex) {
|
||||||
|
return `${mapStem}|${bundleAbsoluteOffset.toString(16)}|${frameIndex}`;
|
||||||
|
}
|
||||||
|
function spriteUrl(mapStem, bundleAbsoluteOffset, frameIndex) {
|
||||||
|
const bundleHex = bundleAbsoluteOffset.toString(16).padStart(8, '0');
|
||||||
|
const frameStr = String(frameIndex).padStart(3, '0');
|
||||||
|
return `/sprites/${mapStem}/bundle_${bundleHex}/frame_${frameStr}.png`;
|
||||||
|
}
|
||||||
|
function loadSprite(mapStem, bundleAbsoluteOffset, frameIndex) {
|
||||||
|
const key = spriteKey(mapStem, bundleAbsoluteOffset, frameIndex);
|
||||||
|
let entry = spriteCache.get(key);
|
||||||
|
if (entry) return entry;
|
||||||
|
entry = new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
// Replace the Promise with the resolved Image so drawScene's fast path
|
||||||
|
// (synchronous cache hit) sees a real HTMLImageElement instead of a
|
||||||
|
// Promise. The Promise is still returned from this call for any awaiter.
|
||||||
|
spriteCache.set(key, img);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
spriteCache.set(key, null); // Cache the miss so we do not retry forever
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
img.src = spriteUrl(mapStem, bundleAbsoluteOffset, frameIndex);
|
||||||
|
});
|
||||||
|
spriteCache.set(key, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
async function preloadSprites(json, mapStem) {
|
||||||
|
const seen = new Set();
|
||||||
|
const tasks = [];
|
||||||
|
for (const it of json.items) {
|
||||||
|
if (it.placeholder) continue;
|
||||||
|
if (!Number.isInteger(it.bundleAbsoluteOffset) || !Number.isInteger(it.frameIndex)) continue;
|
||||||
|
const key = spriteKey(mapStem, it.bundleAbsoluteOffset, it.frameIndex);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
tasks.push(loadSprite(mapStem, it.bundleAbsoluteOffset, it.frameIndex));
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWidth = computed(() => {
|
||||||
|
if (!scene.value) return 0;
|
||||||
|
return scene.value.json.renderWidth ?? 0;
|
||||||
|
});
|
||||||
|
const canvasHeight = computed(() => {
|
||||||
|
if (!scene.value) return 0;
|
||||||
|
return scene.value.json.renderHeight ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawPlaceholder(ctx, 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);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - half);
|
||||||
|
ctx.lineTo(cx + half, cy);
|
||||||
|
ctx.lineTo(cx, cy + half);
|
||||||
|
ctx.lineTo(cx - half, cy);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = 'rgb(220, 60, 200)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'rgb(90, 0, 110)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScene() {
|
||||||
|
if (!scene.value || !canvasRef.value) return;
|
||||||
|
const cv = canvasRef.value;
|
||||||
|
const w = canvasWidth.value;
|
||||||
|
const h = canvasHeight.value;
|
||||||
|
if (cv.width !== w) cv.width = w;
|
||||||
|
if (cv.height !== h) cv.height = h;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.fillStyle = BACKGROUND;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
const bounds = scene.value.json.bounds;
|
||||||
|
const mapStem = scene.value.mapStem;
|
||||||
|
for (const item of visibleItems()) {
|
||||||
|
const dx = (item.drawX - bounds.minX) + PADDING;
|
||||||
|
const dy = (item.drawY - bounds.minY) + PADDING;
|
||||||
|
if (item.placeholder || !Number.isInteger(item.bundleAbsoluteOffset)) {
|
||||||
|
drawPlaceholder(ctx, dx, dy, item.width, item.height);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = spriteKey(mapStem, item.bundleAbsoluteOffset, item.frameIndex);
|
||||||
|
let cached = spriteCache.get(key);
|
||||||
|
if (cached === undefined) {
|
||||||
|
// Not yet requested: kick a load and skip this paint. The load
|
||||||
|
// resolution will trigger a redraw via the .then handler below.
|
||||||
|
cached = loadSprite(mapStem, item.bundleAbsoluteOffset, item.frameIndex);
|
||||||
|
}
|
||||||
|
// Only synchronous cache hits are drawn this pass. Misses kick off a load
|
||||||
|
// and trigger a redraw when complete; this keeps the paint loop simple
|
||||||
|
// and avoids partial draws when scrolling.
|
||||||
|
if (cached && typeof cached.then === 'function') {
|
||||||
|
cached.then((img) => {
|
||||||
|
if (img && scene.value?.mapStem === mapStem) drawScene();
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const img = cached;
|
||||||
|
if (!img) continue;
|
||||||
|
if (item.flipped) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(dx + item.width, dy);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
ctx.drawImage(img, dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitToContainer() {
|
||||||
|
if (!containerRef.value || !canvasWidth.value) return;
|
||||||
|
const c = containerRef.value.getBoundingClientRect();
|
||||||
|
const sx = c.width / canvasWidth.value;
|
||||||
|
const sy = c.height / canvasHeight.value;
|
||||||
|
const z = Math.min(sx, sy) * 0.95;
|
||||||
|
pan.value = {
|
||||||
|
x: (c.width - canvasWidth.value * z) / 2,
|
||||||
|
y: (c.height - canvasHeight.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 from the live canvas redraw, so the
|
||||||
|
// sprite vanishes instead of being masked over and you can see anything
|
||||||
|
// underneath. Clear via the "Show hidden" / "restore all" buttons.
|
||||||
|
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) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The scene canvas is inside the `v-else-if="scene"` template branch, which
|
||||||
|
// does not mount until `loading` flips back to false. Painting during
|
||||||
|
// `loadScene()` therefore races the DOM and leaves the canvas blank while the
|
||||||
|
// SVG hitbox overlay still appears. Paint only after the scene branch exists.
|
||||||
|
watch(
|
||||||
|
[scene, loading, canvasWidth, canvasHeight],
|
||||||
|
async ([nextScene, isLoading, width, height]) => {
|
||||||
|
if (!nextScene || isLoading || !width || !height) return;
|
||||||
|
await nextTick();
|
||||||
|
fitToContainer();
|
||||||
|
drawScene();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger a canvas repaint whenever any input that affects drawing changes.
|
||||||
|
// `visibleItems()` is not wrapped in a computed (it returns a fresh array),
|
||||||
|
// so we watch the underlying inputs explicitly.
|
||||||
|
watch(
|
||||||
|
[hiddenIds, showOnlyLayer, filterTypeHex, filterBundleHex],
|
||||||
|
() => { drawScene(); },
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="map-canvas"
|
||||||
|
:width="canvasWidth"
|
||||||
|
:height="canvasHeight"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
v-if="canvasWidth"
|
||||||
|
class="overlay"
|
||||||
|
:width="canvasWidth"
|
||||||
|
:height="canvasHeight"
|
||||||
|
:viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
|
||||||
|
>
|
||||||
|
<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-canvas { display: block; image-rendering: pixelated; user-select: none; -webkit-user-drag: none; background: rgb(18, 18, 18); }
|
||||||
|
.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>
|
||||||
102
psx-map-exporter/viewer/src/ItemPanel.vue
Normal file
102
psx-map-exporter/viewer/src/ItemPanel.vue
Normal 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>
|
||||||
5
psx-map-exporter/viewer/src/main.js
Normal file
5
psx-map-exporter/viewer/src/main.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
38
psx-map-exporter/viewer/src/style.css
Normal file
38
psx-map-exporter/viewer/src/style.css
Normal 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; }
|
||||||
110
psx-map-exporter/viewer/vite.config.js
Normal file
110
psx-map-exporter/viewer/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
0
temp_head.json
Normal file
0
temp_head.json
Normal file
Loading…
Add table
Add a link
Reference in a new issue