Fix flippy DC6 offsets, missile data-driven sprites, automap cel extraction, and art loader fail-fast (Closes #609, #612, #619, #622, #623)

This commit is contained in:
troytt 2026-10-02 21:22:51 +00:00
parent 56f4f7f4fe
commit 6af87acc13
5 changed files with 123 additions and 128 deletions

View File

@ -505,13 +505,7 @@ export class AutomapView {
const originX = view.isoOriginX ?? (minCellX - minCellY) * 80
const originY = view.isoOriginY ?? (minCellX + minCellY) * 40
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
cellX: wm.cellX - minCellX,
cellY: wm.cellY - minCellY,
kind: wm.toAreaId === this.currentAreaId ? 'waypoint' : 'exit',
toLevelId: wm.toAreaId,
targetName: wm.label,
}))
const markers: MinimapMarker[] = []
if (Array.isArray(view.entrances)) {
for (const ent of view.entrances) {

View File

@ -9,6 +9,7 @@ import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monste
import { resolveMonsterArtSpec } from '../../common/units/monster-mapping.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
import { reportMissingArt } from './world-renderer.ts'
import { normalizeHeroToken } from '../../common/world/scene-population.ts'
export { ADDITIVE_MONSTER_TOKENS }
@ -305,8 +306,13 @@ export async function loadCharacterArt(
renderer: SpriteRenderer,
packBase?: string,
act?: number,
token = 'so',
token?: string,
): Promise<LoadedCharacter | null> {
if (!token || !token.trim()) {
throw new Error(
'[entity-art-loader] Missing character token in loadCharacterArt (fail-fast, no silent "so" fallback)',
)
}
const heroToken = normalizeHeroToken(token)
if (packBase !== undefined) {
const r8CandidatePrefixes = [
@ -391,6 +397,7 @@ export async function loadCharacterArt(
}
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
reportMissingArt('player', heroToken, `character art unavailable for ${heroToken} in pre-baked packs`)
return null
}
@ -445,7 +452,13 @@ export async function loadObjectArt(
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal',
blendMode:
meta.blendMode === 'additive' ||
indexEntry?.blendMode === 'additive' ||
indexEntry?.trans === 1 ||
indexEntry?.trans === 2
? 'additive'
: 'normal',
directions: Math.max(1, meta.directions || 1),
standOffset: meta.standOffset ?? 0,
standFrames: Math.max(1, meta.standFrames || 1),
@ -489,7 +502,13 @@ export async function loadObjectArt(
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal',
blendMode:
meta.blendMode === 'additive' ||
indexEntry?.blendMode === 'additive' ||
indexEntry?.trans === 1 ||
indexEntry?.trans === 2
? 'additive'
: 'normal',
directions: Math.max(1, meta.directions || 1),
standOffset: meta.standOffset ?? 0,
standFrames: Math.max(1, meta.standFrames || 1),
@ -506,6 +525,7 @@ export async function loadObjectArt(
}
}
reportMissingArt('object', cleanSlug, `Failed to load object art for slug=${cleanSlug}`)
return null
}
@ -660,6 +680,7 @@ export class EntityArtCache {
}
}
this.indexState = 'failed'
reportMissingArt('entity-index', 'entity-index.json', 'Failed to load entity catalog index')
this.onArtLoaded?.()
return null
})()
@ -740,12 +761,14 @@ export class EntityArtCache {
this.monsterByClassIdCache.set(classId, withScale)
} else {
this.failedMonsters.add(classId)
reportMissingArt('monster', specKey, `Failed to load monster art for specKey=${specKey}`)
}
this.onArtLoaded?.()
})
.catch(() => {
.catch(err => {
this.inflightMonsters.delete(classId)
this.failedMonsters.add(classId)
reportMissingArt('monster', specKey, String(err))
this.onArtLoaded?.()
})
@ -812,12 +835,14 @@ export class EntityArtCache {
this.objectByClassIdCache.set(cacheKey, merged)
} else {
this.failedObjects.add(cacheKey)
reportMissingArt('object', slug, `Failed to load object art for slug=${slug}`)
}
this.onArtLoaded?.()
})
.catch(() => {
.catch(err => {
this.inflightObjects.delete(cacheKey)
this.failedObjects.add(cacheKey)
reportMissingArt('object', slug, String(err))
this.onArtLoaded?.()
})
@ -828,8 +853,14 @@ export class EntityArtCache {
art: LoadedObjectArt,
entry: ObjectIndexEntry,
): LoadedObjectArt {
const isAdditive =
entry.blendMode === 'additive' ||
entry.trans === 1 ||
entry.trans === 2 ||
art.blendMode === 'additive'
return {
...art,
blendMode: isAdditive ? 'additive' : art.blendMode ?? 'normal',
...(entry.frameCnt !== undefined ? { frameCnt: entry.frameCnt } : {}),
...(entry.frameDelta !== undefined ? { frameDelta: entry.frameDelta } : {}),
...(entry.cycleAnim !== undefined ? { cycleAnim: entry.cycleAnim } : {}),

View File

@ -532,9 +532,6 @@ export class Minimap {
/** Loaded WebGL atlas handles keyed by sheet name (`MaxiMap`, `MaxiMapS`, etc.). */
private readonly atlases = new Map<AutomapSpriteKey, AtlasHandle>()
/** Cached synthesized `AutomapLayerData` for minimal test `MinimapLevel` objects lacking `level.automap`. */
private readonly synthesizedLayers = new Map<string, AutomapLayerData>()
/** level key -> one byte per cell, non-zero meaning explored. */
private readonly explored = new Map<string, Uint8Array>()
@ -988,7 +985,14 @@ export class Minimap {
this.panY,
)
const automapLayer = level.automap ?? this.getOrSynthesizeAutomapLayer(level)
const automapLayer = level.automap
if (!automapLayer) {
reportMissingArt(
'automap',
String(level.levelId ?? level.key),
'Missing authentic automap layer data (no synthetic fallback allowed)',
)
}
this.lastRenderStats.mode = this.mode
this.lastRenderStats.bMinimap = this.bMinimap
this.lastRenderStats.divisor = vp.divisor
@ -999,15 +1003,16 @@ export class Minimap {
this.lastRenderStats.panY = this.panY
this.lastRenderStats.viewport = vp
this.lastRenderStats.cellCounts = {
floors: automapLayer.floors.length,
walls: automapLayer.walls.length,
objects: automapLayer.objects.length,
extras: automapLayer.extras.length,
total:
automapLayer.floors.length +
automapLayer.walls.length +
automapLayer.objects.length +
automapLayer.extras.length,
floors: automapLayer?.floors.length ?? 0,
walls: automapLayer?.walls.length ?? 0,
objects: automapLayer?.objects.length ?? 0,
extras: automapLayer?.extras.length ?? 0,
total: automapLayer
? automapLayer.floors.length +
automapLayer.walls.length +
automapLayer.objects.length +
automapLayer.extras.length
: 0,
}
this.lastRenderStats.spritesDrawn = {
floors: 0,
@ -1133,14 +1138,16 @@ export class Minimap {
}
// Strict 1.13c 4-layer draw order (`0x6fb10489`):
// 1. Layer 0 (`floors`)
drawLayerCells(automapLayer.floors, 'floors', false)
// 2. Layer 1 (`walls`)
drawLayerCells(automapLayer.walls, 'walls', false)
// 3. Layer 2 (`objects`)
drawLayerCells(automapLayer.objects, 'objects', false)
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
drawLayerCells(automapLayer.extras, 'extras', true)
if (automapLayer) {
// 1. Layer 0 (`floors`)
drawLayerCells(automapLayer.floors, 'floors', false)
// 2. Layer 1 (`walls`)
drawLayerCells(automapLayer.walls, 'walls', false)
// 3. Layer 2 (`objects`)
drawLayerCells(automapLayer.objects, 'objects', false)
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
drawLayerCells(automapLayer.extras, 'extras', true)
}
// 5. Dynamic active Town Portals (`0x6fb11490`: `MaxiMap` / `MaxiMapS` frame 300 in Level 4, else 306/307)
const maxiSheetName = resolveAutomapSheetName(0, this.bMinimap)
@ -1496,95 +1503,6 @@ export class Minimap {
return this.atlases.get(sheetName) ?? null
}
/**
* Synthesizes a 4-layer `AutomapLayerData` from a minimal `MinimapLevel` (when `level.automap`
* is not explicitly passed, e.g. in standalone unit tests).
*/
private getOrSynthesizeAutomapLayer(level: MinimapLevel): AutomapLayerData {
const cached = this.synthesizedLayers.get(level.key)
if (cached) return cached
const floors: AutomapCell[] = []
const walls: AutomapCell[] = []
const objects: AutomapCell[] = []
const extras: AutomapCell[] = []
for (let y = 0; y < level.cellsY; y += 1) {
for (let x = 0; x < level.cellsX; x += 1) {
const coords = computeAutomapTilePixelCoords(x, y, 0)
if (this.cellBlocked(level, x, y)) {
walls.push({
cellNo: 9,
xPixel: coords.xPixel,
yPixel: coords.yPixel,
cellX: x,
cellY: y,
fSaved: 0,
weight: 0,
})
} else {
floors.push({
cellNo: 0,
xPixel: coords.xPixel,
yPixel: coords.yPixel,
cellX: x,
cellY: y,
fSaved: 0,
weight: 0,
})
}
}
}
for (const marker of level.markers) {
const subTileX = marker.cellX * 5 + 2
const subTileY = marker.cellY * 5 + 2
const coords = computeAutomapObjectPixelCoords(subTileX, subTileY)
if (marker.kind === 'waypoint') {
extras.push({
cellNo: 309,
xPixel: coords.xPixel,
yPixel: coords.yPixel,
cellX: marker.cellX,
cellY: marker.cellY,
fSaved: 0,
weight: 0,
})
} else {
objects.push({
cellNo: 306,
xPixel: coords.xPixel,
yPixel: coords.yPixel,
cellX: marker.cellX,
cellY: marker.cellY,
fSaved: 0,
weight: 0,
})
}
}
const layer: AutomapLayerData = {
layerId: level.levelId ?? 0,
levelTypeId: 1,
floors,
walls,
objects,
extras,
}
this.synthesizedLayers.set(level.key, layer)
return layer
}
/**
* Whether a cell is blocked in the sub-tile walkability grid.
*/
private cellBlocked(level: MinimapLevel, cellX: number, cellY: number): boolean {
const sx = cellX * 5 + 2
const sy = cellY * 5 + 2
const idx = sy * level.gridWidth + sx
return idx >= 0 && idx < level.blocked.length && level.blocked[idx] !== 0
}
/**
* The explored bytes for a level, created on first use.
*/

View File

@ -941,7 +941,56 @@ export function itemToUiInventoryItem(
: (rawItem as any).id ?? `item-${code}-${++itemBridgeSeqCounter}`,
)
const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile
let flippyFile: string | undefined = (rawItem as any).flippyFile ?? (rawItem as any).flippyfile
if (!flippyFile && quality === 'unique') {
flippyFile = rawItem.uniqueItemDef?.flippyfile
if (!flippyFile && dropTables?.uniques) {
const targetName = (rawItem.name ?? '').trim()
let match =
(targetName ? dropTables.uniques.get?.(targetName) : undefined) ??
(targetName
? dropTables.uniques.all?.find(
u => u.enabled && u.index.trim().toLowerCase() === targetName.toLowerCase(),
)
: undefined)
if (!match && code) {
const byCode = dropTables.uniques.getEnabledByCode?.(code.trim())
if (byCode && byCode.length === 1) {
match = byCode[0]
}
}
if (match?.flippyfile) {
flippyFile = match.flippyfile
}
}
} else if (!flippyFile && quality === 'set') {
flippyFile = rawItem.setItemDef?.flippyfile
if (!flippyFile && dropTables?.sets?.items) {
const targetName = (rawItem.name ?? '').trim()
let match =
(targetName ? dropTables.sets.items.getByIndex?.(targetName) : undefined) ??
(targetName
? dropTables.sets.items.entries?.find(
s => s.index.trim().toLowerCase() === targetName.toLowerCase(),
)
: undefined)
if (!match && code) {
const lowerCode = code.trim().toLowerCase()
const byCode = dropTables.sets.items.entries?.filter(
s => s.item?.trim().toLowerCase() === lowerCode,
)
if (byCode && byCode.length === 1) {
match = byCode[0]
}
}
if (match?.flippyfile) {
flippyFile = match.flippyfile
}
}
}
if (!flippyFile) {
flippyFile = (base as any).flippyfile
}
const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound
const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe
const permStore = Boolean(rawItem.permStore ?? base.permStoreItem)
@ -1451,7 +1500,10 @@ export function onlineItemDataToUiInventoryItem(
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
} {
const tables = dropTables ?? getEmbeddedDropTables()
const code = String(item.code ?? '').trim().toLowerCase() || 'rin'
const code = String(item.code ?? '').trim().toLowerCase()
if (!code) {
throw new Error('[item-bridge] Missing item code in onlineItemDataToUiInventoryItem (fail-fast, no silent fallback)')
}
const weaponRow = tables.weapons?.get(code)
const armorRow = !weaponRow ? tables.armor?.get(code) : undefined
@ -1759,7 +1811,7 @@ export function onlineItemDataToUiInventoryItem(
invfile: baseRow?.invfile ?? baseRow?.invFile,
uniqueinvfile: baseRow?.uniqueinvfile,
setinvfile: baseRow?.setinvfile,
flippyfile: baseRow?.flippyfile,
flippyfile: uniqueItemDef?.flippyfile ?? setItemDef?.flippyfile ?? baseRow?.flippyfile,
dropsound: baseRow?.dropsound,
dropsfxframe: baseRow?.dropsfxframe,
twoHanded: Boolean(baseRow?.twoHanded || baseRow?.twohanded === '1' || baseRow?.['2handed'] === 1),

View File

@ -594,7 +594,7 @@ describe('Tier 2 — Boundary & Corner Cases (F1–F13, >= 5 tests per feature)'
expect(minimap.spritesDrawnLastFrame.extras).toBe(1)
})
it('F8.B3: synthesizes a fallback 4-layer AutomapLayerData when level.automap is omitted', () => {
it('F8.B3: does not synthesize fake fallback layers when level.automap is omitted', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
@ -613,8 +613,8 @@ describe('Tier 2 — Boundary & Corner Cases (F1–F13, >= 5 tests per feature)'
}
minimap.reveal(level, 2, 2, 0, 160, true)
minimap.draw(createNoopRenderer(), level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
expect(minimap.lastRenderStats.cellCounts.total).toBe(100 + 2)
expect(minimap.spritesDrawnLastFrame.total).toBeGreaterThan(0)
expect(minimap.lastRenderStats.cellCounts.total).toBe(0)
expect(minimap.spritesDrawnLastFrame.total).toBe(0)
})
it('F8.B4: skips all rendering when minimap.visible === false while resetting frame draw stats to 0', () => {