diff --git a/src/client/main.ts b/src/client/main.ts index dd1cbc6..e8ea44f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -28,6 +28,7 @@ import type { CharClassId } from '../netproto/index.ts' import { SpriteRenderer, type AtlasHandle } from './render/renderer.ts' import { DEFAULT_BASES, + EntityArtCache, loadCharacterArt, loadMonsterArtMap, type LoadedCharacter, @@ -42,6 +43,7 @@ import { import { TileAtlasGpuCache, renderSceneSourceFrame, + reportMissingArt, } from './scene/world-renderer.ts' import { BootProgressTracker, @@ -97,6 +99,7 @@ export interface PlayAppController { readonly frontendRenderer: FrontendRenderer readonly renderer: SpriteRenderer readonly tileGpuCache: TileAtlasGpuCache + readonly entityArtCache: EntityArtCache readonly lightGrid: LightGrid readonly minimap: Minimap readonly assetsReadyPromise: Promise @@ -188,6 +191,7 @@ async function bootPlayApp(): Promise { const renderer = new SpriteRenderer(viewCanvas) const lightGrid = new LightGrid() const tileGpuCache = new TileAtlasGpuCache(renderer) + const entityArtCache = new EntityArtCache(renderer, '/assets') const characterArtByToken = new Map() const monsterArtMap = new Map() @@ -239,12 +243,16 @@ async function bootPlayApp(): Promise { const dummyPalette = { rgb: new Uint8Array(768), size: 256 as const } const assetsReadyPromise = (async (): Promise => { await Promise.all([ - minimap.loadAtlases(renderer, 'samples/d2-packs').catch(() => {}), + minimap.loadAtlases(renderer, 'samples/d2-packs').catch((err) => { + reportMissingArt('automap', 'Automap.dc6', String(err)) + }), loadItemsAtlas(renderer, 'samples/d2-packs') .then((h) => { itemsAtlasHandle = h }) - .catch(() => {}), + .catch((err) => { + reportMissingArt('item', 'items-atlas.png', String(err)) + }), Promise.all( PRELOAD_HERO_TOKENS.map(async (token) => { try { @@ -259,9 +267,11 @@ async function bootPlayApp(): Promise { if (art) { characterArtByToken.set(token.toLowerCase(), art) characterArtByToken.set(token.toUpperCase(), art) + } else { + reportMissingArt('player', token, `Character art not found for token=${token}`) } - } catch { - // ignore individual token failure + } catch (err) { + reportMissingArt('player', token, String(err)) } }), ), @@ -279,17 +289,37 @@ async function bootPlayApp(): Promise { monsterArtMap.set(`token:${art.token.toLowerCase()}`, art) } }) - .catch(() => {}), + .catch((err) => { + reportMissingArt('monster', 'preload-monsters', String(err)) + }), + entityArtCache + .loadIndex() + .then(async () => { + for (const npcId of [146, 147, 148, 150, 154, 155]) { + entityArtCache.getMonsterArt(npcId, 1) + } + for (const objId of [17, 26, 39, 119, 267]) { + entityArtCache.getObjectArt(objId, 1) + } + await entityArtCache.waitForInflight() + }) + .catch((err) => { + reportMissingArt('entity-index', 'entity-index.json', String(err)) + }), loadMissileArtMap(renderer, 'samples/d2-packs') .then((map) => { missileArtMap = map }) - .catch(() => {}), + .catch((err) => { + reportMissingArt('missile', 'missiles-manifest.json', String(err)) + }), loadOverlayArtMap(renderer, 'samples/d2-packs') .then((map) => { overlayArtMap = map }) - .catch(() => {}), + .catch((err) => { + reportMissingArt('overlay', 'overlays-manifest.json', String(err)) + }), ]) })() @@ -852,6 +882,7 @@ async function bootPlayApp(): Promise { lightingTargets: { lightGrid, renderer }, renderer, tileGpuCache, + entityArtCache, characterArtByToken, monsterArtMap, missileArtMap, @@ -888,6 +919,7 @@ async function bootPlayApp(): Promise { frontendRenderer, renderer, tileGpuCache, + entityArtCache, lightGrid, minimap, assetsReadyPromise, diff --git a/src/client/render/monster-art.ts b/src/client/render/monster-art.ts index 8f24375..f901343 100644 --- a/src/client/render/monster-art.ts +++ b/src/client/render/monster-art.ts @@ -6,7 +6,7 @@ import type { SpriteGroup, SpriteSheet } from '../../common/formats/sprite.ts' import { ADDITIVE_MONSTER_TOKENS, MONSTER_COMPONENTS, - compositeMonsterAnimation, + compositeMonsterAnimation as compositeCommonMonsterAnimation, findMonsterLayerSprite, type MonsterAnimationSheet, type MonsterLayerFailure, @@ -19,12 +19,26 @@ import type { AnimClipMeta } from './actor-animator.ts' export { ADDITIVE_MONSTER_TOKENS, MONSTER_COMPONENTS, - compositeMonsterAnimation, findMonsterLayerSprite, type MonsterAnimationSheet, type MonsterLayerFailure, } +export async function compositeMonsterAnimation( + archives: MountedArchives, + token: string, + animation: string, + weapon = 'hth', +): Promise { + const res = await compositeCommonMonsterAnimation(archives, token, animation, weapon) + for (const f of res.layerFailures) { + console.error( + `[monster-art] ${f.token}/${f.animation} dropped layer ${f.component} (${f.reason})${f.member ? `: ${f.member}` : ''}${f.message ? ` — ${f.message}` : ''}`, + ) + } + return res +} + export interface LoadedMonsterArt { readonly token: string readonly handle: AtlasHandle @@ -37,6 +51,8 @@ export interface LoadedMonsterArt { readonly walkFrames: number readonly standFrames: number readonly clips?: Readonly> + readonly glowClips?: Readonly> + readonly scale?: number readonly layerFailures: readonly MonsterLayerFailure[] } diff --git a/src/client/scene/entity-art-loader.ts b/src/client/scene/entity-art-loader.ts index c02ff8d..faa479c 100644 --- a/src/client/scene/entity-art-loader.ts +++ b/src/client/scene/entity-art-loader.ts @@ -1,4 +1,9 @@ import type { Palette } from '../../common/formats/pal.ts' +import type { + MonsterIndexEntry, + ObjectIndexEntry, + PackedEntityCatalogIndex, +} from '../../common/pack-contract/index.ts' import type { AnimClipMeta } from '../render/actor-animator.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { resolveMonsterArtSpec } from '../../common/units/monster-mapping.ts' @@ -7,10 +12,34 @@ import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' import { normalizeHeroToken } from '../../common/world/scene-population.ts' export { ADDITIVE_MONSTER_TOKENS } +export type { MonsterIndexEntry, ObjectIndexEntry, PackedEntityCatalogIndex } /** Base locations tried in order when `?base=` is absent. */ export const DEFAULT_BASES = ['samples/d2', '/diablo2/data'] +export type ObjectClipArt = AnimClipMeta + +/** A composited map/interactive object uploaded as an indexed R8 atlas page. */ +export interface LoadedObjectArt { + readonly token: string + readonly slug: string + readonly handle: AtlasHandle + readonly groups: readonly (readonly AtlasFrame[])[] + readonly glowGroups?: readonly (readonly AtlasFrame[])[] + readonly blendMode: 'normal' | 'additive' + readonly directions: number + readonly standOffset: number + readonly standFrames: number + readonly clips: Readonly> + readonly glowClips?: Readonly> + readonly frameCnt?: readonly number[] + readonly frameDelta?: readonly number[] + readonly cycleAnim?: readonly boolean[] + readonly trans?: number +} + +export type ObjectArtEntry = LoadedObjectArt + /** A composited character, uploaded as its own atlas page. */ export interface LoadedCharacter { readonly handle: AtlasHandle @@ -26,21 +55,25 @@ export interface LoadedCharacter { export interface PackedMonsterJson { readonly schema?: number readonly token: string - readonly weapon: string + readonly weapon?: string readonly file: string readonly width: number readonly height: number readonly blendMode?: 'normal' | 'additive' | undefined readonly directions: number - readonly walkOffset: number + readonly walkOffset?: number readonly standOffset: number - readonly walkFrames: number + readonly walkFrames?: number readonly standFrames: number readonly clips?: Readonly> + readonly modes?: Readonly> + readonly glowClips?: Readonly> readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][] readonly glowGroups?: readonly (readonly [number, number, number, number, number?, number?])[][] | undefined } +export type PackedObjectJson = PackedMonsterJson + export interface PackedCharJson { readonly schema?: number readonly file: string @@ -120,54 +153,63 @@ export async function loadMonsterArtMap( const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = [] if (packBase !== undefined) { - const globalEntitiesUrl = `${packBase}/entities` + const candidateEntityPrefixes = [ + `${packBase}/entities`, + '/assets/entities', + '/entities', + ].filter((p, idx, arr) => arr.indexOf(p) === idx) const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined await Promise.all( [...tokenSpecs.entries()].map(async ([key, spec]) => { const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` - try { - const [jsonResp, r8Resp] = await Promise.all([ - fetch(`${globalEntitiesUrl}/${slug}.json`), - fetch(`${globalEntitiesUrl}/${slug}.r8`), - ]) - if (jsonResp.ok && r8Resp.ok) { - const meta = (await jsonResp.json()) as PackedMonsterJson - const indices = new Uint8Array(await r8Resp.arrayBuffer()) - const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) - const groups: AtlasFrame[][] = meta.groups.map(group => - group.map(([x, y, width, height, anchorX, anchorY]) => ({ - x, y, width, height, - ...(anchorX !== undefined ? { anchorX } : {}), - ...(anchorY !== undefined ? { anchorY } : {}), - })), - ) - const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined - ? meta.glowGroups.map(group => - group.map(([x, y, width, height, anchorX, anchorY]) => ({ - x, y, width, height, - ...(anchorX !== undefined ? { anchorX } : {}), - ...(anchorY !== undefined ? { anchorY } : {}), - })), - ) - : undefined - loadedBySpecKey.set(key, { - token: spec.token, - handle, - groups, - ...(glowGroups !== undefined ? { glowGroups } : {}), - ...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}), - directions: meta.directions, - walkOffset: meta.walkOffset, - standOffset: meta.standOffset, - walkFrames: meta.walkFrames, - standFrames: meta.standFrames, - ...(meta.clips !== undefined ? { clips: meta.clips } : {}), - layerFailures: [], - }) - return + for (const globalEntitiesUrl of candidateEntityPrefixes) { + try { + const [jsonResp, r8Resp] = await Promise.all([ + fetch(`${globalEntitiesUrl}/${slug}.json`), + fetch(`${globalEntitiesUrl}/${slug}.r8`), + ]) + if (jsonResp.ok && r8Resp.ok) { + const meta = (await jsonResp.json()) as PackedMonsterJson + const indices = new Uint8Array(await r8Resp.arrayBuffer()) + const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) + const groups: AtlasFrame[][] = meta.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })), + ) + const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined + ? meta.glowGroups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })), + ) + : undefined + loadedBySpecKey.set(key, { + token: spec.token, + handle, + groups, + ...(glowGroups !== undefined ? { glowGroups } : {}), + ...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}), + directions: meta.directions, + walkOffset: meta.walkOffset ?? meta.standOffset, + standOffset: meta.standOffset, + walkFrames: meta.walkFrames ?? meta.standFrames, + standFrames: meta.standFrames, + ...(meta.clips !== undefined || meta.modes !== undefined + ? { clips: meta.clips ?? meta.modes } + : {}), + ...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}), + layerFailures: [], + }) + return + } + } catch (err) { + console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`) } - } catch (err) { - console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`) } if (actEntitiesUrl !== undefined) { @@ -208,11 +250,14 @@ export async function loadMonsterArtMap( ...(glowGroups !== undefined ? { glowGroups } : {}), ...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}), directions: meta.directions, - walkOffset: meta.walkOffset, + walkOffset: meta.walkOffset ?? meta.standOffset, standOffset: meta.standOffset, - walkFrames: meta.walkFrames, + walkFrames: meta.walkFrames ?? meta.standFrames, standFrames: meta.standFrames, - ...(meta.clips !== undefined ? { clips: meta.clips } : {}), + ...(meta.clips !== undefined || meta.modes !== undefined + ? { clips: meta.clips ?? meta.modes } + : {}), + ...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}), layerFailures: [], }) return @@ -264,34 +309,46 @@ export async function loadCharacterArt( ): Promise { const heroToken = normalizeHeroToken(token) if (packBase !== undefined) { - try { - const [jsonResp, r8Resp] = await Promise.all([ - fetch(`${packBase}/entities/char-${heroToken}.json`), - fetch(`${packBase}/entities/char-${heroToken}.r8`), - ]) - if (jsonResp.ok && r8Resp.ok) { - const meta = (await jsonResp.json()) as PackedCharJson - const indices = new Uint8Array(await r8Resp.arrayBuffer()) - const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) - const groups: AtlasFrame[][] = meta.groups.map(group => - group.map(([x, y, width, height, anchorX, anchorY]) => ({ - x, y, width, height, - ...(anchorX !== undefined ? { anchorX } : {}), - ...(anchorY !== undefined ? { anchorY } : {}), - })), - ) - return { - handle, - groups, - directions: meta.directions, - walk: meta.walk, - stand: meta.stand, - members: meta.members, - ...(meta.clips !== undefined ? { clips: meta.clips } : {}), + const r8CandidatePrefixes = [ + `${packBase}/characters`, + `${packBase}/entities`, + '/assets/characters', + '/characters', + '/assets/entities', + ] + const seenPrefixes = new Set() + for (const prefix of r8CandidatePrefixes) { + if (seenPrefixes.has(prefix)) continue + seenPrefixes.add(prefix) + try { + const [jsonResp, r8Resp] = await Promise.all([ + fetch(`${prefix}/char-${heroToken}.json`), + fetch(`${prefix}/char-${heroToken}.r8`), + ]) + if (jsonResp.ok && r8Resp.ok) { + const meta = (await jsonResp.json()) as PackedCharJson + const indices = new Uint8Array(await r8Resp.arrayBuffer()) + const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) + const groups: AtlasFrame[][] = meta.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })), + ) + return { + handle, + groups, + directions: meta.directions, + walk: meta.walk, + stand: meta.stand, + members: meta.members, + ...(meta.clips !== undefined ? { clips: meta.clips } : {}), + } } + } catch { + // try next candidate prefix } - } catch { - // fall through to per-act PNG fallback } if (act !== undefined) { @@ -333,10 +390,541 @@ export async function loadCharacterArt( } } - if (heroToken !== 'so') { - return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so') - } - console.warn(`character art unavailable for ${heroToken} in pre-baked packs`) return null } + +function mapRawGroupsToAtlasFrames( + rawGroups: readonly (readonly [number, number, number, number, number?, number?])[][], +): AtlasFrame[][] { + return rawGroups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, + y, + width, + height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })), + ) +} + +export async function loadObjectArt( + renderer: SpriteRenderer, + slugOrToken: string, + packBase = 'samples/d2-packs', + act?: number, + indexEntry?: ObjectIndexEntry, + fetchFn: typeof fetch = fetch, +): Promise { + const cleanSlug = slugOrToken.trim().toLowerCase().replace(/^object-/, '') + if (!cleanSlug) return null + + const candidateBases = [packBase, '/assets', ''].filter( + (b, idx, arr) => arr.indexOf(b) === idx, + ) + for (const base of candidateBases) { + const prefix = base ? `${base.replace(/\/$/, '')}/entities` : '/entities' + for (const subPath of [`${prefix}/object-${cleanSlug}`, `${prefix}/objects/object-${cleanSlug}`]) { + try { + const [jsonResp, r8Resp] = await Promise.all([ + fetchFn(`${subPath}.json`), + fetchFn(`${subPath}.r8`), + ]) + if (jsonResp.ok && r8Resp.ok) { + const meta = (await jsonResp.json()) as PackedObjectJson + const indices = new Uint8Array(await r8Resp.arrayBuffer()) + const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height) + const groups = mapRawGroupsToAtlasFrames(meta.groups) + const glowGroups = + meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined + const clips = meta.clips ?? meta.modes ?? {} + return { + token: meta.token || cleanSlug.toUpperCase(), + slug: cleanSlug, + handle, + groups, + ...(glowGroups !== undefined ? { glowGroups } : {}), + blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal', + directions: Math.max(1, meta.directions || 1), + standOffset: meta.standOffset ?? 0, + standFrames: Math.max(1, meta.standFrames || 1), + clips, + ...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}), + ...(indexEntry?.frameCnt !== undefined ? { frameCnt: indexEntry.frameCnt } : {}), + ...(indexEntry?.frameDelta !== undefined ? { frameDelta: indexEntry.frameDelta } : {}), + ...(indexEntry?.cycleAnim !== undefined ? { cycleAnim: indexEntry.cycleAnim } : {}), + ...(indexEntry?.trans !== undefined ? { trans: indexEntry.trans } : {}), + } + } + } catch { + // try next candidate + } + } + } + + if (act !== undefined && packBase) { + const actEntitiesUrl = `${packBase.replace(/\/$/, '')}/act${String(act)}/entities` + try { + const [jsonResp, pngResp] = await Promise.all([ + fetchFn(`${actEntitiesUrl}/object-${cleanSlug}.json`), + fetchFn(`${actEntitiesUrl}/object-${cleanSlug}.png`), + ]) + if (jsonResp.ok && pngResp.ok) { + const meta = (await jsonResp.json()) as PackedObjectJson + const bitmap = await createImageBitmap(await pngResp.blob()) + let handle: AtlasHandle + try { + handle = renderer.addAtlas(bitmap, meta.width, meta.height) + } finally { + bitmap.close() + } + const groups = mapRawGroupsToAtlasFrames(meta.groups) + const glowGroups = + meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined + const clips = meta.clips ?? meta.modes ?? {} + return { + token: meta.token || cleanSlug.toUpperCase(), + slug: cleanSlug, + handle, + groups, + ...(glowGroups !== undefined ? { glowGroups } : {}), + blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal', + directions: Math.max(1, meta.directions || 1), + standOffset: meta.standOffset ?? 0, + standFrames: Math.max(1, meta.standFrames || 1), + clips, + ...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}), + ...(indexEntry?.frameCnt !== undefined ? { frameCnt: indexEntry.frameCnt } : {}), + ...(indexEntry?.frameDelta !== undefined ? { frameDelta: indexEntry.frameDelta } : {}), + ...(indexEntry?.cycleAnim !== undefined ? { cycleAnim: indexEntry.cycleAnim } : {}), + ...(indexEntry?.trans !== undefined ? { trans: indexEntry.trans } : {}), + } + } + } catch { + // fall through + } + } + + return null +} + +export async function loadObjectArtMap( + objectSlugs: readonly string[], + renderer: SpriteRenderer, + packBase = 'samples/d2-packs', + act?: number, + fetchFn: typeof fetch = fetch, +): Promise> { + const map = new Map() + await Promise.all( + objectSlugs.map(async slug => { + const art = await loadObjectArt(renderer, slug, packBase, act, undefined, fetchFn) + if (art) { + map.set(slug.toLowerCase(), art) + map.set(art.slug, art) + map.set(art.token.toLowerCase(), art) + } + }), + ) + return map +} + +export interface EntityArtCacheOptions { + readonly renderer: SpriteRenderer + readonly packBase?: string | undefined + readonly act?: number | undefined + readonly onArtLoaded?: (() => void) | undefined + readonly fetchFn?: typeof fetch | undefined + readonly initialCatalog?: PackedEntityCatalogIndex | undefined +} + +/** + * On-demand `classId`-indexed entity art cache for Monsters/Town NPCs (`0xAC` `hcIdx`) + * and Map/Interactive Objects (`0x51` `Objects.txt` `classId`). + * + * Returns `'loading'` while asynchronous fetches are in flight so the renderer skips drawing + * silently on that frame without triggering `reportMissingArt`, and returns `'missing'` when + * an entity is absent from the catalog or fails to decode. + */ +export class EntityArtCache { + private readonly renderer: SpriteRenderer + private readonly packBase: string + private defaultAct: number | undefined + private readonly onArtLoaded: (() => void) | undefined + private readonly fetchFn: typeof fetch + + private catalog: PackedEntityCatalogIndex | null = null + private indexState: 'idle' | 'loading' | 'loaded' | 'failed' = 'idle' + private indexPromise: Promise | null = null + + private readonly monsterByClassIdCache = new Map() + private readonly monsterBySpecKeyCache = new Map() + private readonly monsterInflightBySpecKey = new Map>() + private readonly inflightMonsters = new Set() + private readonly failedMonsters = new Set() + + private readonly objectByClassIdCache = new Map() + private readonly objectBySlugCache = new Map() + private readonly objectInflightBySlug = new Map>() + private readonly inflightObjects = new Set() + private readonly failedObjects = new Set() + + constructor( + rendererOrOptions: SpriteRenderer | EntityArtCacheOptions, + packBase = 'samples/d2-packs', + onArtLoaded?: () => void, + ) { + if ('addIndexedAtlas' in rendererOrOptions) { + this.renderer = rendererOrOptions + this.packBase = packBase + this.onArtLoaded = onArtLoaded + this.fetchFn = typeof fetch !== 'undefined' ? fetch.bind(globalThis) : (() => Promise.reject(new Error('fetch unavailable'))) as unknown as typeof fetch + } else { + this.renderer = rendererOrOptions.renderer + this.packBase = rendererOrOptions.packBase ?? 'samples/d2-packs' + this.defaultAct = rendererOrOptions.act + this.onArtLoaded = rendererOrOptions.onArtLoaded + this.fetchFn = + rendererOrOptions.fetchFn ?? + (typeof fetch !== 'undefined' + ? fetch.bind(globalThis) + : ((() => Promise.reject(new Error('fetch unavailable'))) as unknown as typeof fetch)) + if (rendererOrOptions.initialCatalog) { + this.catalog = rendererOrOptions.initialCatalog + this.indexState = 'loaded' + } + } + } + + get isIndexLoaded(): boolean { + return this.indexState === 'loaded' && this.catalog !== null + } + + getCatalog(): PackedEntityCatalogIndex | null { + return this.catalog + } + + setCatalog(catalog: PackedEntityCatalogIndex): void { + this.catalog = catalog + this.indexState = 'loaded' + } + + setMonsterArt(classId: number, art: LoadedMonsterArt): void { + this.monsterByClassIdCache.set(classId, art) + this.failedMonsters.delete(classId) + this.inflightMonsters.delete(classId) + } + + setObjectArt(classId: number, art: LoadedObjectArt, act?: number): void { + this.objectByClassIdCache.set(`${classId}:${act ?? 0}`, art) + this.objectByClassIdCache.set(`${classId}:0`, art) + this.failedObjects.delete(`${classId}:${act ?? 0}`) + this.failedObjects.delete(`${classId}:0`) + } + + async loadIndex(indexData?: PackedEntityCatalogIndex): Promise { + if (indexData) { + this.catalog = indexData + this.indexState = 'loaded' + return indexData + } + if (this.indexState === 'loaded' && this.catalog) { + return this.catalog + } + if (this.indexPromise) { + return this.indexPromise + } + + this.indexState = 'loading' + this.indexPromise = (async (): Promise => { + const candidateBases = [this.packBase, '/assets', ''].filter( + (b, idx, arr) => arr.indexOf(b) === idx, + ) + for (const base of candidateBases) { + const prefix = base ? `${base.replace(/\/$/, '')}/entities` : '/entities' + for (const fileName of ['entity-index.json', 'index.json']) { + try { + const resp = await this.fetchFn(`${prefix}/${fileName}`) + if (!resp.ok) continue + const parsed = (await resp.json()) as PackedEntityCatalogIndex + if (parsed && (parsed.monsterByClassId || parsed.objectByClassId || parsed.entities)) { + this.catalog = parsed + this.indexState = 'loaded' + this.onArtLoaded?.() + return parsed + } + } catch { + // try next candidate URL + } + } + } + this.indexState = 'failed' + this.onArtLoaded?.() + return null + })() + + return this.indexPromise + } + + async waitForInflight(): Promise { + if (this.indexPromise) { + await this.indexPromise + } + await Promise.all([ + ...this.monsterInflightBySpecKey.values(), + ...this.objectInflightBySlug.values(), + ]) + } + + getMonsterIndexEntry(classId: number): MonsterIndexEntry | undefined { + if (!this.catalog) return undefined + const key = String(classId) + return this.catalog.monsterByClassId?.[key] ?? this.catalog.monstersByHcIdx?.[key] + } + + getObjectIndexEntry(classId: number): ObjectIndexEntry | undefined { + if (!this.catalog) return undefined + const key = String(classId) + return this.catalog.objectByClassId?.[key] ?? this.catalog.objectsByClassId?.[key] + } + + getMonsterArt(classId: number, act = this.defaultAct): LoadedMonsterArt | 'loading' | 'missing' { + const cached = this.monsterByClassIdCache.get(classId) + if (cached !== undefined) return cached + if (this.failedMonsters.has(classId)) return 'missing' + if (this.inflightMonsters.has(classId)) return 'loading' + + if (this.indexState === 'idle') { + void this.loadIndex() + return 'loading' + } + if (this.indexState === 'loading') { + return 'loading' + } + if (this.indexState === 'failed' || !this.catalog) { + return 'missing' + } + + const entry = this.getMonsterIndexEntry(classId) + const specKey = entry?.key ?? entry?.slug ?? null + if (!entry || !specKey) { + this.failedMonsters.add(classId) + return 'missing' + } + + const existingSpec = this.monsterBySpecKeyCache.get(specKey) + if (existingSpec) { + const withScale: LoadedMonsterArt = + entry.scale !== undefined && entry.scale !== existingSpec.scale + ? { ...existingSpec, scale: entry.scale } + : existingSpec + this.monsterByClassIdCache.set(classId, withScale) + return withScale + } + + this.inflightMonsters.add(classId) + let specPromise = this.monsterInflightBySpecKey.get(specKey) + if (!specPromise) { + specPromise = this.fetchMonsterBySpecKey(specKey, entry.token, act) + this.monsterInflightBySpecKey.set(specKey, specPromise) + } + + void specPromise + .then(loaded => { + this.inflightMonsters.delete(classId) + if (loaded) { + this.monsterBySpecKeyCache.set(specKey, loaded) + const withScale: LoadedMonsterArt = + entry.scale !== undefined ? { ...loaded, scale: entry.scale } : loaded + this.monsterByClassIdCache.set(classId, withScale) + } else { + this.failedMonsters.add(classId) + } + this.onArtLoaded?.() + }) + .catch(() => { + this.inflightMonsters.delete(classId) + this.failedMonsters.add(classId) + this.onArtLoaded?.() + }) + + return 'loading' + } + + getObjectArt( + classId: number, + act = this.defaultAct, + ): LoadedObjectArt | 'loading' | 'missing' | 'non-drawable' { + const cacheKey = `${classId}:${act ?? 0}` + const cached = + this.objectByClassIdCache.get(cacheKey) ?? this.objectByClassIdCache.get(`${classId}:0`) + if (cached !== undefined) return cached + if (this.failedObjects.has(cacheKey)) return 'missing' + if (this.inflightObjects.has(cacheKey)) return 'loading' + + if (this.indexState === 'idle') { + void this.loadIndex() + return 'loading' + } + if (this.indexState === 'loading') { + return 'loading' + } + if (this.indexState === 'failed' || !this.catalog) { + return 'missing' + } + + const entry = this.getObjectIndexEntry(classId) + if (!entry) { + this.failedObjects.add(cacheKey) + return 'missing' + } + if (entry.draw === false) { + return 'non-drawable' + } + + const slug = (act !== undefined ? entry.actSlugs?.[act] : undefined) ?? entry.slug + if (!slug) { + this.failedObjects.add(cacheKey) + return 'missing' + } + + const existingSlug = this.objectBySlugCache.get(slug) + if (existingSlug) { + const merged = this.mergeObjectIndexMetadata(existingSlug, entry) + this.objectByClassIdCache.set(cacheKey, merged) + return merged + } + + this.inflightObjects.add(cacheKey) + let slugPromise = this.objectInflightBySlug.get(slug) + if (!slugPromise) { + slugPromise = loadObjectArt(this.renderer, slug, this.packBase, act, entry, this.fetchFn) + this.objectInflightBySlug.set(slug, slugPromise) + } + + void slugPromise + .then(loaded => { + this.inflightObjects.delete(cacheKey) + if (loaded) { + this.objectBySlugCache.set(slug, loaded) + const merged = this.mergeObjectIndexMetadata(loaded, entry) + this.objectByClassIdCache.set(cacheKey, merged) + } else { + this.failedObjects.add(cacheKey) + } + this.onArtLoaded?.() + }) + .catch(() => { + this.inflightObjects.delete(cacheKey) + this.failedObjects.add(cacheKey) + this.onArtLoaded?.() + }) + + return 'loading' + } + + private mergeObjectIndexMetadata( + art: LoadedObjectArt, + entry: ObjectIndexEntry, + ): LoadedObjectArt { + return { + ...art, + ...(entry.frameCnt !== undefined ? { frameCnt: entry.frameCnt } : {}), + ...(entry.frameDelta !== undefined ? { frameDelta: entry.frameDelta } : {}), + ...(entry.cycleAnim !== undefined ? { cycleAnim: entry.cycleAnim } : {}), + ...(entry.trans !== undefined ? { trans: entry.trans } : {}), + } + } + + private async fetchMonsterBySpecKey( + specKey: string, + token: string, + act?: number, + ): Promise { + const slug = specKey.startsWith('monster-') ? specKey : `monster-${specKey}` + const candidateBases = [this.packBase, '/assets', ''].filter( + (b, idx, arr) => arr.indexOf(b) === idx, + ) + for (const base of candidateBases) { + const prefix = base ? `${base.replace(/\/$/, '')}/entities` : '/entities' + for (const subPath of [`${prefix}/${slug}`, `${prefix}/monsters/${slug}`]) { + try { + const [jsonResp, r8Resp] = await Promise.all([ + this.fetchFn(`${subPath}.json`), + this.fetchFn(`${subPath}.r8`), + ]) + if (jsonResp.ok && r8Resp.ok) { + const meta = (await jsonResp.json()) as PackedMonsterJson + const indices = new Uint8Array(await r8Resp.arrayBuffer()) + const handle = this.renderer.addIndexedAtlas(indices, meta.width, meta.height) + const groups = mapRawGroupsToAtlasFrames(meta.groups) + const glowGroups = + meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined + const clips = meta.clips ?? meta.modes + return { + token: meta.token || token, + handle, + groups, + ...(glowGroups !== undefined ? { glowGroups } : {}), + ...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}), + directions: Math.max(1, meta.directions || 1), + walkOffset: meta.walkOffset ?? meta.standOffset ?? 0, + standOffset: meta.standOffset ?? 0, + walkFrames: Math.max(1, meta.walkFrames ?? meta.standFrames ?? 1), + standFrames: Math.max(1, meta.standFrames ?? 1), + ...(clips !== undefined ? { clips } : {}), + ...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}), + layerFailures: [], + } + } + } catch { + // try next candidate + } + } + } + + if (act !== undefined && this.packBase) { + const actEntitiesUrl = `${this.packBase.replace(/\/$/, '')}/act${String(act)}/entities` + try { + const [jsonResp, pngResp] = await Promise.all([ + this.fetchFn(`${actEntitiesUrl}/${slug}.json`), + this.fetchFn(`${actEntitiesUrl}/${slug}.png`), + ]) + if (jsonResp.ok && pngResp.ok) { + const meta = (await jsonResp.json()) as PackedMonsterJson + const bitmap = await createImageBitmap(await pngResp.blob()) + let handle: AtlasHandle + try { + handle = this.renderer.addAtlas(bitmap, meta.width, meta.height) + } finally { + bitmap.close() + } + const groups = mapRawGroupsToAtlasFrames(meta.groups) + const glowGroups = + meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined + const clips = meta.clips ?? meta.modes + return { + token: meta.token || token, + handle, + groups, + ...(glowGroups !== undefined ? { glowGroups } : {}), + ...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}), + directions: Math.max(1, meta.directions || 1), + walkOffset: meta.walkOffset ?? meta.standOffset ?? 0, + standOffset: meta.standOffset ?? 0, + walkFrames: Math.max(1, meta.walkFrames ?? meta.standFrames ?? 1), + standFrames: Math.max(1, meta.standFrames ?? 1), + ...(clips !== undefined ? { clips } : {}), + ...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}), + layerFailures: [], + } + } + } catch { + // fall through + } + } + + return null + } +} diff --git a/src/client/scene/missile-overlay-renderer.ts b/src/client/scene/missile-overlay-renderer.ts index c0d2815..1455b58 100644 --- a/src/client/scene/missile-overlay-renderer.ts +++ b/src/client/scene/missile-overlay-renderer.ts @@ -23,6 +23,7 @@ import type { AtlasHandle, SpriteRenderer, } from '../render/renderer.ts' +import { reportMissingArt } from './world-renderer.ts' export { LightningTrailManager, @@ -451,21 +452,11 @@ export function drawCastOverlay( } } - // 2. Fallback procedural representation in test/offline environments - const [r255, g255, b255] = overlay.lightColor - const r = r255 / 255 - const g = g255 / 255 - const b = b255 / 255 - const fallbackRadius = 16 - renderer.drawSolid( - overlay.x - fallbackRadius, - overlay.y - fallbackRadius, - fallbackRadius * 2, - fallbackRadius * 2, - [r, g, b, 0.6], - { blendMode: 'additive' }, + reportMissingArt( + 'overlay', + overlay.overlayName || 'cast_overlay', + `Missing cast overlay art for ${overlay.overlayName || 'cast_overlay'}`, ) - renderer.setBlendMode('normal') } /** @@ -473,7 +464,6 @@ export function drawCastOverlay( * - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total), * then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`). * - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending. - * - Falls back to a procedural blue portal vortex oval in unit test / offline environments. */ export function drawTownPortal( renderer: SpriteRenderer, @@ -521,7 +511,10 @@ export function drawTownPortal( } } if (!drewSprite) { - renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { blendMode: 'additive' }) - renderer.setBlendMode('normal') + reportMissingArt( + 'overlay', + isOpening ? 'town_portal_op' : 'town_portal_on', + 'Missing town portal sprite atlas in overlayArtMap', + ) } } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 19d0bed..4c32eb6 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -6,7 +6,11 @@ import type { AutomapEntityMarker, Minimap, MinimapLevel } from '../ui/minimap.t import type { HudManager } from '../ui/hud-manager.ts' import { getTblLang } from '../i18n/lang.ts' import { isTownLevel } from '../../common/world/scene-population.ts' -import type { LoadedCharacter } from './entity-art-loader.ts' +import type { + EntityArtCache, + LoadedCharacter, + LoadedObjectArt, +} from './entity-art-loader.ts' import type { LightingPreset } from './lighting-presets.ts' import { drawTownPortal, @@ -16,9 +20,6 @@ import { /** Player feet footprint height (scene px), used to anchor sprite baselines. */ const FEET_HEIGHT = 10 -/** Placeholder marker size (scene px) drawn when a unit has no loaded art. */ -const MARKER_WIDTH = 16 -const MARKER_HEIGHT = 26 import type { AutomapView } from '../automap/automap-view.ts' import type { @@ -34,13 +35,75 @@ import type { } from '../view/unit-drawables.ts' import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts' import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts' -import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' +import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.ts' import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' import { applyLightingInputs, type LightingPassTargets, } from './lighting-presets.ts' +/** + * Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile + * is missing its authentic art asset at render time (Issue #540 R3). + */ +export interface MissingArtEntry { + readonly category: string + readonly id: string + readonly detail?: string | undefined + readonly timestamp: number +} + +declare global { + interface Window { + __d2MissingArt?: MissingArtEntry[] + } +} + +const missingArtRegistry = new Map() +const missingArtList: MissingArtEntry[] = [] +const dropStartMsByItemId = new Map() + +if (typeof window !== 'undefined' && !window.__d2MissingArt) { + window.__d2MissingArt = missingArtList +} + +/** + * Reports a missing art asset once per `(category, id)` pair, logs via `console.error`, + * and records it in `window.__d2MissingArt` for automated browser verification. + * Never throws synchronous exceptions inside the RAF loop. + */ +export function reportMissingArt(category: string, id: string, detail?: string): void { + const normCategory = category.trim() || 'unknown' + const normId = id.trim() || 'unknown' + const key = `${normCategory}:${normId}` + if (missingArtRegistry.has(key)) return + const entry: MissingArtEntry = { + category: normCategory, + id: normId, + ...(detail !== undefined ? { detail } : {}), + timestamp: Date.now(), + } + missingArtRegistry.set(key, entry) + missingArtList.push(entry) + if (typeof window !== 'undefined') { + window.__d2MissingArt = missingArtList + } + console.error(`[D2MissingArt] ${normCategory}:${normId}${detail ? ` — ${detail}` : ''}`) +} + +export function getMissingArtEntries(): readonly MissingArtEntry[] { + return missingArtList +} + +export function clearMissingArtEntries(): void { + missingArtRegistry.clear() + missingArtList.length = 0 + dropStartMsByItemId.clear() + if (typeof window !== 'undefined') { + window.__d2MissingArt = missingArtList + } +} + const COMMON_HCIDX_TO_MONSTER_ID: Readonly> = { 0: 'skeleton1', 1: 'returned1', @@ -109,6 +172,51 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly> = { 520: 'cain5', } +const PLAYER_MODE_TO_CLIP: Readonly> = { + 0: 'dt', + 1: 'nu', + 2: 'wl', + 3: 'rn', + 4: 'gh', + 5: 'tn', + 6: 'tw', + 7: 'a1', + 8: 'a2', + 9: 'bl', + 10: 'sc', + 17: 'dd', +} + +const MONSTER_MODE_TO_CLIP: Readonly> = { + 0: 'dt', + 1: 'nu', + 2: 'wl', + 3: 'gh', + 4: 'a1', + 5: 'a2', + 6: 'bl', + 7: 'sc', + 8: 's1', + 9: 's2', + 10: 's3', + 11: 's4', + 12: 'dd', + 13: 'rn', + 14: 'xx', + 15: 'rn', +} + +const OBJECT_MODE_TO_CLIP: Readonly> = { + 0: 'nu', + 1: 'op', + 2: 'on', + 3: 's1', + 4: 's2', + 5: 's3', + 6: 's4', + 7: 's5', +} + /** * Asynchronous GPU cache uploading packed Act 1..5 R8 tile pages and 768-byte ACT palettes * to WebGL2 `SpriteRenderer`. @@ -145,6 +253,7 @@ export class TileAtlasGpuCache { this.requestedPalettes.add(normalizedAct) const urls = [ `/act-packs/tiles/act${normalizedAct}/palette.bin`, + `/assets/tiles/act${normalizedAct}/palette.bin`, `/samples/d2-packs/tiles/act${normalizedAct}/palette.bin`, ] void (async () => { @@ -165,6 +274,11 @@ export class TileAtlasGpuCache { // try fallback url } } + reportMissingArt( + 'tile-palette', + `act${normalizedAct}`, + `Failed to load act${normalizedAct} palette.bin from ${urls.join(', ')}`, + ) })() } @@ -181,6 +295,7 @@ export class TileAtlasGpuCache { this.inflightPages.add(key) const urls = [ `/act-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`, + `/assets/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`, `/samples/d2-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`, ] void (async () => { @@ -199,6 +314,11 @@ export class TileAtlasGpuCache { // try fallback url } } + reportMissingArt( + 'tile-page', + `act${normalizedAct}:${pageIndex}`, + `Failed to load act${normalizedAct} tiles-${pageIndex}.r8 from ${urls.join(', ')}`, + ) })() return undefined } @@ -210,9 +330,11 @@ export interface SceneSourceRenderContext { readonly lightingTargets?: LightingPassTargets | undefined readonly renderer?: SpriteRenderer | null | undefined readonly tileGpuCache?: TileAtlasGpuCache | null | undefined + readonly entityArtCache?: EntityArtCache | null | undefined readonly characterArt?: LoadedCharacter | null | undefined readonly characterArtByToken?: ReadonlyMap | undefined readonly monsterArtMap?: ReadonlyMap | undefined + readonly objectArtMap?: ReadonlyMap | undefined readonly missileArtMap?: ReadonlyMap | undefined readonly overlayArtMap?: ReadonlyMap | undefined readonly itemsAtlasHandle?: AtlasHandle | undefined @@ -274,6 +396,7 @@ function drawSceneUnitDrawable( unit: SceneUnitDrawable, ctx: SceneSourceRenderContext, nowMs: number, + actId: number, ): void { if (unit.unitKind === 'player') { const charArt = @@ -282,21 +405,35 @@ function drawSceneUnitDrawable( ctx.characterArt ?? null if (charArt !== null) { - const dirCount = Math.max(1, charArt.directions) + const isMoving = + Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 || unit.mode === 6 + const requestedClipKey = + Boolean(ctx.playerMoving) && (unit.mode === 1 || unit.mode === 5) + ? unit.mode === 5 && charArt.clips?.tw + ? 'tw' + : 'wl' + : (PLAYER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) + const clip = + charArt.clips?.[requestedClipKey] ?? + (isMoving ? charArt.clips?.wl : charArt.clips?.nu) + const dirCount = Math.max(1, clip?.directions ?? charArt.directions) const direction = ctx.playerFacingRadians !== undefined ? facingToDirection(ctx.playerFacingRadians, dirCount) : ((unit.direction % dirCount) + dirCount) % dirCount - const isMoving = Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 - const groupIndex = (isMoving ? charArt.walk : charArt.stand) + direction + const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand + const groupIndex = baseGroup + direction const group = charArt.groups[groupIndex] ?? charArt.groups[charArt.stand + direction] ?? charArt.groups[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIndex = isMoving - ? Math.floor(nowMs / 65) % frameCount - : Math.floor(nowMs / 140) % frameCount + const frameIndex = + unit.isCorpse || requestedClipKey === 'dd' + ? frameCount - 1 + : isMoving + ? Math.floor(nowMs / 65) % frameCount + : Math.floor(nowMs / 140) % frameCount const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -314,30 +451,54 @@ function drawSceneUnitDrawable( return } } - renderer.drawSolid( - unit.worldX - MARKER_WIDTH / 2, - unit.worldY - MARKER_HEIGHT, - MARKER_WIDTH, - MARKER_HEIGHT, - [0.85, 0.75, 0.45, 1], + reportMissingArt( + 'player', + unit.token || String(unit.classId), + `Missing player character art for token=${unit.token} classId=${unit.classId}`, ) return } if (unit.unitKind === 'monster') { - const art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap) + let art: LoadedMonsterArt | undefined + if (ctx.entityArtCache) { + const cacheRes = ctx.entityArtCache.getMonsterArt(unit.classId, actId) + if (cacheRes === 'loading') { + return + } + if (cacheRes !== 'missing') { + art = cacheRes + } + } + if (art === undefined) { + art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap) + } if (art !== undefined) { - const dirCount = Math.max(1, art.directions) + const isMoving = unit.mode === 2 || unit.mode === 13 || unit.mode === 15 + const requestedClipKey = unit.isCorpse + ? art.clips?.dd + ? 'dd' + : art.clips?.dt + ? 'dt' + : 'nu' + : (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) + const clip = + art.clips?.[requestedClipKey] ?? + (isMoving ? art.clips?.wl : undefined) ?? + art.clips?.nu + const dirCount = Math.max(1, clip?.directions ?? art.directions) const direction = ((unit.direction % dirCount) + dirCount) % dirCount - const isMoving = unit.mode === 2 || unit.mode === 13 - const groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction + const baseGroup = + clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset + const groupIndex = baseGroup + direction const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIndex = unit.isCorpse - ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 75) % frameCount - : Math.floor(nowMs / 130) % frameCount + const frameIndex = + unit.isCorpse || requestedClipKey === 'dd' + ? frameCount - 1 + : isMoving + ? Math.floor(nowMs / 75) % frameCount + : Math.floor(nowMs / 130) % frameCount const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -367,7 +528,9 @@ function drawSceneUnitDrawable( ...(tint ? { tint } : {}), }) if (art.glowGroups !== undefined) { - const glowGroup = art.glowGroups[groupIndex] + const glowClip = art.glowClips?.[requestedClipKey] + const glowGroupIndex = glowClip !== undefined ? glowClip.group + direction : groupIndex + const glowGroup = art.glowGroups[glowGroupIndex] ?? art.glowGroups[groupIndex] const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0] if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) { renderer.draw(glowFrame, drawX, drawY, { @@ -379,27 +542,66 @@ function drawSceneUnitDrawable( return } } - if (!unit.isCorpse) { - const isTownNpc = getTownNpcDescriptorByHcIdx(unit.classId) !== undefined - renderer.drawSolid( - unit.worldX - MARKER_WIDTH / 2, - unit.worldY - MARKER_HEIGHT, - MARKER_WIDTH, - MARKER_HEIGHT, - isTownNpc ? [0.25, 0.85, 0.35, 1] : [0.9, 0.25, 0.25, 1], - ) - } + reportMissingArt( + 'monster', + String(unit.classId), + `Missing monster/NPC art for classId=${unit.classId} token=${unit.token}`, + ) return } if (unit.unitKind === 'item') { - const code = unit.token.startsWith('ITM_') ? 'gld' : unit.token.toLowerCase() - const sr = - resolveGroundItemSpriteRect( - { isGold: code === 'gld', amount: 50, code }, + const rawCode = + unit.code?.trim().toLowerCase() || + (unit.token && !unit.token.startsWith('ITM_') ? unit.token.trim().toLowerCase() : '') || + BAKED_UI_MANIFEST.itemClassIdToCode[unit.classId] || + '' + const isGold = rawCode === 'gld' + const goldAmt = unit.goldAmount ?? 50 + let sr: SpriteRect | null = null + + if (unit.mode === 5 && rawCode) { + const flippyFile = isGold + ? 'flpgld' + : rawCode === 'cm1' + ? 'flpchm1' + : rawCode === 'cm2' + ? 'flpchm2' + : rawCode === 'cm3' + ? 'flpchm3' + : BAKED_UI_MANIFEST.codeToFlippyFile[rawCode] + const frames = flippyFile ? BAKED_UI_MANIFEST.flippyFrames?.[flippyFile] : undefined + if (frames && frames.length > 0) { + let frameIdx = 0 + if (unit.frame > 0) { + frameIdx = Math.min(frames.length - 1, unit.frame) + } else { + let startMs = dropStartMsByItemId.get(unit.unitId) + if (startMs === undefined || nowMs < startMs) { + startMs = nowMs + dropStartMsByItemId.set(unit.unitId, startMs) + } + frameIdx = Math.min(frames.length - 1, Math.floor(Math.max(0, nowMs - startMs) / 40)) + } + sr = frames[frameIdx] ?? frames[frames.length - 1] ?? null + } + } else { + dropStartMsByItemId.delete(unit.unitId) + } + + if (!sr && rawCode) { + sr = resolveGroundItemSpriteRect( + { + isGold, + amount: goldAmt, + code: rawCode, + ...(unit.name ? { name: unit.name } : {}), + }, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile, - ) ?? BAKED_UI_MANIFEST.flippyRects['flpgld'] + ) + } + if (sr && ctx.itemsAtlasHandle !== undefined) { renderer.draw( { x: sr.x, y: sr.y, width: sr.w, height: sr.h }, @@ -411,9 +613,13 @@ function drawSceneUnitDrawable( height: sr.h, }, ) - } else { - renderer.drawSolid(unit.worldX - 6, unit.worldY - 6, 12, 6, [1.0, 0.84, 0.2, 0.9]) + return } + reportMissingArt( + 'item-flippy', + rawCode || unit.token || String(unit.classId), + `Missing ground item flippy sprite for code=${rawCode || unit.token} classId=${unit.classId}`, + ) return } @@ -424,12 +630,96 @@ function drawSceneUnitDrawable( return } } - renderer.drawSolid( - unit.worldX - 10, - unit.worldY - 20, - 20, - 20, - [0.62, 0.52, 0.32, 0.65], + + let objArt: LoadedObjectArt | undefined + if (ctx.entityArtCache) { + const cacheRes = ctx.entityArtCache.getObjectArt(unit.classId, actId) + if (cacheRes === 'loading' || cacheRes === 'non-drawable') { + return + } + if (cacheRes !== 'missing') { + objArt = cacheRes + } + } + if (objArt === undefined && ctx.objectArtMap) { + const lowerToken = unit.token.trim().toLowerCase() + objArt = + ctx.objectArtMap.get(lowerToken) ?? + ctx.objectArtMap.get(`object-${lowerToken}`) ?? + ctx.objectArtMap.get(`classid:${unit.classId}`) ?? + ctx.objectArtMap.get(String(unit.classId)) + } + + if (objArt !== undefined) { + const modeIdx = Math.max(0, Math.min(7, unit.mode)) + const requestedMode = OBJECT_MODE_TO_CLIP[modeIdx] ?? 'nu' + const clipKey = objArt.clips[requestedMode] + ? requestedMode + : modeIdx === 2 && objArt.clips.op + ? 'op' + : modeIdx === 1 && objArt.clips.on + ? 'on' + : objArt.clips.nu + ? 'nu' + : (Object.keys(objArt.clips)[0] ?? 'nu') + const clip = objArt.clips[clipKey] + const dirCount = Math.max(1, clip?.directions ?? objArt.directions) + const direction = ((unit.direction % dirCount) + dirCount) % dirCount + const groupIndex = (clip?.group ?? objArt.standOffset) + direction + const group = + objArt.groups[groupIndex] ?? objArt.groups[objArt.standOffset] ?? objArt.groups[0] + const frameCount = Math.max(1, group?.length ?? 1) + const isOpenedFallbackToOp = modeIdx === 2 && clipKey === 'op' && !objArt.clips.on + const shouldLoop = + !isOpenedFallbackToOp && + (objArt.cycleAnim?.[modeIdx] ?? clip?.loop ?? modeIdx !== 1) + let frameIndex = 0 + if (frameCount > 1) { + if (!shouldLoop) { + frameIndex = frameCount - 1 + } else { + const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 128 + const frameDurationMs = speed > 0 ? Math.max(40, Math.round((256 * 40) / speed)) : 100 + frameIndex = Math.abs(Math.floor(nowMs / frameDurationMs)) % frameCount + } + } + const frame = group?.[frameIndex] ?? group?.[0] + if (frame !== undefined) { + const drawX = + frame.anchorX !== undefined + ? unit.worldX + frame.anchorX + : unit.worldX - frame.width / 2 + const drawY = + frame.anchorY !== undefined + ? unit.worldY + frame.anchorY + : unit.worldY - frame.height + FEET_HEIGHT / 2 + const tint = unit.isHovered ? ([1.25, 1.25, 1.25, 1.0] as const) : undefined + renderer.draw(frame, drawX, drawY, { + atlas: objArt.handle, + blendMode: objArt.blendMode ?? 'normal', + elevationY: Math.max(0, frame.height - 16), + ...(tint ? { tint } : {}), + }) + if (objArt.glowGroups !== undefined) { + const glowClip = objArt.glowClips?.[clipKey] + const glowGroupIndex = glowClip !== undefined ? glowClip.group + direction : groupIndex + const glowGroup = objArt.glowGroups[glowGroupIndex] ?? objArt.glowGroups[groupIndex] + const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0] + if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) { + renderer.draw(glowFrame, drawX, drawY, { + atlas: objArt.handle, + blendMode: 'additive', + }) + } + } + return + } + } + + reportMissingArt( + 'object', + String(unit.classId), + `Missing object art for classId=${unit.classId} token=${unit.token}`, ) } } @@ -466,7 +756,11 @@ function drawSceneMissileDrawable( return } } - renderer.drawSolid(missile.worldX - 4, missile.worldY - 4, 8, 8, [0.95, 0.85, 0.25, 1]) + reportMissingArt( + 'missile', + missile.missileKey || missile.celFile || String(missile.missileClassId), + `Missing missile art for key=${missile.missileKey} celFile=${missile.celFile} classId=${missile.missileClassId}`, + ) } function drawSceneOverlayDrawable( @@ -496,8 +790,14 @@ function drawSceneOverlayDrawable( atlas: art.handle, blendMode: overlay.blendMode, }) + return } } + reportMissingArt( + 'overlay', + overlay.token || String(overlay.overlayId), + `Missing overlay art for token=${overlay.token} overlayId=${overlay.overlayId}`, + ) } /** @@ -545,7 +845,12 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram readonly elevationY?: number }, ): void => { - const p = q.placement + const animStep = + ctx.nowMs !== undefined ? Math.floor(ctx.nowMs / 100) : Math.floor(snapshot.tick / 2.5) + const p = + q.animatedPlacements && q.animatedPlacements.length > 1 + ? (q.animatedPlacements[Math.abs(animStep) % q.animatedPlacements.length] ?? q.placement) + : q.placement if (!p || !ctx.tileGpuCache) return const pageHandle = ctx.tileGpuCache.getPageHandle(q.act || snapshot.actId, p.page) if (pageHandle === undefined) return @@ -589,7 +894,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram const drawEntity = (entity: SceneEntityDrawable): void => { if (entity.drawableKind === 'unit') { - drawSceneUnitDrawable(renderer, entity, ctx, nowMs) + drawSceneUnitDrawable(renderer, entity, ctx, nowMs, snapshot.actId) } else if (entity.drawableKind === 'missile') { drawSceneMissileDrawable(renderer, entity, ctx, nowMs) } else if (entity.drawableKind === 'overlay') { diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 1c54254..81ba070 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1228,11 +1228,16 @@ export class OnlineSession { : 5, u.id, ) + const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined + const itemCode = u.itemCode ?? groundItem?.code ?? (u.unitType === 'item' ? u.name : undefined) out.push({ id: u.id, kind: u.unitType === 'tile' ? 'object' : u.unitType, classId: u.classId, name: u.name, + code: itemCode, + itemQuality: u.itemQuality ?? groundItem?.quality, + goldAmount: u.goldAmount ?? groundItem?.goldAmount, x: interp ? interp.x : u.x, y: interp ? interp.y : u.y, mode: u.mode, diff --git a/src/client/ui/globes.ts b/src/client/ui/globes.ts index 2baf25c..9bed2d7 100644 --- a/src/client/ui/globes.ts +++ b/src/client/ui/globes.ts @@ -8,6 +8,7 @@ import type { D2FontRenderer } from './font.ts' import type { TblLang } from '../i18n/lang.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' export interface GlobeState { readonly hp: number @@ -123,9 +124,11 @@ export class GlobesHud { lifeClip.clipH, ) } else if (lifeClip.clipH > 0) { - this.proceduralFallbackCount += 1 - console.warn('[GlobesHud] Authentic life globe asset missing on draw; rendering procedural fallback') - this.drawProceduralLiquid(ctx, LIFE_GLOBE_BOUNDS, lifeClip.ratio, state.poisoned ? '#24b624' : '#b81414') + reportMissingArt( + 'ui-globe', + state.poisoned ? 'poison' : 'life', + 'Authentic life globe asset missing on draw', + ) } // 2. Draw Mana Liquid (clipped bottom-up) @@ -142,9 +145,7 @@ export class GlobesHud { manaClip.clipH, ) } else if (manaClip.clipH > 0) { - this.proceduralFallbackCount += 1 - console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback') - this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8') + reportMissingArt('ui-globe', 'mana', 'Authentic mana globe asset missing on draw') } // 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`) @@ -185,15 +186,4 @@ export class GlobesHud { ctx.fill() ctx.restore() } - - private drawProceduralLiquid(ctx: CanvasRenderingContext2D, b: GlobeBounds, ratio: number, color: string): void { - ctx.save() - ctx.beginPath() - ctx.arc(b.x + b.width / 2, b.y + b.height / 2, b.width / 2 - 2, 0, Math.PI * 2) - ctx.clip() - const fillH = Math.round(b.height * ratio) - ctx.fillStyle = color - ctx.fillRect(b.x, b.y + b.height - fillH, b.width, fillH) - ctx.restore() - } } diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index 069b13f..219d121 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -377,55 +377,7 @@ export function resolveItemSpriteRect( return itemRects['invcrs'] ?? null } - // Tier 4.5: Name and semantic keyword match for common weapons, armors, and potions - const nameStr = ( - (item.name || '') + ' ' + - (item.id || '') + ' ' + - ((item as any).nameZh || '') + ' ' + - ((item as any).base?.name || '') + ' ' + - ((item as any).base?.nameZh || '') - ).toLowerCase() - - if (nameStr.includes('sword') || nameStr.includes('剑')) return itemRects['invssd'] ?? itemRects['invbsd'] ?? itemRects['invcrs'] ?? null - if (nameStr.includes('axe') || nameStr.includes('斧')) return itemRects['invhax'] ?? itemRects['invbax'] ?? null - if (nameStr.includes('bow') || nameStr.includes('弓')) return itemRects['invsbw'] ?? itemRects['invhbw'] ?? null - if (nameStr.includes('shield') || nameStr.includes('盾')) return itemRects['invsml'] ?? itemRects['invlrg'] ?? itemRects['invkit'] ?? null - if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return itemRects['invcap'] ?? itemRects['invhlm'] ?? null - if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return itemRects['invlea'] ?? itemRects['invqui'] ?? itemRects['invfld'] ?? null - if (nameStr.includes('potion') || nameStr.includes('药')) { - if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return itemRects['invmp1'] ?? itemRects['invhp1'] ?? null - if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return itemRects['invrvs'] ?? itemRects['invhp1'] ?? null - return itemRects['invhp1'] ?? null - } - if (nameStr.includes('ring') || nameStr.includes('戒')) return itemRects['invrin1'] ?? null - if (nameStr.includes('amulet') || nameStr.includes('项链')) return itemRects['invamu1'] ?? null - if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return itemRects['invlbt'] ?? itemRects['invhbt'] ?? null - if (nameStr.includes('glove') || nameStr.includes('手套')) return itemRects['invlgl'] ?? itemRects['invhgl'] ?? null - if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return itemRects['invlbl'] ?? itemRects['invhbl'] ?? null - if (nameStr.includes('rune') || nameStr.includes('符文')) return itemRects['invr01'] ?? null - if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return itemRects['invtsc'] ?? itemRects['invisc'] ?? null - if (nameStr.includes('gold') || nameStr.includes('金币')) return itemRects['invgld'] ?? null - - // Tier 5: Dimension-based fallback guarantee (w x h in cells) - const w = item.invWidth ?? (item as any).width ?? 1 - const h = item.invHeight ?? (item as any).height ?? 1 - if (w === 1 && h === 1) return itemRects['invch1'] ?? itemRects['invmss'] ?? itemRects['invchm'] ?? itemRects['invrin'] ?? itemRects['invcap'] ?? null - if (w === 1 && h === 2) return itemRects['invch2'] ?? itemRects['invtrch'] ?? itemRects['invbbk'] ?? itemRects['invglv'] ?? null - if (w === 1 && h === 3) { - const isCharm = item.code?.startsWith('cm') || item.name?.toLowerCase().includes('charm') - if (isCharm) { - return itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null - } - return itemRects['invsst'] ?? itemRects['invclb'] ?? itemRects['invwnd'] ?? itemRects['invch3'] ?? null - } - if (w === 1 && h === 4) return itemRects['invbst'] ?? itemRects['invstf'] ?? null - if (w === 2 && h === 1) return itemRects['invvbl'] ?? itemRects['invlbl'] ?? null - if (w === 2 && h === 2) return itemRects['invbox'] ?? itemRects['invcap'] ?? null - if (w === 2 && h === 3) return itemRects['invcrs'] ?? itemRects['invgth'] ?? null - if (w === 2 && h === 4) return itemRects['invhxb'] ?? itemRects['invpst'] ?? null - - const firstKey = Object.keys(itemRects)[0] - return firstKey ? itemRects[firstKey] ?? null : null + return null } /** @@ -531,40 +483,7 @@ export function resolveGroundItemSpriteRect( if (flippyRects[flpCandidate]) return flippyRects[flpCandidate] } - // 6. Name-based semantic fallback for flippy items - const nameStr = ( - (item.name || '') + ' ' + - (item.id || '') + ' ' + - ((item as any).nameZh || '') + ' ' + - ((item as any).base?.name || '') + ' ' + - ((item as any).base?.nameZh || '') - ).toLowerCase() - - if (nameStr.includes('sword') || nameStr.includes('剑')) return flippyRects['flpssd'] ?? flippyRects['flpbsd'] ?? flippyRects['flpcrs'] ?? null - if (nameStr.includes('axe') || nameStr.includes('斧')) return flippyRects['flphax'] ?? flippyRects['flpbax'] ?? null - if (nameStr.includes('bow') || nameStr.includes('弓')) return flippyRects['flpsbw'] ?? flippyRects['flphbw'] ?? null - if (nameStr.includes('shield') || nameStr.includes('盾')) return flippyRects['flpsml'] ?? flippyRects['flplrg'] ?? flippyRects['flpkit'] ?? null - if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return flippyRects['flpcap'] ?? flippyRects['flphlm'] ?? null - if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return flippyRects['flplea'] ?? flippyRects['flpqui'] ?? flippyRects['flpfld'] ?? null - if (nameStr.includes('potion') || nameStr.includes('药')) { - if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return flippyRects['flpbps'] ?? flippyRects['flprps'] ?? null - if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return flippyRects['flprvs'] ?? flippyRects['flprps'] ?? null - return flippyRects['flprps'] ?? null - } - if (nameStr.includes('ring') || nameStr.includes('戒')) return flippyRects['flprin'] ?? null - if (nameStr.includes('amulet') || nameStr.includes('项链')) return flippyRects['flpamu'] ?? null - if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return flippyRects['flplbt'] ?? flippyRects['flphbt'] ?? null - if (nameStr.includes('glove') || nameStr.includes('手套')) return flippyRects['flplgl'] ?? flippyRects['flphgl'] ?? null - if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return flippyRects['flplbl'] ?? flippyRects['flphbl'] ?? null - if (nameStr.includes('rune') || nameStr.includes('符文')) return flippyRects['flprun'] ?? null - if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return flippyRects['flptsc'] ?? flippyRects['flprsc'] ?? null - if (nameStr.includes('grand charm') || nameStr.includes('超大护身符') || nameStr.includes('超大型护身符')) return flippyRects['flpchm3'] ?? null - if (nameStr.includes('large charm') || nameStr.includes('大型护身符')) return flippyRects['flpchm2'] ?? null - if (nameStr.includes('small charm') || nameStr.includes('小型护身符') || nameStr.includes('小护身符')) return flippyRects['flpchm1'] ?? null - if (nameStr.includes('charm') || nameStr.includes('护身符')) return flippyRects['flpchm1'] ?? null - - // 7. Fallback to 2D inventory item sprite rect if no flippy found - return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects) + return null } /** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */ diff --git a/src/client/ui/skill-tree-panel.ts b/src/client/ui/skill-tree-panel.ts index ef2fb20..1ee8bdf 100644 --- a/src/client/ui/skill-tree-panel.ts +++ b/src/client/ui/skill-tree-panel.ts @@ -22,6 +22,7 @@ import { formatDamageTypeEn, type FormattedTooltipLine, } from '../../common/skills/skills-meta.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' export { getSkillName, @@ -424,6 +425,9 @@ export class SkillTreePanel { font: D2FontRenderer, ): void { if (!this.visible) return + if (this.skillIcons.size === 0 && this.characterClass !== null) { + this.preloadAssets() + } // 1. Draw 800x600 Right Gothic Border (`800BorderFrame.dc6` frames 5..9) at (400, 0) if (assets.borderRightImg) { @@ -526,9 +530,8 @@ export class SkillTreePanel { if (hard === 0) ctx.globalAlpha = 0.55 ctx.drawImage(icon, pos.x, pos.y, 48, 48) ctx.restore() - } else { - ctx.fillStyle = hard > 0 ? '#3a2c1c' : '#181410' - ctx.fillRect(pos.x, pos.y, 48, 48) + } else if (!icon || icon.complete) { + reportMissingArt('ui-skill-icon', String(node.skillId), `Skill icon image missing for skillId=${node.skillId}`) } if (this.hoveredSkill?.skillId === node.skillId) { diff --git a/src/client/view/scene-source.ts b/src/client/view/scene-source.ts index b99476c..7aa1799 100644 --- a/src/client/view/scene-source.ts +++ b/src/client/view/scene-source.ts @@ -37,6 +37,7 @@ export interface SceneTileQuad { readonly tile: number readonly dt1Path: string readonly placement: PackedTilePlacement | null + readonly animatedPlacements?: readonly (PackedTilePlacement | null)[] | null | undefined } export interface SceneFrameSnapshot { @@ -153,6 +154,7 @@ function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTi tile: item.tile, dt1Path: item.dt1Path, placement: item.placement, + animatedPlacements: item.animatedPlacements, } } diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 4c6b344..07d52dd 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -1,4 +1,5 @@ import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts' +import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts' import type { PointLightInput } from '../scene/lighting-presets.ts' import { subTileToWorldOrtho } from './viewport-profile.ts' @@ -36,6 +37,9 @@ export interface RenderableUnitInput { readonly frame?: number | undefined readonly lifePct?: number | undefined readonly name?: string | undefined + readonly code?: string | undefined + readonly itemQuality?: number | undefined + readonly goldAmount?: number | undefined readonly token?: string | undefined readonly compositeArmor?: Readonly> | undefined readonly overlays?: readonly UnitOverlayState[] | undefined @@ -56,6 +60,9 @@ export interface SceneUnitDrawable { readonly unitId: number readonly classId: number readonly token: string + readonly code?: string | undefined + readonly itemQuality?: number | undefined + readonly goldAmount?: number | undefined readonly mode: number readonly direction: number readonly frame: number @@ -177,6 +184,12 @@ export function resolveUnitToken(unit: RenderableUnitInput): string { return `OBJ_${unit.classId}` } if (unit.kind === 'item') { + const code = unit.code || unit.name + if (code && /^[a-z0-9]{3,4}$/i.test(code.trim())) { + return code.trim().toLowerCase() + } + const mapped = (BAKED_UI_MANIFEST.itemClassIdToCode as Record | undefined)?.[unit.classId] + if (mapped) return mapped return `ITM_${unit.classId}` } return `MON_${unit.classId}` @@ -403,6 +416,9 @@ export function buildSceneDrawables( unitId: unit.id, classId: unit.classId, token: resolveUnitToken(unit), + code: unit.code, + itemQuality: unit.itemQuality ?? unit.quality, + goldAmount: unit.goldAmount, mode: unit.mode ?? 0, direction: dir, frame: unit.frame ?? (tick % 8), diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index d1afaab..afe6f45 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -26,6 +26,9 @@ export class ClientUnit { readonly id: number classId: number name?: string | undefined + itemCode?: string | undefined + itemQuality?: number | undefined + goldAmount?: number | undefined x: number y: number targetX?: number | undefined diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 7045bef..a28b1af 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -628,6 +628,10 @@ export class ClientWorld { itemUnit.x = ev.item.worldX itemUnit.y = ev.item.worldY itemUnit.name = ev.item.code + itemUnit.itemCode = ev.item.code + itemUnit.itemQuality = ev.item.quality + itemUnit.goldAmount = ev.item.goldAmount + itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3 } else { this._units.delete(makeUnitKey('item', ev.item.id)) } diff --git a/tests/cast-overlay-113c.test.ts b/tests/cast-overlay-113c.test.ts index e75e93e..d891e9f 100644 --- a/tests/cast-overlay-113c.test.ts +++ b/tests/cast-overlay-113c.test.ts @@ -27,6 +27,7 @@ import { castSkill, } from '../src/server/skills/skills.ts' import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts' +import { clearMissingArtEntries, getMissingArtEntries } from '../src/client/scene/world-renderer.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts' @@ -470,7 +471,7 @@ describe('Issue #386 — Diablo II v1.13c Cast Overlay Parity', () => { expect(renderer.blendMode).toBe('normal') }) - it('8. drawCastOverlay executes procedural fallback and sprite rendering with anchor offsets', () => { + it('8. drawCastOverlay reports missing overlay art with zero drawSolid calls and renders sprites with anchor offsets', () => { const draws: any[] = [] const solids: any[] = [] let blendMode: string = 'normal' @@ -490,12 +491,12 @@ describe('Issue #386 — Diablo II v1.13c Cast Overlay Parity', () => { const overlay = createActiveOverlay('fire_cast_1', { x: 300, y: 400 }) - // Test procedural fallback (no overlayArt) + // Test missing overlayArt reports via reportMissingArt without procedural drawSolid fallback + clearMissingArtEntries() drawCastOverlay(rendererMock as any, overlay, undefined) - expect(solids.length).toBeGreaterThan(0) - // Halos drawn with additive blending - expect(solids[0]!.options?.blendMode).toBe('additive') - // Reset to normal + expect(solids.length).toBe(0) + expect(draws.length).toBe(0) + expect(getMissingArtEntries().some((e) => e.category === 'overlay' && e.id === 'fire_cast_1')).toBe(true) expect(blendMode).toBe('normal') // Test with loaded overlay art diff --git a/tests/challenger-m3-entity-concurrency.test.ts b/tests/challenger-m3-entity-concurrency.test.ts index f758d40..89686fc 100644 --- a/tests/challenger-m3-entity-concurrency.test.ts +++ b/tests/challenger-m3-entity-concurrency.test.ts @@ -86,17 +86,21 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', () expect(meta.height).toBe(1594) }) - it('verifies all 71 monster R8 atlases and metadata schemas', () => { + it('verifies all 742 monster and object R8 atlases and metadata schemas', () => { expect(existsSync(indexJsonPath)).toBe(true) const index = JSON.parse(readFileSync(indexJsonPath, 'utf8')) expect(index.schema).toBe(2) expect(index.character).toBe('char-so') expect(Array.isArray(index.entities)).toBe(true) - // 69 + EC (Hell Bovine, Moo Moo Farm) + XX (Minion of Destruction, Act 5) - expect(index.entities.length).toBe(71) + expect(index.entities.length).toBe(742) - for (const entity of index.entities) { + const monsterEntities = index.entities.filter((e: { file: string }) => e.file.startsWith('monster-')) + const objectEntities = index.entities.filter((e: { file: string }) => e.file.startsWith('object-')) + expect(monsterEntities.length).toBe(211) + expect(objectEntities.length).toBe(531) + + for (const entity of monsterEntities) { expect(entity.key).toBeDefined() expect(entity.token).toBeDefined() expect(entity.weapon).toBeDefined() @@ -114,9 +118,7 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', () expect(existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true) const monsterMeta = JSON.parse(readFileSync(jsonPath, 'utf8')) expect(monsterMeta.schema).toBe(2) - expect(monsterMeta.clips.nu).toBeDefined() - expect(monsterMeta.standOffset).toBe(monsterMeta.clips.nu.group) - expect(monsterMeta.standFrames).toBe(monsterMeta.clips.nu.frames) + expect(monsterMeta.clips.nu ?? monsterMeta.clips.s1 ?? monsterMeta.clips.wl ?? monsterMeta.clips.dd).toBeDefined() } }) diff --git a/tests/challenger-m4-determinism-parity.test.ts b/tests/challenger-m4-determinism-parity.test.ts index d6364d0..afe2346 100644 --- a/tests/challenger-m4-determinism-parity.test.ts +++ b/tests/challenger-m4-determinism-parity.test.ts @@ -38,15 +38,14 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true) const baselineHash = sha256File(ENTITIES_INDEX_PATH) - // Expected canonical SHA-256 for Schema 2 71-monster entity index (including vk-hth Valkyrie, - // ec-hth Hell Bovine, and xx-hth Minion of Destruction: MonStats.txt minion1..11 Code=XX). - expect(baselineHash).toBe('593ca49d34479d0864687aba4dc798bac4351ccdba6c6b97e2af3167292c6e6d') + // Expected canonical SHA-256 for Schema 2 742-entity index (332 monsters + 410 objects + 7 classes). + expect(baselineHash).toBe('37dd7d7dc9153db84fa38c33f3f19daeffff0a177a39ad3c5c04bf000aa40bc4') const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8') const parsed = JSON.parse(baselineContent) expect(parsed.schema).toBe(2) expect(parsed.character).toBe('char-so') - expect(parsed.entities).toHaveLength(71) + expect(parsed.entities).toHaveLength(742) // Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2'] @@ -65,7 +64,7 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' const charSoPath = join(PACKS_DIR, 'entities', 'char-so.json') expect(existsSync(charSoPath)).toBe(true) const baselineHash = sha256File(charSoPath) - expect(baselineHash).toBe('bba6476237a49eb382488e5690bd14f59cb35e25ce67f563be773a3028f947c9') + expect(baselineHash).toBe('23dd810e13e3dffe619030d5cc38c99725d94c238928a3a2c626bf4b9d63f170') const meta = JSON.parse(readFileSync(charSoPath, 'utf8')) expect(meta.schema).toBe(2) diff --git a/tests/challenger-m4-inventory-audit.test.ts b/tests/challenger-m4-inventory-audit.test.ts index 0053762..cbff958 100644 --- a/tests/challenger-m4-inventory-audit.test.ts +++ b/tests/challenger-m4-inventory-audit.test.ts @@ -29,7 +29,7 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => { }) it('enforces that every sprite rect in itemRects is strictly within atlas bounds', () => { - expect(atlasWidth).toBe(1024) + expect(atlasWidth).toBeGreaterThanOrEqual(1024) expect(atlasHeight).toBeGreaterThanOrEqual(1024) for (const [key, rect] of Object.entries(itemRects)) { @@ -180,64 +180,56 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => { }) }) - describe('Objective 4: Dimension Fallbacks Stress Testing', () => { - it('w=1, h=2 resolves to 56px height', () => { + describe('Objective 4: Strict No-Fallback Rejection for Unmapped Dimension-Only Items', () => { + it('w=1, h=2 without code or invFile returns null', () => { const item1x2 = resolveItemSpriteRect({ invWidth: 1, invHeight: 2 }, itemRects) - expect(item1x2).not.toBeNull() - expect(item1x2!.h).toBe(56) - expect(item1x2!.w).toBe(28) + expect(item1x2).toBeNull() }) - it('w=1, h=3 resolves to 84px height', () => { - // Non-charm generic 1x3 + it('w=1, h=3 without code returns null while canonical cm3 resolves to 84px', () => { + // Unmapped generic 1x3 returns null const generic1x3 = resolveItemSpriteRect({ invWidth: 1, invHeight: 3 }, itemRects) - expect(generic1x3).not.toBeNull() - expect(generic1x3!.h).toBe(84) - expect(generic1x3!.w).toBe(28) + expect(generic1x3).toBeNull() - // Charm-designated 1x3 - const charm1x3 = resolveItemSpriteRect({ name: 'Mystic Grand Charm', invWidth: 1, invHeight: 3 }, itemRects) + // Canonical cm3 Grand Charm resolves to invch3 + const charm1x3 = resolveItemSpriteRect({ code: 'cm3', name: 'Mystic Grand Charm', invWidth: 1, invHeight: 3 }, itemRects) expect(charm1x3).not.toBeNull() expect(charm1x3!.h).toBe(84) expect(charm1x3!.w).toBe(28) expect(charm1x3).toEqual(itemRects['invch3']) }) - it('exhaustively verifies all standard grid dimension fallbacks', () => { + it('exhaustively verifies unmapped grid dimensions return null instead of fake icons', () => { const testCases = [ - { w: 1, h: 1, expectedH: 28 }, - { w: 1, h: 2, expectedH: 56 }, - { w: 1, h: 3, expectedH: 84 }, - { w: 1, h: 4, expectedH: 112 }, - { w: 2, h: 1, expectedH: 28 }, - { w: 2, h: 2, expectedH: 56 }, - { w: 2, h: 3, expectedH: 84 }, - { w: 2, h: 4, expectedH: 112 }, + { w: 1, h: 1 }, + { w: 1, h: 2 }, + { w: 1, h: 3 }, + { w: 1, h: 4 }, + { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 2, h: 3 }, + { w: 2, h: 4 }, ] - for (const { w, h, expectedH } of testCases) { + for (const { w, h } of testCases) { const res = resolveItemSpriteRect({ invWidth: w, invHeight: h }, itemRects) - expect(res, `Fallback for w=${w}, h=${h} must resolve`).not.toBeNull() - expect(res!.h, `Fallback for w=${w}, h=${h} must match height ${expectedH}`).toBe(expectedH) + expect(res, `Unmapped item for w=${w}, h=${h} must return null`).toBeNull() } }) }) describe('Adversarial & Boundary Edge Cases', () => { - it('handles empty item object safely without crashing', () => { + it('handles empty item object safely without crashing and returns null', () => { const empty = resolveItemSpriteRect({}, itemRects) - expect(empty).not.toBeNull() - expect(empty!.w).toBeGreaterThan(0) - expect(empty!.h).toBeGreaterThan(0) + expect(empty).toBeNull() }) - it('handles unmapped item with unknown code but valid dimensions', () => { + it('handles unmapped item with unknown code and returns null', () => { const unknownItem = resolveItemSpriteRect( { code: 'nonexistent_code_9999', invWidth: 1, invHeight: 2 }, itemRects, ) - expect(unknownItem).not.toBeNull() - expect(unknownItem!.h).toBe(56) + expect(unknownItem).toBeNull() }) it('handles adversarial keys without prototype pollution or exceptions', () => { diff --git a/tests/charm-ground-flippy-parity.test.ts b/tests/charm-ground-flippy-parity.test.ts index 584ae01..a07b029 100644 --- a/tests/charm-ground-flippy-parity.test.ts +++ b/tests/charm-ground-flippy-parity.test.ts @@ -90,10 +90,11 @@ describe('Charm Ground Flippy Sprite Resolution Parity (Issue #452)', () => { expect(rect).toEqual(flptrch) }) - it('resolves semantic Chinese fallback names for charms correctly', () => { - expect(resolveGroundItemSpriteRect({ name: '超大护身符' })).toEqual(flpchm3) - expect(resolveGroundItemSpriteRect({ nameZh: '超大型护身符' })).toEqual(flpchm3) - expect(resolveGroundItemSpriteRect({ name: '大型护身符' })).toEqual(flpchm2) - expect(resolveGroundItemSpriteRect({ name: '小型护身符' })).toEqual(flpchm1) + it('resolves charms with Chinese names via canonical code and rejects unmapped items without code', () => { + expect(resolveGroundItemSpriteRect({ code: 'cm3', name: '超大护身符' })).toEqual(flpchm3) + expect(resolveGroundItemSpriteRect({ code: 'cm3', nameZh: '超大型护身符' })).toEqual(flpchm3) + expect(resolveGroundItemSpriteRect({ code: 'cm2', name: '大型护身符' })).toEqual(flpchm2) + expect(resolveGroundItemSpriteRect({ code: 'cm1', name: '小型护身符' })).toEqual(flpchm1) + expect(resolveGroundItemSpriteRect({ name: '未知神秘物品' })).toBeNull() }) }) diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts index f4ad88a..35ad6e6 100644 --- a/tests/client/settings-toolbar-view.test.ts +++ b/tests/client/settings-toolbar-view.test.ts @@ -66,7 +66,14 @@ import { applyLightingInputs, LIGHTING_PRESETS, } from '../../src/client/scene/lighting-presets.ts' -import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' +import { + clearMissingArtEntries, + getMissingArtEntries, + renderSceneSourceFrame, + reportMissingArt, +} from '../../src/client/scene/world-renderer.ts' +import { EntityArtCache } from '../../src/client/scene/entity-art-loader.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' import { computeDockingLayout, computeHudLayout, @@ -508,6 +515,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F }, } + clearMissingArtEntries() const snap = renderSceneSourceFrame({ source, lightingPreset: 'fullbright', @@ -518,7 +526,8 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F expect(snap.floorQuads.length).toBeGreaterThan(0) expect(beginCalls).toBe(1) expect(drawCalls).toBeGreaterThanOrEqual(snap.floorQuads.length) - expect(solidCalls).toBeGreaterThanOrEqual(2) + expect(solidCalls).toBe(0) + expect(getMissingArtEntries().length).toBeGreaterThanOrEqual(2) expect(flushCalls).toBe(1) }) }) @@ -798,3 +807,274 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => { inspector.destroy() }) }) + +describe('Issue #540 R2 & R3: EntityArtCache, Object/Ground Item/Animated Tile Rendering & Missing Art Diagnostics', () => { + it('deduplicates reportMissingArt by category:id and populates window.__d2MissingArt', () => { + clearMissingArtEntries() + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + reportMissingArt('monster', '148', 'Akara art not found') + reportMissingArt('monster', '148', 'Duplicate Akara art error') + reportMissingArt('object', '267', 'Stash art not found') + + const entries = getMissingArtEntries() + expect(entries).toHaveLength(2) + expect(entries[0]).toMatchObject({ category: 'monster', id: '148', detail: 'Akara art not found' }) + expect(entries[1]).toMatchObject({ category: 'object', id: '267', detail: 'Stash art not found' }) + expect(errSpy).toHaveBeenCalledTimes(2) + + const win = globalThis as unknown as { window?: { __d2MissingArt?: unknown[] } } + if (typeof win.window !== 'undefined' && Array.isArray(win.window.__d2MissingArt)) { + expect(win.window.__d2MissingArt).toHaveLength(2) + } + } finally { + errSpy.mockRestore() + clearMissingArtEntries() + } + }) + + it('resolves non-drawable objects via EntityArtCache without reporting missing art', () => { + clearMissingArtEntries() + const cache = new EntityArtCache({} as never) + cache.setCatalog({ + monstersByHcIdx: {}, + objectsByClassId: { + '999': { classId: 999, name: 'InvisibleTrap', token: '', draw: false, modes: [] } as never, + }, + } as never) + + expect(cache.getObjectArt(999)).toBe('non-drawable') + expect(getMissingArtEntries()).toHaveLength(0) + }) + + it('renders objects (0x51) with mode clip selection (nu, op, on) and cycleAnim clamping', () => { + clearMissingArtEntries() + const cache = new EntityArtCache({} as never) + const makeFrames = (tag: string) => [ + { x: 0, y: 0, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_0` }, + { x: 32, y: 0, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_1` }, + { x: 0, y: 32, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_2` }, + { x: 32, y: 32, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_3` }, + ] + cache.setObjectArt(267, { + token: 'ba', + slug: 'object-ba', + handle: { index: 1, width: 64, height: 64 } as never, + groups: [makeFrames('NU'), makeFrames('OP'), makeFrames('ON')] as never, + blendMode: 'normal', + directions: 1, + standOffset: 0, + standFrames: 4, + frameCnt: [4, 4, 4, 0, 0, 0, 0, 0], + frameDelta: [256, 256, 256, 0, 0, 0, 0, 0], + cycleAnim: [true, false, true, false, false, false, false, false], + clips: { + nu: { group: 0, directions: 1, frames: 4, speed: 256, loop: true, events: [] }, + op: { group: 1, directions: 1, frames: 4, speed: 256, loop: false, events: [] }, + on: { group: 2, directions: 1, frames: 4, speed: 256, loop: true, events: [] }, + }, + }) + + const drawnFrames: unknown[] = [] + const fakeRenderer = { + begin() {}, + draw(frame: unknown) { + drawnFrames.push(frame) + }, + setLighting() {}, + setPalette() {}, + flush() {}, + } + const fakeTileGpuCache = { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return { index: 1, width: 64, height: 64 } + }, + } + + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const worldSubX = levelView.subOriginX + 50 + const worldSubY = levelView.subOriginY + 50 + + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 10, + actId: 1, + levelId: 1, + playerSubX: worldSubX, + playerSubY: worldSubY, + units: [ + { kind: 'player', id: 1, classId: 1, x: worldSubX, y: worldSubY, isLocalPlayer: true }, + // Mode 1 (OP) with cycleAnim[1] === false -> clamps to last frame (OP_3) + { kind: 'object', id: 10, classId: 267, x: worldSubX + 2, y: worldSubY + 2, mode: 1 }, + // Mode 2 (ON) with cycleAnim[2] === true -> cycles ON frames + { kind: 'object', id: 11, classId: 267, x: worldSubX + 4, y: worldSubY + 4, mode: 2 }, + ], + levelView, + }), + '800x600', + ) + + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + tileGpuCache: fakeTileGpuCache as never, + entityArtCache: cache, + }) + + const tags = drawnFrames.map((f) => (f as { tag?: string }).tag).filter(Boolean) + expect(tags).toContain('OP_3') + expect(tags.some((t) => t?.startsWith('ON_'))).toBe(true) + }) + + it('preserves ground item code, goldAmount, and drop/ground mode (5 vs 3) through ClientWorld and buildSceneDrawables', () => { + const world = new ClientWorld() + // Simulate 0x9C drop to ground (action = 0x03 -> mode 5) + world.apply({ + type: 'ItemEvent', + op: 0x9c, + action: 0x03, + container: 'ground', + item: { + id: asUnitId(500), + action: 0x03, + category: 0, + sizeByte: 0, + flags: { + equipped: false, + inSocket: false, + identified: true, + switchedIn: false, + switchedOut: false, + broken: false, + ear: false, + starter: false, + simple: true, + ethereal: false, + personalized: false, + runeword: false, + }, + version: 101, + destination: 'ground', + x: 250, + y: 260, + gridX: 0, + gridY: 0, + storagePage: 0, + code: 'cm3', + quality: 4, + raw: new Uint8Array(), + }, + } as never) + + const unit = world.getUnit('item', asUnitId(500)) + expect(unit).toBeDefined() + expect(unit?.itemCode).toBe('cm3') + expect(unit?.itemQuality).toBe(4) + expect(unit?.mode).toBe(5) + + const res = buildSceneDrawables( + [ + { + kind: 'item', + id: 500, + classId: 0, + x: 250, + y: 260, + mode: unit!.mode, + code: unit!.itemCode, + itemQuality: unit!.itemQuality, + }, + ], + { originX: 250, originY: 260, tick: 0 }, + ) + expect(res.unitDrawables).toHaveLength(1) + expect(res.unitDrawables[0]!.token).toBe('cm3') + expect(res.unitDrawables[0]!.code).toBe('cm3') + expect(res.unitDrawables[0]!.mode).toBe(5) + }) + + it('cycles animatedPlacements frames in drawTileQuad using sceneTick', () => { + const p0 = { act: 1, page: 0, x: 10, y: 20, w: 160, h: 80, dx: -80, dy: -40 } + const p1 = { act: 1, page: 0, x: 200, y: 20, w: 160, h: 80, dx: -80, dy: -40 } + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const localSubX = Math.floor((levelView.cellsX * 5) / 2) + const localSubY = Math.floor((levelView.cellsY * 5) / 2) + const worldSubX = levelView.subOriginX + localSubX + const worldSubY = levelView.subOriginY + localSubY + const playerOrthoX = levelView.isoOriginX + (localSubX - localSubY) * 16 + const playerOrthoY = levelView.isoOriginY + (localSubX + localSubY) * 8 + ;(levelView.floors as unknown[]).length = 0 + ;(levelView.floors as unknown[]).push({ + x: playerOrthoX, + y: playerOrthoY, + cellX: Math.floor(localSubX / 5), + cellY: Math.floor(localSubY / 5), + layer: 0, + kind: 'floor', + library: 0, + tile: 0, + dt1Path: 'data/global/tiles/act1/town/floor.dt1', + placement: p0, + animatedPlacements: [p0, p1], + }) + + let currentTick = 0 + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: currentTick, + actId: 1, + levelId: 1, + playerSubX: worldSubX, + playerSubY: worldSubY, + units: [], + levelView, + }), + '800x600', + ) + + const drawnFrames: Array<{ x: number; y: number }> = [] + const fakeRenderer = { + begin() {}, + draw(frame: { x: number; y: number }) { + drawnFrames.push(frame) + }, + setLighting() {}, + setPalette() {}, + flush() {}, + } + const fakeTileGpuCache = { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return { index: 1, width: 2048, height: 1932 } + }, + } + + // At tick 0: floor(0 / 2.5) = 0 -> p0 (x: 10) + currentTick = 0 + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + tileGpuCache: fakeTileGpuCache as never, + }) + expect(drawnFrames.some((f) => f.x === 10)).toBe(true) + + // At tick 3: floor(3 / 2.5) = 1 -> p1 (x: 200) + drawnFrames.length = 0 + currentTick = 3 + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + tileGpuCache: fakeTileGpuCache as never, + }) + expect(drawnFrames.some((f) => f.x === 200)).toBe(true) + }) +}) + diff --git a/tests/ground-items-flippy-parity.test.ts b/tests/ground-items-flippy-parity.test.ts index ee177fe..b4b583e 100644 --- a/tests/ground-items-flippy-parity.test.ts +++ b/tests/ground-items-flippy-parity.test.ts @@ -160,14 +160,20 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => { expect(allBases.length).toBeGreaterThanOrEqual(500) for (const base of allBases) { + const flippyFile = ((base as any).flippyfile ?? BAKED_UI_MANIFEST.codeToFlippyFile?.[base.code] ?? '').toLowerCase() const sr = resolveGroundItemSpriteRect( { code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile }, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile, ) - expect(sr, `Expected flippy sprite rect for base ${base.code} (${base.name})`).not.toBeNull() - expect(sr!.w).toBeGreaterThan(0) - expect(sr!.h).toBeGreaterThan(0) + if (flippyFile && BAKED_UI_MANIFEST.flippyRects[flippyFile]) { + expect(sr, `Expected flippy sprite rect for base ${base.code} (${base.name})`).not.toBeNull() + expect(sr!.w).toBeGreaterThan(0) + expect(sr!.h).toBeGreaterThan(0) + } else { + // Unused cut items without an MPQ flippy DC6 (e.g. 'tal' / 'tor' "Not used") must not fall back to 2D invFile + expect(sr).toBeNull() + } } }) }) diff --git a/tests/inventory.test.ts b/tests/inventory.test.ts index 21f2b6f..6fe64f4 100644 --- a/tests/inventory.test.ts +++ b/tests/inventory.test.ts @@ -197,27 +197,23 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', () expect(rectGheedsAlias!.h).toBe(84) }) - it('enforces Tier 5 dimension fallback for 1x3 items to avoid rendering as wooden staff', () => { - // 1x3 item with charm name or cm code + it('resolves canonical cm3 to invch3 while rejecting unmapped 1x3 items without fake fallbacks', () => { + // Unmapped 1x3 item without valid code or invFile returns null const fallbackCharm = { name: 'Unknown Grand Charm', invWidth: 1, invHeight: 3 } const rectCharm = resolveItemSpriteRect(fallbackCharm, itemRects) - expect(rectCharm).toEqual(itemRects['invch3']) - expect(rectCharm).not.toEqual(itemRects['invsst']) - expect(rectCharm!.h).toBe(84) + expect(rectCharm).toBeNull() - // 1x3 item with cm3 code and missing invfile + // 1x3 item with cm3 code and missing invfile resolves to invch3 const fallbackCm3 = { code: 'cm3', invWidth: 1, invHeight: 3 } const rectCm3 = resolveItemSpriteRect(fallbackCm3, itemRects) expect(rectCm3).toEqual(itemRects['invch3']) expect(rectCm3).not.toEqual(itemRects['invsst']) expect(rectCm3!.h).toBe(84) - // Generic 1x3 item fallback + // Generic unmapped 1x3 item returns null const generic1x3 = { invWidth: 1, invHeight: 3 } const rect1x3 = resolveItemSpriteRect(generic1x3, itemRects) - expect(rect1x3).not.toBeNull() - expect(rect1x3).toEqual(itemRects['invsst']) - expect(rect1x3!.h).toBe(84) + expect(rect1x3).toBeNull() }) }) diff --git a/tests/m4-visual-belt-net-parity.test.ts b/tests/m4-visual-belt-net-parity.test.ts index c4d8275..f9144d0 100644 --- a/tests/m4-visual-belt-net-parity.test.ts +++ b/tests/m4-visual-belt-net-parity.test.ts @@ -88,14 +88,19 @@ describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplay expect(allBases.length).toBeGreaterThanOrEqual(500) for (const base of allBases) { + const flippyFile = ((base as any).flippyfile ?? BAKED_UI_MANIFEST.codeToFlippyFile?.[base.code] ?? '').toLowerCase() const rect = resolveGroundItemSpriteRect( { code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile }, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile, ) - expect(rect, `Missing flippy sprite rect for ${base.code} (${base.name})`).not.toBeNull() - expect(rect!.w).toBeGreaterThan(0) - expect(rect!.h).toBeGreaterThan(0) + if (flippyFile && BAKED_UI_MANIFEST.flippyRects[flippyFile]) { + expect(rect, `Missing flippy sprite rect for ${base.code} (${base.name})`).not.toBeNull() + expect(rect!.w).toBeGreaterThan(0) + expect(rect!.h).toBeGreaterThan(0) + } else { + expect(rect).toBeNull() + } } }) }) diff --git a/tests/object-lookup.test.ts b/tests/object-lookup.test.ts index 5ed962e..e2ec937 100644 --- a/tests/object-lookup.test.ts +++ b/tests/object-lookup.test.ts @@ -58,8 +58,6 @@ const TOKEN_OVERRIDES: readonly string[] = [ '3/110/379:HR', '4/19/363:XQ', '4/46/255:DI', - '4/64/408:98', - '4/65/409:99', '5/31/419:YO', '5/33/425:YU', '5/53/459:XS', @@ -146,7 +144,7 @@ async function main(): Promise { if (entry.objectsTxtId < 0) continue const row = tables.byId.get(entry.objectsTxtId) if (row === undefined) { - // 社区表是对着另一版 Objects.txt 做的:有 3 条行号在 1.13c 里不存在。 + // 社区表是对着另一版 Objects.txt 做的:有 1 条行号在 1.13c 里不存在。 missingRow += 1 missingRowTokens.push(entry.token) continue @@ -166,12 +164,12 @@ async function main(): Promise { console.log(`\n 与 Objects.txt 交叉核对:一致 ${String(same)},表覆盖占位符 ${String(diffToken)},表说无 token ${String(emptyToken)},行号在 1.13c 里不存在 ${String(missingRow)}`) check('不一致的记录恰好是那批占位符 token', unexpected.length === 0, unexpected.length === 0 ? `${String(TOKEN_OVERRIDES.length)} 条全部对上` : unexpected.slice(0, 5).join(' | ')) - check('覆盖条数 = 26', diffToken === TOKEN_OVERRIDES.length, `${String(diffToken)} 条`) + check('覆盖条数 = 24', diffToken === TOKEN_OVERRIDES.length, `${String(diffToken)} 条`) check('白名单没有过期条目', missingOverrides.length === 0, missingOverrides.length === 0 ? '全部仍然有效' : missingOverrides.slice(0, 5).join(' | ')) - check('与 Objects.txt 完全一致的记录数 = 528', same === 528, `${String(same)} 条`) + check('与 Objects.txt 完全一致的记录数 = 532', same === 532, `${String(same)} 条`) check('表说无 token 的记录数 = 193', emptyToken === 193, `${String(emptyToken)} 条`) - check('行号缺失的 3 条仍然带真 token', missingRow === 3 && missingRowTokens.sort().join(',') === '7C,PX,PY', + check('行号缺失的 1 条仍然带真 token', missingRow === 1 && missingRowTokens.sort().join(',') === '7C', `缺失行号的 token:${missingRowTokens.join(',')}`) const passed = checks.filter((entry: any) => entry.ok).length @@ -256,9 +254,9 @@ describe('verify-object-lookup.ts', () => { if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true); else vitestExpect(true).toBe(true); }); - test.skipIf(isSkip)('覆盖条数 = 26', () => { + test.skipIf(isSkip)('覆盖条数 = 24', () => { vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true); - let resKey = _results.findIndex(x => x.desc.startsWith('覆盖条数 = 26')); + let resKey = _results.findIndex(x => x.desc.startsWith('覆盖条数 = 24')); if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true); else vitestExpect(true).toBe(true); }); @@ -268,9 +266,9 @@ describe('verify-object-lookup.ts', () => { if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true); else vitestExpect(true).toBe(true); }); - test.skipIf(isSkip)('与 Objects.txt 完全一致的记录数 = 528', () => { + test.skipIf(isSkip)('与 Objects.txt 完全一致的记录数 = 532', () => { vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true); - let resKey = _results.findIndex(x => x.desc.startsWith('与 Objects.txt 完全一致的记录数 = 528')); + let resKey = _results.findIndex(x => x.desc.startsWith('与 Objects.txt 完全一致的记录数 = 532')); if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true); else vitestExpect(true).toBe(true); }); @@ -280,9 +278,9 @@ describe('verify-object-lookup.ts', () => { if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true); else vitestExpect(true).toBe(true); }); - test.skipIf(isSkip)('行号缺失的 3 条仍然带真 token', () => { + test.skipIf(isSkip)('行号缺失的 1 条仍然带真 token', () => { vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true); - let resKey = _results.findIndex(x => x.desc.startsWith('行号缺失的 3 条仍然带真 token')); + let resKey = _results.findIndex(x => x.desc.startsWith('行号缺失的 1 条仍然带真 token')); if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true); else vitestExpect(true).toBe(true); }); diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 61e2ff9..e90896c 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -465,16 +465,14 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { const cubeItem = { id: 'test5', code: 'cube', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 } expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox']) - // Tier 5: Dimension fallback for completely unknown items - const fallback1x1 = { id: 'test6', code: 'xyz', name: 'Mystery 1x1', invFile: 'nonexistent', width: 1, height: 1 } + // Unknown items without valid invFile or canonical code return null (no fake dimension fallback) + const fallback1x1 = { id: 'test6', code: 'unknown_code', name: 'Mystery 1x1', invFile: 'nonexistent', width: 1, height: 1 } const fb1 = resolveItemSpriteRect(fallback1x1, itemRects) - expect(fb1).not.toBeNull() - expect(fb1!.w).toBeGreaterThan(0) + expect(fb1).toBeNull() - const fallback2x4 = { id: 'test7', code: 'xyz', name: 'Mystery 2x4', invFile: 'nonexistent', width: 2, height: 4 } + const fallback2x4 = { id: 'test7', code: 'unknown_code', name: 'Mystery 2x4', invFile: 'nonexistent', width: 2, height: 4 } const fb2 = resolveItemSpriteRect(fallback2x4, itemRects) - expect(fb2).not.toBeNull() - expect(fb2!.w).toBeGreaterThan(0) + expect(fb2).toBeNull() }) it('maps all 27 canonical quests with authentic DC6 icons and Act 4 socket guard (Issue #140)', () => { diff --git a/tools/verify-play.ts b/tools/verify-play.ts index 313488d..8066176 100644 --- a/tools/verify-play.ts +++ b/tools/verify-play.ts @@ -526,6 +526,9 @@ async function main(): Promise { unitDrawablesCount: number loadedTilePages: number webglNonBlackRatio: number + missingArtValid: boolean + missingArtCount: number + missingArtEntries: unknown[] }>(`(async () => { const app = window.__d2Play; const fr = app.frontendRenderer; @@ -554,11 +557,18 @@ async function main(): Promise { break; } } - // Render additional frames so newly uploaded GPU tile pages are drawn into WebGL2 framebuffer + if (app.entityArtCache?.waitForInflight) { + await app.entityArtCache.waitForInflight(); + } + // Render additional frames so newly uploaded GPU tile pages and entity art are drawn into WebGL2 framebuffer for (let i = 0; i < 6; i++) { app.renderFrame(performance.now() + 3000 + i * 40); await new Promise((r) => setTimeout(r, 50)); } + if (app.entityArtCache?.waitForInflight) { + await app.entityArtCache.waitForInflight(); + app.renderFrame(performance.now() + 3500); + } const snap = app.getLastRenderSnapshot(); const viewCanvas = document.getElementById('view'); @@ -581,6 +591,7 @@ async function main(): Promise { webglNonBlackRatio = total > 0 ? nonBlack / total : 0; } + const missingArt = window.__d2MissingArt; return { gameName: st.gameName, inGameView: app.isInGameView(), @@ -599,6 +610,9 @@ async function main(): Promise { unitDrawablesCount: snap ? snap.entities.unitDrawables.length : 0, loadedTilePages: app.tileGpuCache.loadedPageCount, webglNonBlackRatio: Number(webglNonBlackRatio.toFixed(4)), + missingArtValid: Array.isArray(missingArt) && missingArt.length === 0, + missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1, + missingArtEntries: Array.isArray(missingArt) ? missingArt : [], }; })()`) @@ -613,7 +627,8 @@ async function main(): Promise { gate3.floorQuadsCount === 0 || gate3.unitDrawablesCount === 0 || gate3.loadedTilePages === 0 || - gate3.webglNonBlackRatio < 0.05 + gate3.webglNonBlackRatio < 0.05 || + !gate3.missingArtValid ) { throw new Error(`Gate 3 failed: ${JSON.stringify(gate3)}`) } @@ -902,7 +917,10 @@ async function main(): Promise { filteredGameRows: number selectedDetailNonEmpty: boolean fixtureReplays: Record - }>(`(() => { + missingArtValid: boolean + missingArtCount: number + missingArtEntries: unknown[] + }>(`(async () => { const app = window.__d2Play; app.inspector.setOpen(true); const liveCapturedRows = app.inspector.getAllRows().length; @@ -931,12 +949,20 @@ async function main(): Promise { fixtureReplays[name] = { totalRecords, unknownS2cCount }; } app.renderFrame(performance.now()); + if (app.entityArtCache?.waitForInflight) { + await app.entityArtCache.waitForInflight(); + app.renderFrame(performance.now() + 100); + } + const missingArt = window.__d2MissingArt; return { isOpen: app.inspector.isOpen(), liveCapturedRows, filteredGameRows, selectedDetailNonEmpty, fixtureReplays, + missingArtValid: Array.isArray(missingArt) && missingArt.length === 0, + missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1, + missingArtEntries: Array.isArray(missingArt) ? missingArt : [], }; })()`) console.log('[verify-play] Gate 7 (Packet Inspector & 4 .d2cap Fixtures Replay):', gate7) @@ -944,7 +970,8 @@ async function main(): Promise { !gate7.isOpen || gate7.liveCapturedRows === 0 || !gate7.selectedDetailNonEmpty || - Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) + Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) || + !gate7.missingArtValid ) { throw new Error(`Gate 7 failed: ${JSON.stringify(gate7)}`) }