map viewer

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

View file

@ -8,6 +8,10 @@ function readU16LE(buffer, offset) {
return buffer.readUInt16LE(offset);
}
function readS16LE(buffer, offset) {
return buffer.readInt16LE(offset);
}
function rowByteWidth(width, mode) {
return mode === 2 ? Math.ceil(width / 2) : width;
}
@ -314,8 +318,8 @@ export function extractBundleFromHeader(payloadBuffer, absoluteHeaderOffset) {
const relativeDataOffset = readU32LE(payloadBuffer, entryOffset + 0x08);
const frameWidth = readU16LE(payloadBuffer, entryOffset + 0x0c);
const frameHeight = readU16LE(payloadBuffer, entryOffset + 0x0e);
const originX = readU16LE(payloadBuffer, entryOffset + 0x10);
const originY = readU16LE(payloadBuffer, entryOffset + 0x12);
const originX = readS16LE(payloadBuffer, entryOffset + 0x10);
const originY = readS16LE(payloadBuffer, entryOffset + 0x12);
const dataStart = dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;

View file

@ -1,21 +1,37 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { exportMap } from './export-map.js';
// Resolve the on-disc PSX asset root. We prefer the ORIGINAL extracted disc
// files so the exporter never depends on a pre-processed cache; the cached
// STATIC_PSX copy in the sibling viewer workspace is kept only as a fallback
// for environments without the disc mounted.
function resolveDefaultDiscRoot(moduleDir) {
const candidates = [
'E:/emu/psx/Crusader - No Remorse',
'E:/emu/psx/Crusader 2 Pre-Pre Alpha',
path.resolve(moduleDir, '..', '..', '..', 'crusader_map_viewer', 'map_renderer', 'STATIC_PSX'),
];
for (const candidate of candidates) {
try {
const stat = fs.statSync(candidate);
if (stat.isDirectory()) {
return candidate;
}
} catch {
// try next
}
}
return candidates[candidates.length - 1];
}
function parseArgs(argv) {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const options = {
discRoot: path.resolve(
moduleDir,
'..',
'..',
'..',
'crusader_map_viewer',
'map_renderer',
'STATIC_PSX'
),
discRoot: resolveDefaultDiscRoot(moduleDir),
gpuRamDump: null,
mapSource: 'auto',
bindingMode: 'raw',
@ -75,6 +91,15 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (arg === '--output-root') {
options.outputRoot = path.resolve(next);
index += 1;
continue;
}
if (arg === '--no-reset-output') {
options.resetOutputRoot = false;
continue;
}
if (arg === '--debug-labels') {
options.debugLabels = true;
continue;
@ -140,6 +165,8 @@ async function main() {
gpuRamDumpPath: options.gpuRamDump,
validationBundles: options.validationBundles,
outName: options.outName,
outputRoot: options.outputRoot,
resetOutputRoot: options.resetOutputRoot,
debugLabels: Boolean(options.debugLabels),
});

View file

@ -431,6 +431,21 @@ function chooseBundleBinding(record, bundles, options = {}) {
return null;
}
// Mark high-typeWord dispatch_root entries (>= 0xAC) as non-renderable
// placeholders rather than guessing art for them. The PSX engine switches
// its palette-token sourcing at typeWord 0xAC: types below use the 16-byte
// ctor record (renderable world objects) and types at or above use the
// 24-byte dispatch-root record (spawners, triggers, NPCs, UI panels) and
// typically have no sprite art at all. Without this guard the
// `bundles[typeWord]` fallback below picks an arbitrary bundle by raw scan
// order and produces wildly wrong sprites (e.g. invisible spawners
// rendered as brick walls / turrets / teleporters). The viewer still gets
// a placeholder so these entities remain inspectable.
// Cross-ref `/memories/repo/psx-typeword-renderable-boundary-2026-04-19.md`.
if (record.sourceFamily === 'section0_dispatch_roots' && record.typeWord >= 0xAC) {
return { bundle: null, mappingSource: 'placeholder-dispatch-root-high-typeword', runtimeBinding: null, placeholder: true };
}
const rawTypeBundle = chooseBundleForType(bundles, record.typeWord);
if (!rawTypeBundle) {
return null;
@ -1093,7 +1108,11 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
// 0x000d84f4 - portrait/talk panel (already known)
// 0x00074f44 - type 0xAC (172) full-screen UI panel, 216x126, kind-4
// mode-2; user-confirmed as the lone "TAC:b4f44" leak.
const nonMapFacingBundleOffsets = new Set([0x000d84f4, 0x00074f44]);
// 0x00086810 - "ceiling" tile (T0x43, kind-5 mode-2, 128x65). Engine uses
// it at runtime to temporarily obscure rooms the player has
// not entered yet; for static map rendering it just hides
// the geometry the user wants to see, so it is suppressed.
const nonMapFacingBundleOffsets = new Set([0x000d84f4, 0x00074f44, 0x00086810]);
for (const record of records) {
if (record.sourceFamily === 'section0_dispatch_roots' && nonMapFacingRootTypes.has(record.typeWord)) {
skippedRecords.push({
@ -1107,7 +1126,62 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
const binding = chooseBundleBinding(record, bundles, options);
const bundle = binding?.bundle ?? null;
if (!bundle || !binding) {
if (!binding) {
continue;
}
if (binding.placeholder) {
// Non-renderable entity (spawner, trigger, NPC, UI marker). Emit a small
// square footprint so the viewer can still display and inspect it.
const PLACEHOLDER_SIZE = 12;
items.push({
id: record.index,
instanceId: record.index,
recordIndex: record.index,
recordSource: record.source,
sourceFamily: record.sourceFamily,
authoredLayer: record.authoredLayer ?? record.sourceRole ?? null,
recordSide: record.recordSide,
rowIndex: record.rowIndex,
typeWord: record.typeWord,
laneWord: record.laneWord,
worldX: record.xWord ?? null,
worldY: record.yWord ?? null,
worldZ: record.zWord ?? null,
screenX: record.screenX,
screenY: record.screenY,
bundleSlot: null,
bundleAbsoluteOffset: null,
bundleSource: null,
requestedFrameIndex: record.selectorWord,
frameIndex: null,
defaultPaletteIndex: null,
resolvedPaletteIndex: null,
paletteFormula: null,
mappingSource: binding.mappingSource,
templateTypeId: null,
donorTypeId: null,
runtimeBindingMaskedAbsoluteOffset: null,
runtimeBindingOffsetDelta: null,
runtimeBindingVisibleCount: null,
runtimeBindingRawRecordCount: null,
rawWords: record.rawWords ?? record.words,
flipped: false,
width: PLACEHOLDER_SIZE,
height: PLACEHOLDER_SIZE,
originX: PLACEHOLDER_SIZE / 2,
originY: PLACEHOLDER_SIZE / 2,
drawX: record.screenX - PLACEHOLDER_SIZE / 2,
drawY: record.screenY - PLACEHOLDER_SIZE / 2,
stage: 1, // overlays so placeholders sit on top of geometry
isFlat: false,
resourceKey: 'placeholder',
paletteDiagnostics: null,
sprite: null,
placeholder: true,
});
continue;
}
if (!bundle) {
continue;
}
if (nonMapFacingBundleOffsets.has(bundle.absoluteOffset)) {
@ -1171,9 +1245,27 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
height: sprite.height,
originX: sprite.originX,
originY: sprite.originY,
drawX: record.screenX - sprite.originX,
// Engine-accurate anchor selection per psx_project_object_main_visible
// (0x80040d44 / 0x80040ddc): when laneWord bit 0x0002 is set the engine
// mirrors the horizontal anchor from origin_x to (frame_w - origin_x).
// The blit step then flips the sprite. Applying both keeps the visible
// anchor on the same world point, fixing flipped walls that previously
// landed (frame_w - 2*origin_x) px too far left.
drawX: ((record.laneWord & 0x0002) !== 0)
? record.screenX - (sprite.width - sprite.originX)
: record.screenX - sprite.originX,
drawY: record.screenY - sprite.originY,
stage: record.typeWord === 4 || (record.laneWord & 0x0400) !== 0 ? 1 : 0,
// Heuristic: flat floor/ceiling decals are tile-sized sprites whose
// anchor sits at the bottom edge AND whose silhouette is wider than
// tall (i.e. matches the engine's 2:1 isometric ground footprint).
// Upright sprites (walls, crates, terminals, props) extend well above
// the anchor and have height >= width.
isFlat:
sprite.width >= 48 &&
sprite.height > 0 &&
sprite.height / sprite.width <= 0.6 &&
sprite.originY >= sprite.height - 4,
resourceKey,
paletteDiagnostics: derivePaletteDiagnostics(record, bundle),
sprite,
@ -1183,18 +1275,23 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
// Painter's order for an isometric top-down full-map render.
// 1. `stage`: runtime sub-stage flag (0 = default, 1 = overlays flagged via
// typeWord===4 or laneWord bit 0x0400). Overlays always on top.
// 2. `authoredLayer`: `constructors` is the static geometry lane (walls,
// floors, architecture placed by `psx_dispatch_section0_constructor_placements`).
// `roots` is the dispatch-root lane — interactive/dynamic objects such as
// crates, terminals, doors, pickups placed by
// `psx_dispatch_section0_dispatch_roots`. Geometry draws first so that
// props (roots) sit ON TOP of the room they occupy.
// 3. Isometric depth within a layer: back-to-front by world `X + Y` (ground
// depth along the engine's isometric axis), then by `Z` ascending so a
// lower object at the same ground cell does not occlude a taller one
// behind it. Falls back to screenY when world coords are unavailable.
// 4. `screenX` ascending: stable tie-breaker left-to-right.
const layerPriority = (item) => {
// 2. Isometric depth: back-to-front by world `X + Y` (ground depth along the
// engine's isometric axis). Constructors (static geometry: walls, floors,
// architecture) and roots (dynamic props: crates, terminals, doors) are
// interleaved by depth so a crate placed in front of a wall does not get
// painted over by a wall placed further back. Falls back to screenY when
// world coords are unavailable.
// 3. `isFlat`: at the SAME depth, flat ground decals (floor/ceiling tiles)
// must draw BEFORE upright sprites (walls, crates, props) so the props
// standing on the tile sit visually on top of it. Without this bias,
// floors painted on the same world tile as a prop will overpaint the
// prop whenever screenX happens to sort the floor last.
// 4. `worldZ` ascending: lower objects at the same ground cell draw before
// taller ones at that cell.
// 5. `authoredLayer`: when world coords + flatness tie, draw constructors
// (geometry) before roots (props) so props sit on top of their floor.
// 6. `screenX` ascending: stable tie-breaker left-to-right.
const layerTieBreak = (item) => {
if (item.authoredLayer === 'constructors') return 0;
if (item.authoredLayer === 'roots') return 1;
return 2;
@ -1209,10 +1306,13 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
if (left.stage !== right.stage) {
return left.stage - right.stage;
}
const leftLayer = layerPriority(left);
const rightLayer = layerPriority(right);
if (leftLayer !== rightLayer) {
return leftLayer - rightLayer;
// Two-pass painter: ALL flat ground decals first (back-to-front by depth),
// then ALL upright sprites (back-to-front by depth). This avoids the
// floor-anchor problem where a flat tile's authored anchor sits at its
// FRONT tip (largest world X+Y in the footprint), which a single-pass
// depth sort would draw AFTER walls/props that visually stand on it.
if (left.isFlat !== right.isFlat) {
return left.isFlat ? -1 : 1;
}
const leftDepth = depthKey(left);
const rightDepth = depthKey(right);
@ -1224,6 +1324,11 @@ async function buildSceneItems(region04, records, bundles, options = {}) {
if (leftZ !== rightZ) {
return leftZ - rightZ;
}
const leftLayer = layerTieBreak(left);
const rightLayer = layerTieBreak(right);
if (leftLayer !== rightLayer) {
return leftLayer - rightLayer;
}
return left.screenX - right.screenX;
});
@ -1283,13 +1388,19 @@ export async function exportMap(options) {
const cacheBaseRoot = path.join(options.projectRoot, '.cache');
const cacheRoot = path.join(options.projectRoot, '.cache', mapStem);
const spriteRoot = path.join(cacheRoot, 'sprites');
const outputRoot = path.join(options.projectRoot, '.output');
const outputRoot = options.outputRoot
? path.resolve(options.outputRoot)
: path.join(options.projectRoot, '.output');
await ensureDirectory(cacheBaseRoot);
await resetDirectory(cacheRoot);
await ensureDirectory(cacheRoot);
await ensureDirectory(spriteRoot);
await resetDirectory(outputRoot);
if (options.resetOutputRoot === false) {
await ensureDirectory(outputRoot);
} else {
await resetDirectory(outputRoot);
}
const recordSet = chooseRecordSet(wdl, options.mapSource);
const region04 = wdl.regions.find((region) => region.name === 'post_audio_region_04');
@ -1446,6 +1557,9 @@ export async function exportMap(options) {
rawWords: item.rawWords,
typeWord: item.typeWord,
laneWord: item.laneWord,
worldX: item.worldX ?? null,
worldY: item.worldY ?? null,
worldZ: item.worldZ ?? null,
screenX: item.screenX,
screenY: item.screenY,
bundleSlot: item.bundleSlot,
@ -1478,6 +1592,7 @@ export async function exportMap(options) {
originX: item.originX,
originY: item.originY,
stage: item.stage,
placeholder: item.placeholder === true,
})),
};

View file

@ -139,12 +139,68 @@ function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped
}
}
// Magenta diamond marker for placeholder (non-renderable) entities such as
// spawners, triggers, NPCs, and UI panels. Drawn in place of a sprite blit so
// the viewer still surfaces the entity for inspection without guessing wrong
// art. The diamond is 12x12 by default (caller passes the placeholder width).
function drawPlaceholderMarker(canvas, canvasWidth, canvasHeight, dstX, dstY, w, h) {
const half = Math.max(2, Math.floor(Math.min(w, h) / 2));
const cx = Math.round(dstX + w / 2);
const cy = Math.round(dstY + h / 2);
// Filled diamond by scanline.
for (let dy = -half; dy <= half; dy += 1) {
const span = half - Math.abs(dy);
const y = cy + dy;
if (y < 0 || y >= canvasHeight) continue;
for (let dx = -span; dx <= span; dx += 1) {
const x = cx + dx;
if (x < 0 || x >= canvasWidth) continue;
// Magenta interior with darker outline on the diamond edge.
const onEdge = Math.abs(dx) + Math.abs(dy) === half;
const target = ((y * canvasWidth) + x) * 4;
canvas[target + 0] = onEdge ? 90 : 220;
canvas[target + 1] = onEdge ? 0 : 60;
canvas[target + 2] = onEdge ? 110 : 200;
canvas[target + 3] = 255;
}
}
}
export function renderMap(items, options = {}) {
if (items.length === 0) {
throw new Error('No renderable scene items were produced.');
}
const bounds = items.reduce(
// Guard against a single bad item (e.g. corrupt sprite origin) inflating the
// canvas to multiple gigabytes. We compute the median centroid of all items
// and drop anything farther than MAX_EXTENT px from it before computing
// bounds. This keeps render output sane even if the parser produces a few
// outliers, while preserving every real authored item (the playfield is
// never larger than a few thousand pixels in either axis).
const MAX_EXTENT = 4096;
const centroids = items.map((it) => ({ x: it.drawX + (it.width ?? 0) / 2, y: it.drawY + (it.height ?? 0) / 2 }));
const sortedX = centroids.map((c) => c.x).sort((a, b) => a - b);
const sortedY = centroids.map((c) => c.y).sort((a, b) => a - b);
const medianX = sortedX[sortedX.length >> 1];
const medianY = sortedY[sortedY.length >> 1];
const droppedOutliers = [];
const visibleItems = items.filter((it, idx) => {
const dx = Math.abs(centroids[idx].x - medianX);
const dy = Math.abs(centroids[idx].y - medianY);
if (dx > MAX_EXTENT || dy > MAX_EXTENT) {
droppedOutliers.push({ recordIndex: it.recordIndex, typeWord: it.typeWord, drawX: it.drawX, drawY: it.drawY, dx, dy });
return false;
}
return true;
});
if (droppedOutliers.length > 0 && process.env.PSX_TRACE) {
console.error('[renderMap] dropped ' + droppedOutliers.length + ' outlier item(s) beyond ' + MAX_EXTENT + 'px from median; first: ' + JSON.stringify(droppedOutliers[0]));
}
if (visibleItems.length === 0) {
throw new Error('All scene items were classified as outliers; nothing to render.');
}
const bounds = visibleItems.reduce(
(state, item) => ({
minX: Math.min(state.minX, item.drawX),
minY: Math.min(state.minY, item.drawY),
@ -157,9 +213,27 @@ export function renderMap(items, options = {}) {
const padding = 16;
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
const height = Math.max(1, (bounds.maxY - bounds.minY) + (padding * 2));
if (process.env.PSX_TRACE) {
console.error('[renderMap] items=' + visibleItems.length + ' bounds=' + JSON.stringify(bounds) + ' size=' + width + 'x' + height);
}
const canvas = clearCanvas(width, height, options.background ?? DEFAULT_BACKGROUND);
for (const item of items) {
for (const item of visibleItems) {
if (item.placeholder || !item.sprite) {
// Draw a small magenta diamond marker for non-renderable entities
// (spawners, triggers, NPCs). Keeps them visible and inspectable in
// the viewer overlay without guessing wrong art.
drawPlaceholderMarker(
canvas,
width,
height,
item.drawX - bounds.minX + padding,
item.drawY - bounds.minY + padding,
item.width,
item.height
);
continue;
}
blitRgba(
canvas,
width,
@ -179,7 +253,7 @@ export function renderMap(items, options = {}) {
// on every tile and obscure surrounding art.
const labelBudgetPerBundle = 10;
const labelTally = new Map();
for (const item of items) {
for (const item of visibleItems) {
const tallyKey = `${item.typeWord ?? '?'}|${item.bundleAbsoluteOffset ?? '?'}`;
const used = labelTally.get(tallyKey) ?? 0;
if (used >= labelBudgetPerBundle) {
@ -210,6 +284,7 @@ export function renderMap(items, options = {}) {
width,
height,
bounds,
droppedOutlierCount: droppedOutliers.length,
png: encodePng(canvas, width, height),
};
}

View file

@ -714,10 +714,14 @@ export function parseDispatchRootsBlock(block, variant = 'lset') {
const typeWord = rawWords[2];
const xWord = rawWords[4];
const yWord = rawWords[5];
// Dispatch roots do not carry a Z byte at a well-known offset; they are
// treated as floor-plane (Z=0) until a Z field is recovered from the
// descriptor's slot0 callback.
const zWord = 0;
// Engine reads Z from the LOW byte of the u16 at +0x06 of the shared
// authored prefix (constructor view), even when the record is iterated
// through the dispatcher view. Confirmed via psx_object_create_compound_record
// @ 0x80024eec (memory note psx-coordinate-decode-2026-04-18.md). The HIGH
// byte of the same u16 is a palette-override token, NOT a Z extension.
// The constructor-view u16 at +0x06 lives at rawWords[3] in this 12-u16
// expansion of the 24-byte dispatch-root record.
const zWord = rawWords[3] & 0xff;
const selectorWord = rawWords[6];
const laneWord = rawWords[7];
if (typeWord < 0x20 || typeWord > 0x1ff) {