map viewer
This commit is contained in:
parent
93bc6e7a07
commit
2b1f1a0191
15 changed files with 2355 additions and 40 deletions
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue