feat(client): integrate on-demand EntityArtCache, object/NPC/flippy/animated-tile rendering, and remove drawSolid fallbacks (#540)

This commit is contained in:
troytt 2026-10-01 17:05:25 +00:00
parent 618fa1dfb1
commit 1b93827103
25 changed files with 1538 additions and 357 deletions

View File

@ -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<void>
@ -188,6 +191,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
const renderer = new SpriteRenderer(viewCanvas)
const lightGrid = new LightGrid()
const tileGpuCache = new TileAtlasGpuCache(renderer)
const entityArtCache = new EntityArtCache(renderer, '/assets')
const characterArtByToken = new Map<string, LoadedCharacter>()
const monsterArtMap = new Map<string, LoadedMonsterArt>()
@ -239,12 +243,16 @@ async function bootPlayApp(): Promise<PlayAppController> {
const dummyPalette = { rgb: new Uint8Array(768), size: 256 as const }
const assetsReadyPromise = (async (): Promise<void> => {
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<PlayAppController> {
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<PlayAppController> {
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<PlayAppController> {
lightingTargets: { lightGrid, renderer },
renderer,
tileGpuCache,
entityArtCache,
characterArtByToken,
monsterArtMap,
missileArtMap,
@ -888,6 +919,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
frontendRenderer,
renderer,
tileGpuCache,
entityArtCache,
lightGrid,
minimap,
assetsReadyPromise,

View File

@ -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<MonsterAnimationSheet> {
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<Record<string, AnimClipMeta>>
readonly glowClips?: Readonly<Record<string, AnimClipMeta>>
readonly scale?: number
readonly layerFailures: readonly MonsterLayerFailure[]
}

View File

@ -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<Record<string, AnimClipMeta>>
readonly glowClips?: Readonly<Record<string, AnimClipMeta>>
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<Record<string, AnimClipMeta>>
readonly modes?: Readonly<Record<string, AnimClipMeta>>
readonly glowClips?: Readonly<Record<string, AnimClipMeta>>
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<LoadedCharacter | null> {
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<string>()
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<LoadedObjectArt | null> {
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<Map<string, LoadedObjectArt>> {
const map = new Map<string, LoadedObjectArt>()
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<PackedEntityCatalogIndex | null> | null = null
private readonly monsterByClassIdCache = new Map<number, LoadedMonsterArt>()
private readonly monsterBySpecKeyCache = new Map<string, LoadedMonsterArt>()
private readonly monsterInflightBySpecKey = new Map<string, Promise<LoadedMonsterArt | null>>()
private readonly inflightMonsters = new Set<number>()
private readonly failedMonsters = new Set<number>()
private readonly objectByClassIdCache = new Map<string, LoadedObjectArt>()
private readonly objectBySlugCache = new Map<string, LoadedObjectArt>()
private readonly objectInflightBySlug = new Map<string, Promise<LoadedObjectArt | null>>()
private readonly inflightObjects = new Set<string>()
private readonly failedObjects = new Set<string>()
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<PackedEntityCatalogIndex | null> {
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<PackedEntityCatalogIndex | null> => {
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<void> {
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<LoadedMonsterArt | null> {
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
}
}

View File

@ -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',
)
}
}

View File

@ -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<string, MissingArtEntry>()
const missingArtList: MissingArtEntry[] = []
const dropStartMsByItemId = new Map<number, number>()
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<Record<number, string>> = {
0: 'skeleton1',
1: 'returned1',
@ -109,6 +172,51 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly<Record<number, string>> = {
520: 'cain5',
}
const PLAYER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
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<Record<number, string>> = {
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<Record<number, string>> = {
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<string, LoadedCharacter> | undefined
readonly monsterArtMap?: ReadonlyMap<string, LoadedMonsterArt> | undefined
readonly objectArtMap?: ReadonlyMap<string, LoadedObjectArt> | undefined
readonly missileArtMap?: ReadonlyMap<string, LoadedMissileArt> | undefined
readonly overlayArtMap?: ReadonlyMap<string, LoadedOverlayArt> | 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') {

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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`. */

View File

@ -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) {

View File

@ -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,
}
}

View File

@ -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<Record<string, string>> | 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<number, string | undefined> | 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),

View File

@ -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

View File

@ -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))
}

View File

@ -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

View File

@ -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()
}
})

View File

@ -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)

View File

@ -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', () => {

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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()
}
}
})
})

View File

@ -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()
})
})

View File

@ -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()
}
}
})
})

View File

@ -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<void> {
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<void> {
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);
});

View File

@ -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)', () => {

View File

@ -526,6 +526,9 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
filteredGameRows: number
selectedDetailNonEmpty: boolean
fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }>
}>(`(() => {
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<void> {
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<void> {
!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)}`)
}