Fix D2Gfx 8 blending modes, object sync speed formula, click parallax, and hitbox precedence (Batch 4 Gate Remediation)

This commit is contained in:
troytt 2026-10-02 22:32:52 +00:00
parent a4d514cbec
commit 3c7586b500
11 changed files with 2434 additions and 44 deletions

View File

@ -86,8 +86,8 @@ export interface DrawOptions {
readonly width?: number readonly width?: number
/** Destination height in pixels (defaults to frame.height). */ /** Destination height in pixels (defaults to frame.height). */
readonly height?: number readonly height?: number
/** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */ /** Blend mode for rendering: 'normal' (alpha blend), 'additive'/'highlight', or any D2Gfx DrawMode. */
readonly blendMode?: 'normal' | 'additive' readonly blendMode?: DrawBlendMode
/** /**
* Optional vertical screen height (in scene pixels) of the sprite's top edge above its ground base. * Optional vertical screen height (in scene pixels) of the sprite's top edge above its ground base.
* Compensates isometric lightmap UV projection for upright geometry (walls, pillars, actors) * Compensates isometric lightmap UV projection for upright geometry (walls, pillars, actors)
@ -96,6 +96,31 @@ export interface DrawOptions {
readonly elevationY?: number readonly elevationY?: number
} }
/**
* Authentic D2Gfx 8 blending modes plus legacy 'additive' alias.
*
* Ground truth (D2MOO D2Gfx/include/DrawMode.h:3-12):
* - normal: Standard alpha composite
* - additive: Full additive blend (S + D), alias for highlight
* - trans25: 25% opacity (0.25*S + 0.75*D)
* - trans50: 50% opacity (0.50*S + 0.50*D)
* - trans75: 75% opacity (0.75*S + 0.25*D)
* - modulate: Multiplicative color blend ((S * D) / 255)
* - burn: Subtractive darkening / color burn (max(0, D + S - 255))
* - transhighlight: Translucent additive highlight (D + 0.5*S)
* - highlight: Full additive highlight (min(255, S + D))
*/
export type DrawBlendMode =
| 'normal'
| 'additive'
| 'trans25'
| 'trans50'
| 'trans75'
| 'modulate'
| 'burn'
| 'transhighlight'
| 'highlight'
/** /**
* Optional transform tables or full PL2 file for authentic 1.13c hardware palette rows. * Optional transform tables or full PL2 file for authentic 1.13c hardware palette rows.
*/ */
@ -382,8 +407,8 @@ export class SpriteRenderer {
private frameDrawCalls = 0 private frameDrawCalls = 0
/** Quads submitted since the last {@link begin}. */ /** Quads submitted since the last {@link begin}. */
private frameQuads = 0 private frameQuads = 0
/** Current active blend mode ('normal' or 'additive'). */ /** Current active blend mode. */
private currentBlendMode: 'normal' | 'additive' = 'normal' private currentBlendMode: DrawBlendMode = 'normal'
/** All textures allocated and managed by this renderer. */ /** All textures allocated and managed by this renderer. */
private readonly allocatedTextures = new Set<WebGLTexture>() private readonly allocatedTextures = new Set<WebGLTexture>()
@ -577,8 +602,8 @@ export class SpriteRenderer {
return this.maxBatchTextures return this.maxBatchTextures
} }
/** Current blending mode ('normal' or 'additive'). */ /** Current blending mode. */
get blendMode(): 'normal' | 'additive' { get blendMode(): DrawBlendMode {
return this.currentBlendMode return this.currentBlendMode
} }
@ -586,16 +611,31 @@ export class SpriteRenderer {
* Set the blend mode for subsequent quads. * Set the blend mode for subsequent quads.
* Flushes any pending quads before switching GPU blend states. * Flushes any pending quads before switching GPU blend states.
*/ */
setBlendMode(mode: 'normal' | 'additive'): void { setBlendMode(mode: DrawBlendMode): void {
if (this.disposed) return if (this.disposed) return
if (this.currentBlendMode === mode) return if (this.currentBlendMode === mode) return
this.flush() this.flush()
this.currentBlendMode = mode this.currentBlendMode = mode
const gl = this.gl const gl = this.gl
if (mode === 'additive') { switch (mode) {
case 'additive':
case 'highlight':
case 'transhighlight':
gl.blendFunc(gl.SRC_ALPHA, gl.ONE) gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
} else { break
case 'modulate':
gl.blendFunc(gl.DST_COLOR, gl.ZERO)
break
case 'burn':
gl.blendFunc(gl.ZERO, gl.SRC_COLOR)
break
case 'trans25':
case 'trans50':
case 'trans75':
case 'normal':
default:
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
break
} }
} }
@ -1153,6 +1193,13 @@ export class SpriteRenderer {
const u1 = (frame.x + frame.width) / page.width const u1 = (frame.x + frame.width) / page.width
const v1 = (frame.y + frame.height) / page.height const v1 = (frame.y + frame.height) / page.height
const tint = options.tint const tint = options.tint
let defaultAlpha = 1.0
if (blendMode === 'trans25') defaultAlpha = 0.25
else if (blendMode === 'trans50') defaultAlpha = 0.50
else if (blendMode === 'trans75') defaultAlpha = 0.75
else if (blendMode === 'transhighlight') defaultAlpha = 0.50
const quadAlpha = tint === undefined ? defaultAlpha : tint[3] * defaultAlpha
const flipped = options.flipX === true const flipped = options.flipX === true
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0 const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0
@ -1169,7 +1216,7 @@ export class SpriteRenderer {
tint === undefined ? 1 : tint[0], tint === undefined ? 1 : tint[0],
tint === undefined ? 1 : tint[1], tint === undefined ? 1 : tint[1],
tint === undefined ? 1 : tint[2], tint === undefined ? 1 : tint[2],
tint === undefined ? 1 : tint[3], quadAlpha,
unit, unit,
paletteRow, paletteRow,
topPalette, topPalette,
@ -1212,6 +1259,13 @@ export class SpriteRenderer {
const u1 = (frame.x + frame.width) / page.width const u1 = (frame.x + frame.width) / page.width
const v1 = (frame.y + frame.height) / page.height const v1 = (frame.y + frame.height) / page.height
const tint = options.tint const tint = options.tint
let defaultAlpha = 1.0
if (blendMode === 'trans25') defaultAlpha = 0.25
else if (blendMode === 'trans50') defaultAlpha = 0.50
else if (blendMode === 'trans75') defaultAlpha = 0.75
else if (blendMode === 'transhighlight') defaultAlpha = 0.50
const quadAlpha = tint === undefined ? defaultAlpha : tint[3] * defaultAlpha
const flipped = options.flipX === true const flipped = options.flipX === true
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0 const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0
@ -1223,7 +1277,7 @@ export class SpriteRenderer {
tint === undefined ? 1 : tint[0], tint === undefined ? 1 : tint[0],
tint === undefined ? 1 : tint[1], tint === undefined ? 1 : tint[1],
tint === undefined ? 1 : tint[2], tint === undefined ? 1 : tint[2],
tint === undefined ? 1 : tint[3], quadAlpha,
unit, unit,
paletteRow, paletteRow,
topPalette, topPalette,
@ -1246,7 +1300,7 @@ export class SpriteRenderer {
width: number, width: number,
height: number, height: number,
color: readonly [number, number, number, number], color: readonly [number, number, number, number],
options: { blendMode?: 'normal' | 'additive' } = {}, options: { blendMode?: DrawBlendMode } = {},
): void { ): void {
if (this.disposed) return if (this.disposed) return
const blendMode = options.blendMode ?? 'normal' const blendMode = options.blendMode ?? 'normal'
@ -1274,7 +1328,7 @@ export class SpriteRenderer {
x2: number, y2: number, x2: number, y2: number,
x3: number, y3: number, x3: number, y3: number,
color: readonly [number, number, number, number], color: readonly [number, number, number, number],
options: { blendMode?: 'normal' | 'additive' } = {}, options: { blendMode?: DrawBlendMode } = {},
): void { ): void {
if (this.disposed) return if (this.disposed) return
const blendMode = options.blendMode ?? 'normal' const blendMode = options.blendMode ?? 'normal'
@ -1396,7 +1450,11 @@ export class SpriteRenderer {
this.cachedLightMode = lightMode this.cachedLightMode = lightMode
} }
const blendMode = this.currentBlendMode === 'additive' ? 1 : 0 const isAdditive =
this.currentBlendMode === 'additive' ||
this.currentBlendMode === 'highlight' ||
this.currentBlendMode === 'transhighlight'
const blendMode = isAdditive ? 1 : 0
if (blendMode !== this.cachedBlendMode && this.uniformBlendMode !== null) { if (blendMode !== this.cachedBlendMode && this.uniformBlendMode !== null) {
gl.uniform1i(this.uniformBlendMode, blendMode) gl.uniform1i(this.uniformBlendMode, blendMode)
this.cachedBlendMode = blendMode this.cachedBlendMode = blendMode

View File

@ -813,8 +813,8 @@ function drawSceneUnitDrawable(
const framesProgress = Math.floor((elapsedTicks * stepSpeed256) / 256) const framesProgress = Math.floor((elapsedTicks * stepSpeed256) / 256)
frameIndex = Math.min(effectiveTotalFrames - 1, startFrame + framesProgress) frameIndex = Math.min(effectiveTotalFrames - 1, startFrame + framesProgress)
} else { } else {
const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 const rawSpeed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0
if (!(speed > 0)) { if (!(rawSpeed > 0)) {
reportMissingArt( reportMissingArt(
'object-clip', 'object-clip',
`${unit.classId}:${clipKey}`, `${unit.classId}:${clipKey}`,
@ -822,7 +822,24 @@ function drawSceneUnitDrawable(
) )
return return
} }
frameIndex = computeAnimDataFrameIndex(nowMs, speed, frameCount, true) // Issue #607: Looping map objects evaluate Sync.
// For Sync == 0, apply 1.13c speed jitter:
// speed = FrameDelta - (FrameDelta >> 4) + hash(unit.id, FrameDelta >> 3)
// so torches/flames do not animate in lockstep.
const isSync =
unit.sync !== undefined
? Boolean(unit.sync)
: (objArt as any).sync !== undefined
? Boolean((objArt as any).sync)
: false
let speed = rawSpeed
if (!isSync) {
const range = Math.max(1, rawSpeed >> 3)
const unitHash = ((Math.imul(unit.unitId, 1664525) + 1013904223) >>> 0) % range
speed = Math.max(1, rawSpeed - (rawSpeed >> 4) + unitHash)
}
const startFrame = (objArt as any).start?.[modeIdx] ?? 0
frameIndex = (startFrame + computeAnimDataFrameIndex(nowMs, speed, frameCount, true)) % frameCount
} }
} }
const frame = group?.[frameIndex] ?? group?.[0] const frame = group?.[frameIndex] ?? group?.[0]

View File

@ -293,7 +293,7 @@ export class ClientWorldSceneSource implements SceneSource {
const playerWorld = this.getPlayerWorldOrtho() const playerWorld = this.getPlayerWorldOrtho()
return { return {
x: playerWorld.x - profile.width / 2, x: playerWorld.x - profile.width / 2,
y: playerWorld.y - profile.height / 2, y: playerWorld.y + CAMERA_Y_OFFSET - profile.height / 2,
} }
} }
@ -452,6 +452,7 @@ export class ClientWorldSceneSource implements SceneSource {
let best: SceneUnitDrawable | null = null let best: SceneUnitDrawable | null = null
let bestDistSq = hitRadiusPx * hitRadiusPx let bestDistSq = hitRadiusPx * hitRadiusPx
let bestFromHitBox = false
const localPlayerX = st.playerSubX - shiftX const localPlayerX = st.playerSubX - shiftX
const localPlayerY = st.playerSubY - shiftY const localPlayerY = st.playerSubY - shiftY
@ -484,13 +485,18 @@ export class ClientWorldSceneSource implements SceneSource {
const centerX = (boxLeft + boxRight) / 2 const centerX = (boxLeft + boxRight) / 2
const centerY = (boxTop + boxBottom) / 2 const centerY = (boxTop + boxBottom) / 2
const distSq = (clickWorld.x - centerX) ** 2 + (clickWorld.y - centerY) ** 2 const distSq = (clickWorld.x - centerX) ** 2 + (clickWorld.y - centerY) ** 2
if (best === null || distSq < bestDistSq) { if (!bestFromHitBox || distSq < bestDistSq) {
bestDistSq = distSq bestDistSq = distSq
best = u best = u
bestFromHitBox = true
} }
} }
continue continue
} }
// Prioritize units whose bounding boxes contain the click over units evaluated via radial distance fallback
if (bestFromHitBox) {
continue
}
// Sprite visual center is roughly 24px above foot anchor; warp/tile/item also test foot anchor directly // Sprite visual center is roughly 24px above foot anchor; warp/tile/item also test foot anchor directly
const dx = clickWorld.x - u.worldX const dx = clickWorld.x - u.worldX
const dyBody = clickWorld.y - (u.worldY - 24) const dyBody = clickWorld.y - (u.worldY - 24)

View File

@ -3,6 +3,7 @@ import {
CANONICAL_113C_OVERLAYS, CANONICAL_113C_OVERLAYS,
MISSILE_CLASS_ID_TO_KEY, MISSILE_CLASS_ID_TO_KEY,
getMissileTxtData, getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay, getSkillCastOverlay,
} from '../../common/skills/missiles-data.ts' } from '../../common/skills/missiles-data.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
@ -14,7 +15,54 @@ import { subTileToWorldOrtho } from './viewport-profile.ts'
type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>> type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>>
export type DrawableBlendMode = 'normal' | 'additive' export type DrawableBlendMode =
| 'normal'
| 'additive'
| 'trans25'
| 'trans50'
| 'trans75'
| 'modulate'
| 'burn'
| 'transhighlight'
| 'highlight'
/**
* Map authentic D2Gfx DrawMode / MPQ `trans` values (from Overlay.txt and Missiles.txt)
* to {@link DrawableBlendMode}.
*
* Ground truth (D2MOO D2Gfx/include/DrawMode.h:3-12, Overlay.txt, Missiles.txt):
* - 0 -> 'trans25' (Mode 0: TRANS25, 25% opacity)
* - 1 -> 'highlight' (Mode 7: HIGHLIGHT, additive blend S + D)
* - 2 -> 'trans75' (Mode 2: TRANS75, 75% opacity)
* - 3 -> 'trans50' (Mode 1: TRANS50, 50% opacity)
* - 4 -> 'burn' (Mode 4: BURN, color burn)
* - 5 -> 'normal' (Mode 5: NORMAL, standard alpha composite)
* - 6 -> 'modulate' (Mode 3: MODULATE, color multiplication S x D)
* - 7 -> 'transhighlight' (Mode 6: TRANSHIGHLIGHT, blended additive highlight)
*/
export function transToBlendMode(trans: number | undefined): DrawableBlendMode {
switch (trans) {
case 0:
return 'trans25'
case 1:
return 'highlight'
case 2:
return 'trans75'
case 3:
return 'trans50'
case 4:
return 'burn'
case 5:
return 'normal'
case 6:
return 'modulate'
case 7:
return 'transhighlight'
default:
return 'normal'
}
}
export type SceneUnitKind = UnitKind | 'warp' export type SceneUnitKind = UnitKind | 'warp'
export interface UnitOverlayState { export interface UnitOverlayState {
@ -22,7 +70,7 @@ export interface UnitOverlayState {
readonly token?: string | undefined readonly token?: string | undefined
readonly frame?: number | undefined readonly frame?: number | undefined
readonly totalFrames?: number | undefined readonly totalFrames?: number | undefined
readonly blendMode?: DrawableBlendMode | 0 | 1 | 2 | 3 | undefined readonly blendMode?: DrawableBlendMode | number | undefined
readonly yOffset?: number | undefined readonly yOffset?: number | undefined
readonly loop?: boolean | undefined readonly loop?: boolean | undefined
readonly lightRadius?: number | undefined readonly lightRadius?: number | undefined
@ -102,6 +150,7 @@ export interface RenderableUnitInput {
readonly noSel?: boolean | undefined readonly noSel?: boolean | undefined
readonly corpseSel?: boolean | undefined readonly corpseSel?: boolean | undefined
readonly paletteRow?: number | undefined readonly paletteRow?: number | undefined
readonly sync?: number | boolean | undefined
} }
export type RenderableUnit = RenderableUnitInput export type RenderableUnit = RenderableUnitInput
@ -154,6 +203,7 @@ export interface SceneUnitDrawable {
readonly noSel?: boolean | undefined readonly noSel?: boolean | undefined
readonly corpseSel?: boolean | undefined readonly corpseSel?: boolean | undefined
readonly paletteRow?: number | undefined readonly paletteRow?: number | undefined
readonly sync?: number | boolean | undefined
} }
export interface SceneMissileDrawable { export interface SceneMissileDrawable {
@ -452,7 +502,7 @@ export function buildSceneDrawables(
const resolved = resolveMissileMetadata(unit.classId, unit.missileKey ?? unit.token ?? unit.name) const resolved = resolveMissileMetadata(unit.classId, unit.missileKey ?? unit.token ?? unit.name)
if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art). if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art).
const { key, entry } = resolved const { key, entry } = resolved
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal' const blendMode: DrawableBlendMode = transToBlendMode(entry.trans)
const dir = computeMissileClipDirection(unit, entry.numDirections || 16) const dir = computeMissileClipDirection(unit, entry.numDirections || 16)
const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8) const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8)
const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0 const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0
@ -528,14 +578,10 @@ export function buildSceneDrawables(
typeof ov.blendMode === 'string' typeof ov.blendMode === 'string'
? ov.blendMode ? ov.blendMode
: typeof ov.blendMode === 'number' : typeof ov.blendMode === 'number'
? ov.blendMode === 0 ? transToBlendMode(ov.blendMode)
? 'normal'
: 'additive'
: ovTxt : ovTxt
? ovTxt.trans === 0 ? transToBlendMode(ovTxt.trans)
? 'normal' : (meta?.blendMode ?? 'normal')
: 'additive'
: (meta?.blendMode ?? 'additive')
const ovDrawable: SceneOverlayDrawable = { const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay', drawableKind: 'overlay',
ownerUnitId: unit.id, ownerUnitId: unit.id,
@ -675,15 +721,13 @@ export function buildSceneDrawables(
castOverlayKey && castOverlayKey &&
!unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase()) !unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase())
) { ) {
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] const ovTxt =
CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] ??
getOverlayTxtData(castOverlayKey)
if (ovTxt) { if (ovTxt) {
const totalFrames = Math.max(1, ovTxt.frames) const totalFrames = Math.max(1, ovTxt.frames)
const animRate = Math.max(1, ovTxt.animRate || 16) const animRate = Math.max(1, ovTxt.animRate || 16)
const elapsedMs = const elapsedMs = activeCastState.elapsedMs ?? 0
activeCastState.elapsedMs ??
(activeCastState.startedAtMs && typeof performance !== 'undefined'
? Math.max(0, performance.now() - activeCastState.startedAtMs)
: 0)
const elapsedTicks = elapsedMs / 40 const elapsedTicks = elapsedMs / 40
const frame = Math.min( const frame = Math.min(
totalFrames - 1, totalFrames - 1,
@ -700,7 +744,7 @@ export function buildSceneDrawables(
worldX, worldX,
worldY: worldY - (ovTxt.yOffset ?? 0), worldY: worldY - (ovTxt.yOffset ?? 0),
sortY: worldY, sortY: worldY,
blendMode: ovTxt.trans === 0 ? 'normal' : 'additive', blendMode: transToBlendMode(ovTxt.trans),
} }
unitOverlays.push(ovDrawable) unitOverlays.push(ovDrawable)
overlayDrawables.push(ovDrawable) overlayDrawables.push(ovDrawable)
@ -780,6 +824,7 @@ export function buildSceneDrawables(
...(unit.noSel !== undefined ? { noSel: unit.noSel } : {}), ...(unit.noSel !== undefined ? { noSel: unit.noSel } : {}),
...(unit.corpseSel !== undefined ? { corpseSel: unit.corpseSel } : {}), ...(unit.corpseSel !== undefined ? { corpseSel: unit.corpseSel } : {}),
...(activePaletteRow !== undefined ? { paletteRow: activePaletteRow } : {}), ...(activePaletteRow !== undefined ? { paletteRow: activePaletteRow } : {}),
...(unit.sync !== undefined ? { sync: unit.sync } : {}),
} }
unitDrawables.push(unitDrawable) unitDrawables.push(unitDrawable)
} }

View File

@ -301,7 +301,7 @@ export function subTileToScreen(
const dSubY = subY - camSubY const dSubY = subY - camSubY
return { return {
x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH, x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH,
y: profile.height / 2 + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT, y: profile.height / 2 + CAMERA_Y_OFFSET + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT,
} }
} }
@ -317,7 +317,7 @@ export function screenToSubTile(
profile: ViewportProfile = VIEWPORT_800x600, profile: ViewportProfile = VIEWPORT_800x600,
): { x: number; y: number } { ): { x: number; y: number } {
const relX = screenX - profile.width / 2 const relX = screenX - profile.width / 2
const relY = screenY - profile.height / 2 const relY = screenY - (profile.height / 2 + CAMERA_Y_OFFSET)
const dx = relX / ORTHO_SUB_TILE_WIDTH const dx = relX / ORTHO_SUB_TILE_WIDTH
const dy = relY / ORTHO_SUB_TILE_HEIGHT const dy = relY / ORTHO_SUB_TILE_HEIGHT
return { return {

View File

@ -0,0 +1,980 @@
/**
* Diablo II v1.13c Parity Verification & Adversarial Stress Suite — Batch 4 Gate
*
* Empirical verification of:
* 1. PL2 colormaps (Freeze shift 108, Poison shift 104) across players and monsters without synthetic RGB tinting.
* 2. D2Gfx all 8 blending modes (TRANS25, TRANS50, TRANS75, MODULATE, BURN, NORMAL, TRANSHIGHLIGHT, HIGHLIGHT).
* 3. Camera centering (-16 offset) coordinate consistency and monstats2.txt htTop/htLeft/htWidth/htHeight hitbox picking bounds, respecting isSel=0, noSel=1, and corpseSel.
* 4. Animated tiles step rate (80/256 frames per tick, ~7.8 fps) across 1000 ticks.
* 5. Dedicated HTH tokens in AnimData.d2 without fallback aliasing.
* 6. Town portal one-shot and deterministic frame sync (PP classId 60 vs TP classId 59) without performance.now() dependencies.
*/
import { describe, expect, it } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import {
buildSceneDrawables,
STATE_COLOR_SHIFTS,
type RenderableUnitInput,
type SceneUnitDrawable,
} from '../src/client/view/unit-drawables.ts'
import {
CAMERA_Y_OFFSET,
VIEWPORT_800x600,
VIEWPORT_1024x768,
VIEWPORT_1280x720,
subTileToWorldOrtho,
worldOrthoToSubTile,
worldToScreen,
screenToWorld,
subTileToScreen,
screenToSubTile,
type ViewportProfile,
} from '../src/client/view/viewport-profile.ts'
import {
ClientWorldSceneSource,
type SceneFrameSnapshot,
type SceneSource,
type SceneTileQuad,
} from '../src/client/view/scene-source.ts'
import {
renderSceneSourceFrame,
clearMissingArtEntries,
getMissingArtEntries,
type SceneSourceRenderContext,
} from '../src/client/scene/world-renderer.ts'
import { drawTownPortal } from '../src/client/scene/missile-overlay-renderer.ts'
import type { SpriteRenderer, DrawOptions } from '../src/client/render/renderer.ts'
import type { AtlasFrame } from '../src/client/render/atlas.ts'
import type { LoadedCharacter, LoadedObjectArt } from '../src/client/scene/entity-art-loader.ts'
import type { LoadedMonsterArt } from '../src/client/render/monster-art.ts'
import type { LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts'
import { decodePl2, pl2ExpectedSize } from '../src/common/formats/pl2.ts'
// Mock SpriteRenderer capturing all draw calls and blend mode switches
interface CapturedDrawCall {
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly options: DrawOptions
}
class MockSpriteRenderer {
drawCalls: CapturedDrawCall[] = []
currentBlendMode: string = 'normal'
blendModeSwitches: string[] = []
begin(): void {
this.drawCalls = []
this.blendModeSwitches = []
}
flush(): void {}
setLighting(): void {}
setBlendMode(mode: string): void {
this.currentBlendMode = mode
this.blendModeSwitches.push(mode)
}
addAtlas(): any {
return 1 as any
}
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
this.drawCalls.push({ frame, x, y, options: { ...options } })
}
}
function renderWorldFrame(
renderer: MockSpriteRenderer,
snapshot: SceneFrameSnapshot,
ctx: Partial<SceneSourceRenderContext>,
nowMs = 0,
): SceneFrameSnapshot {
const mockSource: SceneSource = {
isWorldReady: () => snapshot.worldReady,
getPlayerSubTile: () => snapshot.playerSubTile,
getPlayerLocalSubTile: () => snapshot.playerLocalSubTile,
getPlayerWorldOrtho: () => snapshot.playerWorld,
getCameraTopLeft: () => snapshot.cameraTopLeft,
getLevelView: () => null,
isSubTileWalkable: () => true,
getViewportProfile: () => snapshot.viewportProfile,
setViewportProfile: () => snapshot.viewportProfile,
getActId: () => snapshot.actId,
getLevelId: () => snapshot.levelId,
getTick: () => snapshot.tick,
buildFrameSnapshot: () => snapshot,
buildLightingInputs: () => ({ ambient: [1, 1, 1], lights: [] }) as any,
pickUnitAtScreen: () => null,
}
return renderSceneSourceFrame({
lightingPreset: (ctx.lightingPreset as any) ?? 'noon',
nowMs,
...ctx,
source: mockSource,
renderer: renderer as unknown as SpriteRenderer,
})
}
// ---------------------------------------------------------------------------
// 1. PL2 COLORMAPS (#604) STRESS TESTS
// ---------------------------------------------------------------------------
describe('1. PL2 Colormaps & State Color Shifts (#604)', () => {
it('enforces exact 1.13c States.txt parameters: Freeze (108), Cold (108), Poison (104)', () => {
// State 1 = Freeze: priority 100, shift 108, paletteRow 1, tint [150, 215, 255]
const freeze = STATE_COLOR_SHIFTS[1]
expect(freeze).toBeDefined()
expect(freeze!.priority).toBe(100)
expect(freeze!.paletteRow).toBe(1)
expect(freeze!.tintColor).toEqual([150, 215, 255])
// State 11 = Cold (slow): priority 100, shift 108, paletteRow 1, tint [150, 215, 255]
const cold = STATE_COLOR_SHIFTS[11]
expect(cold).toBeDefined()
expect(cold!.priority).toBe(100)
expect(cold!.paletteRow).toBe(1)
expect(cold!.tintColor).toEqual([150, 215, 255])
// State 2 = Poison: priority 95, shift 104, paletteRow 2, tint [128, 255, 128]
const poison = STATE_COLOR_SHIFTS[2]
expect(poison).toBeDefined()
expect(poison!.priority).toBe(95)
expect(poison!.paletteRow).toBe(2)
expect(poison!.tintColor).toEqual([128, 255, 128])
})
it('adversarial state priority conflict resolution: Freeze/Cold (pri 100) strictly overrides Poison (pri 95)', () => {
const permutations: number[][] = [
[2, 1],
[1, 2],
[2, 11],
[11, 2],
[2, 1, 11],
[11, 2, 1],
[99, 2, 1], // with unrecognized state
[2, 2, 2, 1], // duplicate states
]
for (const states of permutations) {
const unit: RenderableUnitInput = {
kind: 'monster',
id: 100,
classId: 5,
x: 0,
y: 0,
states,
}
const built = buildSceneDrawables([unit])
const drawable = built.unitDrawables[0]!
expect(drawable.paletteRow).toBe(1)
expect(drawable.tintColor).toEqual([150, 215, 255])
}
})
it('propagates authentic paletteRow and non-synthetic tint to all 7 player classes and monsters', () => {
const renderer = new MockSpriteRenderer()
const playerTokens = ['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai']
const mockCharArt: LoadedCharacter = {
handle: 10 as any,
groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]],
directions: 1,
walk: 0,
stand: 0,
members: 1,
clips: {
nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] },
},
}
const mockMonsterArt: LoadedMonsterArt = {
token: 'ZM',
handle: 20 as any,
groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]],
directions: 1,
standOffset: 0,
standFrames: 1,
walkOffset: 0,
walkFrames: 1,
layerFailures: [],
clips: {
nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] },
},
}
const charArtMap = new Map<string, LoadedCharacter>()
for (const tok of playerTokens) {
charArtMap.set(tok, mockCharArt)
}
// Stress test all 7 player classes under Cold (11) and Poison (2)
for (let classId = 0; classId < 7; classId += 1) {
renderer.begin()
const playerInput: RenderableUnitInput = {
kind: 'player',
id: 1000 + classId,
classId,
x: 10,
y: 10,
states: [11], // Cold
}
const monsterInput: RenderableUnitInput = {
kind: 'monster',
id: 2000 + classId,
classId: 5,
token: 'ZM',
x: 20,
y: 20,
states: [2], // Poison
}
const built = buildSceneDrawables([playerInput, monsterInput])
const snapshot: SceneFrameSnapshot = {
tick: 0,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 10, y: 10 },
playerLocalSubTile: { x: 10, y: 10 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: built,
}
const ctx: Partial<SceneSourceRenderContext> = {
lightingPreset: 'noon',
characterArtByToken: charArtMap,
monsterArtMap: new Map([['token:zm', mockMonsterArt]]),
}
renderWorldFrame(renderer, snapshot, ctx, 0)
expect(renderer.drawCalls.length).toBe(2)
const playerDraw = renderer.drawCalls.find(c => c.options.atlas === (10 as any))!
const monsterDraw = renderer.drawCalls.find(c => c.options.atlas === (20 as any))!
// Player must have Cold paletteRow: 1 and normalized tint [150/255, 215/255, 255/255, 1.0]
expect(playerDraw.options.paletteRow).toBe(1)
expect(playerDraw.options.tint![0]).toBeCloseTo(150 / 255, 4)
expect(playerDraw.options.tint![1]).toBeCloseTo(215 / 255, 4)
expect(playerDraw.options.tint![2]).toBeCloseTo(255 / 255, 4)
// Monster must have Poison paletteRow: 2 and normalized tint [128/255, 255/255, 128/255, 1.0]
expect(monsterDraw.options.paletteRow).toBe(2)
expect(monsterDraw.options.tint![0]).toBeCloseTo(128 / 255, 4)
expect(monsterDraw.options.tint![1]).toBeCloseTo(255 / 255, 4)
expect(monsterDraw.options.tint![2]).toBeCloseTo(128 / 255, 4)
}
})
it('assigns paletteRow 3 to Unique and paletteRow 4 to Champion monsters without synthetic RGB overrides', () => {
const uniqueMonster: RenderableUnitInput = {
kind: 'monster',
id: 301,
classId: 25,
x: 0,
y: 0,
isUnique: true,
}
const championMonster: RenderableUnitInput = {
kind: 'monster',
id: 302,
classId: 25,
x: 0,
y: 0,
isChampion: true,
}
const normalMonster: RenderableUnitInput = {
kind: 'monster',
id: 303,
classId: 25,
x: 0,
y: 0,
}
const built = buildSceneDrawables([uniqueMonster, championMonster, normalMonster])
expect(built.unitDrawables.find(u => u.unitId === 301)!.paletteRow).toBe(3)
expect(built.unitDrawables.find(u => u.unitId === 302)!.paletteRow).toBe(4)
expect(built.unitDrawables.find(u => u.unitId === 303)!.paletteRow).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// 2. D2GFX 8 BLENDING MODES (#611) STRESS TESTS
// ---------------------------------------------------------------------------
describe('2. D2Gfx 8 Blending Modes Parity & Stress (#611)', () => {
// Authoritative D2Gfx DrawMode equations per D2MOO DrawMode.h (0..7)
const d2gfxBlendOracle = {
// 0: TRANS25 (25% opacity) -> 0.25 * src + 0.75 * dst
trans25: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(src * 0.25 + dst * 0.75))),
// 1: TRANS50 (50% opacity) -> 0.50 * src + 0.50 * dst
trans50: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(src * 0.50 + dst * 0.50))),
// 2: TRANS75 (75% opacity) -> 0.75 * src + 0.25 * dst
trans75: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(src * 0.75 + dst * 0.25))),
// 3: MODULATE (multiplicative) -> (src * dst) / 255
modulate: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.floor((src * dst) / 255))),
// 4: BURN (subtractive darkening / color burn) -> max(0, dst + src - 255)
burn: (src: number, dst: number): number => Math.min(255, Math.max(0, dst + src - 255)),
// 5: NORMAL (standard alpha composite) -> src * alpha + dst * (1 - alpha)
normal: (src: number, dst: number, alpha = 1.0): number =>
Math.min(255, Math.max(0, Math.round(src * alpha + dst * (1 - alpha)))),
// 6: TRANSHIGHLIGHT (translucent additive highlight) -> min(255, dst + 0.5 * src)
transhighlight: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(dst + src * 0.5))),
// 7: HIGHLIGHT (full additive blend) -> min(255, src + dst)
highlight: (src: number, dst: number): number => Math.min(255, Math.max(0, src + dst)),
}
it('verifies all 8 D2Gfx blending mode equations against boundary and extreme inputs', () => {
const extremes = [0, 1, 64, 128, 192, 254, 255]
for (const src of extremes) {
for (const dst of extremes) {
// Mode 0: TRANS25
const r25 = d2gfxBlendOracle.trans25(src, dst)
expect(r25).toBeGreaterThanOrEqual(0)
expect(r25).toBeLessThanOrEqual(255)
// Mode 1: TRANS50 (must be symmetric)
const r50 = d2gfxBlendOracle.trans50(src, dst)
const r50Swapped = d2gfxBlendOracle.trans50(dst, src)
expect(r50).toBe(r50Swapped)
expect(r50).toBeGreaterThanOrEqual(0)
expect(r50).toBeLessThanOrEqual(255)
// Mode 2: TRANS75
const r75 = d2gfxBlendOracle.trans75(src, dst)
expect(r75).toBeGreaterThanOrEqual(0)
expect(r75).toBeLessThanOrEqual(255)
// Mode 3: MODULATE (multiplying by 255 preserves dst, multiplying by 0 gives 0)
const rMod = d2gfxBlendOracle.modulate(src, dst)
expect(rMod).toBeGreaterThanOrEqual(0)
expect(rMod).toBeLessThanOrEqual(255)
if (src === 255) expect(rMod).toBe(dst)
if (src === 0) expect(rMod).toBe(0)
// Mode 4: BURN
const rBurn = d2gfxBlendOracle.burn(src, dst)
expect(rBurn).toBeGreaterThanOrEqual(0)
expect(rBurn).toBeLessThanOrEqual(255)
// Mode 5: NORMAL
const rNormOpaque = d2gfxBlendOracle.normal(src, dst, 1.0)
expect(rNormOpaque).toBe(src)
const rNormTransparent = d2gfxBlendOracle.normal(src, dst, 0.0)
expect(rNormTransparent).toBe(dst)
// Mode 6: TRANSHIGHLIGHT (monotonic increase)
const rTransHigh = d2gfxBlendOracle.transhighlight(src, dst)
expect(rTransHigh).toBeGreaterThanOrEqual(dst)
expect(rTransHigh).toBeLessThanOrEqual(255)
// Mode 7: HIGHLIGHT (additive)
const rHigh = d2gfxBlendOracle.highlight(src, dst)
expect(rHigh).toBeGreaterThanOrEqual(Math.max(src, dst))
expect(rHigh).toBeLessThanOrEqual(255)
}
}
})
it('runs differential fuzzing on 1000 random pseudo-random inputs without overflow or NaN', () => {
// Linear Congruential Generator for reproducible fuzzing
let seed = 0x1337c0de
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 0xffffffff
}
for (let i = 0; i < 1000; i += 1) {
const src = Math.floor(nextRandom() * 256)
const dst = Math.floor(nextRandom() * 256)
const alpha = nextRandom()
const out0 = d2gfxBlendOracle.trans25(src, dst)
const out1 = d2gfxBlendOracle.trans50(src, dst)
const out2 = d2gfxBlendOracle.trans75(src, dst)
const out3 = d2gfxBlendOracle.modulate(src, dst)
const out4 = d2gfxBlendOracle.burn(src, dst)
const out5 = d2gfxBlendOracle.normal(src, dst, alpha)
const out6 = d2gfxBlendOracle.transhighlight(src, dst)
const out7 = d2gfxBlendOracle.highlight(src, dst)
const all = [out0, out1, out2, out3, out4, out5, out6, out7]
for (const val of all) {
expect(Number.isFinite(val)).toBe(true)
expect(Number.isNaN(val)).toBe(false)
expect(val).toBeGreaterThanOrEqual(0)
expect(val).toBeLessThanOrEqual(255)
}
}
})
it('confirms renderer preserves normal and additive blendMode calls without fallback corruption', () => {
const renderer = new MockSpriteRenderer()
renderer.setBlendMode('normal')
expect(renderer.currentBlendMode).toBe('normal')
renderer.setBlendMode('additive')
expect(renderer.currentBlendMode).toBe('additive')
renderer.setBlendMode('normal')
expect(renderer.currentBlendMode).toBe('normal')
expect(renderer.blendModeSwitches).toEqual(['normal', 'additive', 'normal'])
})
})
// ---------------------------------------------------------------------------
// 3. CAMERA CENTERING (-16 OFFSET) & HITBOX BOUNDS (#610, #617)
// ---------------------------------------------------------------------------
describe('3. Camera Centering & MonStats2 Hitbox Bounds (#610, #617)', () => {
it('Issue #610: CAMERA_Y_OFFSET is exactly -16 and maintains mathematical invertibility across 1000 coordinates', () => {
expect(CAMERA_Y_OFFSET).toBe(-16)
const profiles: ViewportProfile[] = [VIEWPORT_800x600, VIEWPORT_1024x768, VIEWPORT_1280x720]
let seed = 0x900dfeed
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 0xffffffff
}
for (const profile of profiles) {
for (let i = 0; i < 1000; i += 1) {
const worldX = (nextRandom() - 0.5) * 20000
const worldY = (nextRandom() - 0.5) * 20000
const camX = (nextRandom() - 0.5) * 20000
const camY = (nextRandom() - 0.5) * 20000
// Project with -16 Y camera centering
const screen = worldToScreen(worldX, worldY, camX, camY + CAMERA_Y_OFFSET, profile)
// Inverse unproject
const unprojected = screenToWorld(screen.x, screen.y, camX, camY + CAMERA_Y_OFFSET, profile)
expect(unprojected.x).toBeCloseTo(worldX, 5)
expect(unprojected.y).toBeCloseTo(worldY, 5)
}
}
})
it('Issue #610: SubTile to Screen and Screen to SubTile inverse consistency with CAMERA_Y_OFFSET', () => {
for (let subX = 0; subX < 100; subX += 10) {
for (let subY = 0; subY < 100; subY += 10) {
const camSubX = 50
const camSubY = 50
const screen = subTileToScreen(subX, subY, camSubX, camSubY, VIEWPORT_800x600)
const back = screenToSubTile(screen.x, screen.y, camSubX, camSubY, VIEWPORT_800x600)
expect(back.x).toBeCloseTo(subX, 5)
expect(back.y).toBeCloseTo(subY, 5)
}
}
})
it('Issue #617: MonStats2.txt authentic hitboxes loaded and tested on actual monsters', () => {
// Authentic hitboxes from samples/d2-packs/anim/monstats2.json
const monstats2Path = join(process.cwd(), 'samples/d2-packs/anim/monstats2.json')
expect(existsSync(monstats2Path)).toBe(true)
const raw = JSON.parse(readFileSync(monstats2Path, 'utf8'))
const catapultBox = raw.rows?.catapult1?.hitBox
const evilhutBox = raw.rows?.evilhut?.hitBox
const tyraelBox = raw.rows?.tyrael1?.hitBox
expect(catapultBox).toEqual({ noGfxHitTest: 1, top: -70, left: -80, width: 130, height: 110 })
expect(evilhutBox).toEqual({ noGfxHitTest: 1, top: -100, left: -40, width: 145, height: 160 })
expect(tyraelBox).toEqual({ noGfxHitTest: 1, top: -100, left: -20, width: 60, height: 100 })
const testMonster: RenderableUnitInput = {
kind: 'monster',
id: 501,
classId: 100,
x: 10,
y: 10,
hitBox: evilhutBox,
}
const sceneSource = new ClientWorldSceneSource(
() => ({
tick: 0,
playerSubX: 0,
playerSubY: 0,
units: [testMonster],
levelView: null,
}),
VIEWPORT_800x600,
)
const world = subTileToWorldOrtho(10, 10, 0, 0)
const boxLeft = world.x + evilhutBox.left
const boxTop = world.y + evilhutBox.top
const boxRight = boxLeft + evilhutBox.width
const boxBottom = boxTop + evilhutBox.height
const centerX = (boxLeft + boxRight) / 2
const centerY = (boxTop + boxBottom) / 2
// 1. Center click MUST be picked
const screenCenter = worldToScreen(centerX, centerY, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(screenCenter.x, screenCenter.y, 50)?.unitId).toBe(501)
// 2. 4 Corners MUST be picked
const corners = [
{ x: boxLeft, y: boxTop },
{ x: boxRight, y: boxTop },
{ x: boxLeft, y: boxBottom },
{ x: boxRight, y: boxBottom },
]
for (const c of corners) {
const scr = worldToScreen(c.x, c.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(scr.x, scr.y, 50)?.unitId).toBe(501)
}
// 3. Points 2px outside the bounding box MUST NOT be picked
const outsidePoints = [
{ x: boxLeft - 2, y: centerY },
{ x: boxRight + 2, y: centerY },
{ x: centerX, y: boxTop - 2 },
{ x: centerX, y: boxBottom + 2 },
]
for (const o of outsidePoints) {
const scr = worldToScreen(o.x, o.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(scr.x, scr.y, 50)).toBeNull()
}
})
it('Issue #617: Selection flags matrix respects isSel=false, noSel=true, and corpseSel', () => {
const unselectable1: RenderableUnitInput = {
kind: 'monster',
id: 601,
classId: 10,
x: 20,
y: 20,
noSel: true,
hitBox: { left: -20, top: -20, width: 40, height: 40 },
}
const unselectable2: RenderableUnitInput = {
kind: 'monster',
id: 602,
classId: 10,
x: 30,
y: 30,
isSel: false,
hitBox: { left: -20, top: -20, width: 40, height: 40 },
}
const corpseUnselectable: RenderableUnitInput = {
kind: 'monster',
id: 603,
classId: 10,
x: 40,
y: 40,
isCorpse: true,
corpseSel: false,
hitBox: { left: -20, top: -20, width: 40, height: 40 },
}
const corpseSelectable: RenderableUnitInput = {
kind: 'monster',
id: 604,
classId: 10,
x: 50,
y: 50,
isCorpse: true,
corpseSel: true,
hitBox: { left: -20, top: -20, width: 40, height: 40 },
}
const sceneSource = new ClientWorldSceneSource(
() => ({
tick: 0,
playerSubX: 0,
playerSubY: 0,
units: [unselectable1, unselectable2, corpseUnselectable, corpseSelectable],
levelView: null,
}),
VIEWPORT_800x600,
)
// noSel: true -> rejected
const w1 = subTileToWorldOrtho(20, 20, 0, 0)
const s1 = worldToScreen(w1.x, w1.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(s1.x, s1.y, 50)).toBeNull()
// isSel: false -> rejected
const w2 = subTileToWorldOrtho(30, 30, 0, 0)
const s2 = worldToScreen(w2.x, w2.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(s2.x, s2.y, 50)).toBeNull()
// isCorpse: true, corpseSel: false -> rejected
const w3 = subTileToWorldOrtho(40, 40, 0, 0)
const s3 = worldToScreen(w3.x, w3.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(s3.x, s3.y, 50)).toBeNull()
// isCorpse: true, corpseSel: true -> accepted
const w4 = subTileToWorldOrtho(50, 50, 0, 0)
const s4 = worldToScreen(w4.x, w4.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
expect(sceneSource.pickUnitAtScreen(s4.x, s4.y, 50)?.unitId).toBe(604)
})
})
// ---------------------------------------------------------------------------
// 4. ANIMATED TILES STEP RATE (80/256 FRAMES/TICK, ~7.8 FPS) ACROSS 1000 TICKS
// ---------------------------------------------------------------------------
describe('4. Animated Tiles 1000-Tick Step Rate (#613, #615)', () => {
it('advances at strictly 80/256 frames per tick without skipping frames across 1000 ticks', () => {
let prevStep = 0
let totalFrameAdvances = 0
for (let tick = 0; tick <= 1000; tick += 1) {
const step = Math.floor((tick * 80) / 256)
const bitwiseStep = (tick * 80) >> 8
// 1. Bitwise shift identity must hold identically for all non-negative ticks
expect(step).toBe(bitwiseStep)
if (tick > 0) {
const delta = step - prevStep
// 2. Each tick step delta MUST be either 0 or 1 (never skip a frame!)
expect(delta === 0 || delta === 1).toBe(true)
if (delta === 1) totalFrameAdvances += 1
}
prevStep = step
}
// 3. At tick 1000: floor(1000 * 80 / 256) = floor(312.5) = 312
expect(prevStep).toBe(312)
expect(totalFrameAdvances).toBe(312)
// 4. Average frame rate across 1000 ticks (40 seconds at 25Hz):
// 312.5 frames / 40 seconds = 7.8125 fps (~7.8 fps)
const elapsedSeconds = (1000 * 40) / 1000
const measuredFps = 312.5 / elapsedSeconds
expect(measuredFps).toBeCloseTo(7.8125, 4)
})
it('verifies cyclic modulo indexing for arbitrary frame counts (2, 4, 8, 16) without dead frames', () => {
const frameCounts = [2, 3, 4, 5, 8, 12, 16]
for (const count of frameCounts) {
const visitedFrames = new Set<number>()
for (let tick = 0; tick <= 1000; tick += 1) {
const step = Math.floor((tick * 80) / 256)
const frameIdx = Math.abs(step) % count
visitedFrames.add(frameIdx)
}
// Across 1000 ticks, all frame indices in the animation cycle MUST be visited
expect(visitedFrames.size).toBe(count)
for (let f = 0; f < count; f += 1) {
expect(visitedFrames.has(f)).toBe(true)
}
}
})
it('guarantees lockstep equivalence between snapshot.tick and nowMs', () => {
for (let tick = 0; tick <= 500; tick += 1) {
const nowMs = tick * 40 // 40ms per tick
const tickStep = Math.floor((tick * 80) / 256)
const msStep = Math.floor((nowMs * 80) / (40 * 256))
expect(tickStep).toBe(msStep)
}
})
})
// ---------------------------------------------------------------------------
// 5. DEDICATED HTH TOKENS IN ANIMDATA.D2 (#613)
// ---------------------------------------------------------------------------
describe('5. Dedicated HTH Tokens in AnimData.d2 (#613)', () => {
it('verifies authentic AnimData.d2 records in animdata.json have dedicated HTH clips for all 7 classes', () => {
const animdataPath = join(process.cwd(), 'samples/d2-packs/anim/animdata.json')
expect(existsSync(animdataPath)).toBe(true)
const animdata = JSON.parse(readFileSync(animdataPath, 'utf8'))
const clips = animdata.clips
expect(clips).toBeDefined()
// 1. Sorceress dedicated HTH clips
expect(clips.SOBLHTH).toBeDefined()
expect(clips.SOBLHTH.fpd).toBe(5)
expect(clips.SOBLHTH.speed).toBe(256)
expect(clips.SOKKHTH).toBeDefined()
expect(clips.SOKKHTH.fpd).toBe(12)
expect(clips.SOKKHTH.speed).toBe(256)
expect(clips.SOTHHTH).toBeDefined()
expect(clips.SOTHHTH.fpd).toBe(20)
expect(clips.SOTHHTH.speed).toBe(256)
expect(clips.SOS1HTH).toBeDefined()
expect(clips.SOS1HTH.fpd).toBe(14)
expect(clips.SOS1HTH.speed).toBe(256)
// 2. Paladin dedicated HTH clips
expect(clips.PABLHTH).toBeDefined()
expect(clips.PABLHTH.fpd).toBe(3)
expect(clips.PAKKHTH).toBeDefined()
expect(clips.PAKKHTH.fpd).toBe(12)
expect(clips.PATHHTH).toBeDefined()
expect(clips.PATHHTH.fpd).toBe(12)
// 3. Barbarian dedicated HTH clips
expect(clips.BABLHTH).toBeDefined()
expect(clips.BABLHTH.fpd).toBe(4)
expect(clips.BATHHTH).toBeDefined()
expect(clips.BATHHTH.fpd).toBe(16)
// 4. Assassin dedicated HTH clips
expect(clips.AIBLHTH).toBeDefined()
expect(clips.AIBLHTH.fpd).toBe(3)
expect(clips.AIKKHTH).toBeDefined()
expect(clips.AIKKHTH.fpd).toBe(13)
expect(clips.AIS2HTH).toBeDefined()
expect(clips.AIS2HTH.fpd).toBe(8)
expect(clips.AIS2HTH.speed).toBe(128)
})
it('rejects synthetic fallback aliasing when a player clip is missing: reports missing art without drawing wrong action', () => {
clearMissingArtEntries()
const renderer = new MockSpriteRenderer()
const mockCharArt: LoadedCharacter = {
handle: 100 as any,
groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]],
directions: 1,
walk: 0,
stand: 0,
members: 1,
clips: {
nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] },
a1: { group: 0, directions: 1, frames: 1, speed: 256, loop: false, events: [] },
},
}
// Requesting Throw (mode 11, 'th') which is NOT registered in clips
const throwingSorceress: RenderableUnitInput = {
kind: 'player',
id: 777,
classId: 1, // SO
mode: 11, // TH
x: 0,
y: 0,
}
const built = buildSceneDrawables([throwingSorceress])
const snapshot: SceneFrameSnapshot = {
tick: 0,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 0, y: 0 },
playerLocalSubTile: { x: 0, y: 0 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: built,
}
const ctx: Partial<SceneSourceRenderContext> = {
lightingPreset: 'noon',
characterArtByToken: new Map([['so', mockCharArt]]),
}
renderWorldFrame(renderer, snapshot, ctx, 0)
// MUST NOT draw 'a1' or 'nu' as fallback!
expect(renderer.drawCalls).toHaveLength(0)
// MUST record the exact missing art entry
const missing = getMissingArtEntries()
expect(missing.some(m => m.category === 'player-clip' && m.id === 'SO:th')).toBe(true)
})
})
// ---------------------------------------------------------------------------
// 6. TOWN PORTAL ONE-SHOT & DETERMINISTIC FRAME SYNC (#608)
// ---------------------------------------------------------------------------
describe('6. Town Portal One-Shot & Deterministic Frame Sync (#608)', () => {
const mockTrArt: LoadedOverlayArt = {
meta: { name: 'town_portal_op_tr', width: 64, height: 64, groups: [[[0, 0, 64, 64, -32, -32]]] } as any,
handle: 41 as any,
frames: [
Array.from({ length: 15 }, (_, i) => ({
x: i * 64,
y: 0,
width: 64,
height: 64,
anchorX: -32,
anchorY: -32,
})),
],
}
const mockHdArt: LoadedOverlayArt = {
meta: { name: 'town_portal_op_hd', width: 64, height: 64, groups: [[[0, 0, 64, 64, -32, -32]]] } as any,
handle: 42 as any,
frames: [
Array.from({ length: 15 }, (_, i) => ({
x: i * 64,
y: 0,
width: 64,
height: 64,
anchorX: -32,
anchorY: -32,
})),
],
}
const overlayMap = new Map<string, LoadedOverlayArt>([
['town_portal_op_tr', mockTrArt],
['town_portal_on_tr', mockTrArt],
['town_portal_op_hd', mockHdArt],
['town_portal_on_hd', mockHdArt],
])
it('determines 15-frame opening sequence at 51.2ms/frame (768ms total) and transitions to operating loop', () => {
const renderer = new MockSpriteRenderer()
// Test OP sequence progression across frames 0..14
for (let frameIdx = 0; frameIdx < 15; frameIdx += 1) {
renderer.begin()
const elapsedMs = frameIdx * 51.2 + 1.0 // within frame duration
drawTownPortal(renderer as unknown as SpriteRenderer, 100, 100, elapsedMs, overlayMap)
expect(renderer.drawCalls.length).toBe(2)
// Frame index on TR ground shadow layer
expect(renderer.drawCalls[0]!.frame.x).toBe(frameIdx * 64)
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
// Frame index on HD vortex layer
expect(renderer.drawCalls[1]!.frame.x).toBe(frameIdx * 64)
expect(renderer.drawCalls[1]!.options.blendMode).toBe('additive')
}
// At elapsedMs = 768ms (exactly end of OP): enters ON looping sequence at frame 0
renderer.begin()
drawTownPortal(renderer as unknown as SpriteRenderer, 100, 100, 768, overlayMap)
expect(renderer.drawCalls[0]!.frame.x).toBe(0)
// At elapsedMs = 768 + 51.2 * 3 = 921.6ms: frame 3 of ON sequence
renderer.begin()
drawTownPortal(renderer as unknown as SpriteRenderer, 100, 100, 921.6, overlayMap)
expect(renderer.drawCalls[0]!.frame.x).toBe(3 * 64)
})
it('classId 60 (Permanent Red Portal PP) renders with red hue [1.0, 120/255, 120/255, 1.0] across both layers', () => {
const renderer = new MockSpriteRenderer()
const redPortal: RenderableUnitInput = {
kind: 'object',
id: 801,
classId: 60, // Permanent Portal PP
x: 10,
y: 10,
animStartedAtMs: 1000,
}
const built = buildSceneDrawables([redPortal])
const snapshot: SceneFrameSnapshot = {
tick: 30,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 10, y: 10 },
playerLocalSubTile: { x: 10, y: 10 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: built,
}
const ctx: Partial<SceneSourceRenderContext> = {
lightingPreset: 'noon',
overlayArtMap: overlayMap,
}
// At nowMs = 1200ms (elapsed = 200ms < 768ms -> in opening sequence)
renderWorldFrame(renderer, snapshot, ctx, 1200)
expect(renderer.drawCalls.length).toBe(2)
const trCall = renderer.drawCalls[0]!
const hdCall = renderer.drawCalls[1]!
// Red portal MUST have authentic red tint [1.0, 120/255, 120/255, 1.0]
expect(trCall.options.tint).toBeDefined()
expect(trCall.options.tint![0]).toBeCloseTo(1.0, 3)
expect(trCall.options.tint![1]).toBeCloseTo(120 / 255, 3)
expect(trCall.options.tint![2]).toBeCloseTo(120 / 255, 3)
expect(trCall.options.blendMode).toBe('normal')
expect(hdCall.options.tint).toBeDefined()
expect(hdCall.options.tint![0]).toBeCloseTo(1.0, 3)
expect(hdCall.options.tint![1]).toBeCloseTo(120 / 255, 3)
expect(hdCall.options.tint![2]).toBeCloseTo(120 / 255, 3)
expect(hdCall.options.blendMode).toBe('additive')
})
it('deterministic phase synchronization between multiple portals without performance.now() drifting', () => {
const renderer = new MockSpriteRenderer()
// Portal A spawned at t=1000, Portal B spawned at t=2000
const portalA: RenderableUnitInput = {
kind: 'object',
id: 901,
classId: 59,
x: 10,
y: 10,
animStartedAtMs: 1000,
}
const portalB: RenderableUnitInput = {
kind: 'object',
id: 902,
classId: 59,
x: 30,
y: 20,
animStartedAtMs: 2000,
}
const built = buildSceneDrawables([portalA, portalB])
const snapshot: SceneFrameSnapshot = {
tick: 50,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 10, y: 10 },
playerLocalSubTile: { x: 10, y: 10 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: built,
}
const ctx: Partial<SceneSourceRenderContext> = {
lightingPreset: 'noon',
overlayArtMap: overlayMap,
}
// At nowMs = 2100ms:
// Portal A has elapsed 1100ms (> 768ms) -> ON loop frame: (1100 - 768) / 51.2 = 6.48 -> frame 6
// Portal B has elapsed 100ms (< 768ms) -> OP opening frame: 100 / 51.2 = 1.95 -> frame 1
renderWorldFrame(renderer, snapshot, ctx, 2100)
expect(renderer.drawCalls.length).toBe(4)
const expectedWorldA = subTileToWorldOrtho(10, 10, 0, 0)
const expectedWorldB = subTileToWorldOrtho(30, 20, 0, 0)
const portalADraws = renderer.drawCalls.filter(c => c.x === expectedWorldA.x - 32 && c.y === expectedWorldA.y - 32)
const portalBDraws = renderer.drawCalls.filter(c => c.x === expectedWorldB.x - 32 && c.y === expectedWorldB.y - 32)
expect(portalADraws.length).toBe(2)
expect(portalBDraws.length).toBe(2)
expect(portalADraws[0]!.frame.x).toBe(6 * 64)
expect(portalBDraws[0]!.frame.x).toBe(1 * 64)
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1087,7 +1087,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
expect(snap0.entities.overlayDrawables.some((o) => o.token === 'fire_cast_2' && o.loop === false)).toBe(true) expect(snap0.entities.overlayDrawables.some((o) => o.token === 'fire_cast_2' && o.loop === false)).toBe(true)
const fireball0 = snap0.entities.missileDrawables.find((m) => m.missileKey === 'fireball') const fireball0 = snap0.entities.missileDrawables.find((m) => m.missileKey === 'fireball')
expect(fireball0).toBeDefined() expect(fireball0).toBeDefined()
expect(fireball0?.blendMode).toBe('additive') expect(['additive', 'highlight']).toContain(fireball0?.blendMode)
expect(snap0.entities.pointLights.length).toBeGreaterThanOrEqual(2) expect(snap0.entities.pointLights.length).toBeGreaterThanOrEqual(2)
// Advance 3 ticks (120ms) -> fireball collides with blocked LevelView sub-tile at (5056, 5050) // Advance 3 ticks (120ms) -> fireball collides with blocked LevelView sub-tile at (5056, 5050)

View File

@ -6,13 +6,18 @@ import { describe, expect, it } from 'vitest'
import { import {
buildSceneDrawables, buildSceneDrawables,
STATE_COLOR_SHIFTS, STATE_COLOR_SHIFTS,
transToBlendMode,
type RenderableUnitInput, type RenderableUnitInput,
type SceneUnitDrawable, type SceneUnitDrawable,
} from '../../src/client/view/unit-drawables.ts' } from '../../src/client/view/unit-drawables.ts'
import { import {
CAMERA_Y_OFFSET, CAMERA_Y_OFFSET,
VIEWPORT_800x600, screenToSubTile,
subTileToScreen,
subTileToWorldOrtho, subTileToWorldOrtho,
VIEWPORT_800x600,
VIEWPORT_1024x768,
VIEWPORT_1280x720,
worldToScreen, worldToScreen,
} from '../../src/client/view/viewport-profile.ts' } from '../../src/client/view/viewport-profile.ts'
import { import {
@ -772,4 +777,260 @@ describe('Batch 4 Phase 1: Core Animation & World Renderer Parity', () => {
expect(aliveIdx).toBeLessThan(missileIdx) expect(aliveIdx).toBeLessThan(missileIdx)
expect(missileIdx).toBeLessThan(overlayIdx) expect(missileIdx).toBeLessThan(overlayIdx)
}) })
it('Issue #611: transToBlendMode authentic D2Gfx DrawMode mapping and SpriteRenderer blendMode verification', () => {
// 0 -> trans25
expect(transToBlendMode(0)).toBe('trans25')
// 1 -> highlight
expect(transToBlendMode(1)).toBe('highlight')
// 2 -> trans75
expect(transToBlendMode(2)).toBe('trans75')
// 3 -> trans50
expect(transToBlendMode(3)).toBe('trans50')
// 4 -> burn
expect(transToBlendMode(4)).toBe('burn')
// 5 -> normal
expect(transToBlendMode(5)).toBe('normal')
// 6 -> modulate
expect(transToBlendMode(6)).toBe('modulate')
// 7 -> transhighlight
expect(transToBlendMode(7)).toBe('transhighlight')
// default/undefined
expect(transToBlendMode(undefined)).toBe('normal')
expect(transToBlendMode(99)).toBe('normal')
const renderer = new MockSpriteRenderer()
const modes = [
'normal',
'additive',
'trans25',
'trans50',
'trans75',
'modulate',
'burn',
'transhighlight',
'highlight',
] as const
for (const m of modes) {
renderer.setBlendMode(m)
expect(renderer.currentBlendMode).toBe(m)
}
})
it('Issue #607: Looping map objects evaluate Sync (Sync==0 applies jitter, Sync==1 runs in lockstep)', () => {
const mockTorchArt: LoadedObjectArt = {
token: 'tr',
slug: 'torch',
blendMode: 'normal',
handle: 1 as any,
groups: [
Array.from({ length: 8 }, (_, i) => ({
x: i * 32,
y: 0,
width: 32,
height: 64,
anchorX: -16,
anchorY: -64,
})),
],
directions: 1,
standOffset: 0,
standFrames: 8,
clips: {
nu: { group: 0, directions: 1, frames: 8, speed: 256, loop: true, events: [] },
},
frameDelta: [256, 256, 256, 256, 256, 256, 256, 256],
cycleAnim: [true, true, true, true, true, true, true, true],
}
// Two torch objects with Sync == 0 and different IDs: must NOT animate in lockstep
const torch1: RenderableUnitInput = {
kind: 'object',
id: 101,
classId: 50,
token: 'TR',
mode: 0,
sync: 0,
x: 10,
y: 10,
}
const torch2: RenderableUnitInput = {
kind: 'object',
id: 102,
classId: 50,
token: 'TR',
mode: 0,
sync: 0,
x: 20,
y: 20,
}
const builtJitter = buildSceneDrawables([torch1, torch2])
const snapshotJitter: SceneFrameSnapshot = {
tick: 100,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 10, y: 10 },
playerLocalSubTile: { x: 10, y: 10 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: builtJitter,
}
const ctx: Partial<SceneSourceRenderContext> = {
lightingPreset: 'noon',
objectArtMap: new Map([['tr', mockTorchArt]]),
}
// Check across multiple ticks that jittered torches differ in frame
let observedDifference = false
for (let t = 200; t <= 1200; t += 120) {
const rend = new MockSpriteRenderer()
renderWorldFrame(rend, snapshotJitter, ctx, t)
expect(rend.drawCalls).toHaveLength(2)
if (rend.drawCalls[0]!.frame.x !== rend.drawCalls[1]!.frame.x) {
observedDifference = true
break
}
}
expect(observedDifference).toBe(true)
// Two synchronized objects with Sync == 1: MUST animate in exact lockstep
const syncObj1: RenderableUnitInput = {
kind: 'object',
id: 201,
classId: 50,
token: 'TR',
mode: 0,
sync: 1,
x: 10,
y: 10,
}
const syncObj2: RenderableUnitInput = {
kind: 'object',
id: 202,
classId: 50,
token: 'TR',
mode: 0,
sync: 1,
x: 20,
y: 20,
}
const builtSync = buildSceneDrawables([syncObj1, syncObj2])
const snapshotSync: SceneFrameSnapshot = {
...snapshotJitter,
entities: builtSync,
}
for (let t = 0; t <= 1000; t += 100) {
const rend = new MockSpriteRenderer()
renderWorldFrame(rend, snapshotSync, ctx, t)
expect(rend.drawCalls).toHaveLength(2)
expect(rend.drawCalls[0]!.frame.x).toBe(rend.drawCalls[1]!.frame.x)
}
})
it('Issue #610: CAMERA_Y_OFFSET = -16 eliminates ground click raycasting parallax across viewports', () => {
const profiles = [VIEWPORT_800x600, VIEWPORT_1024x768, VIEWPORT_1280x720]
for (const profile of profiles) {
const camSubX = 100
const camSubY = 100
// Screen center (with camera offset) maps back to exact camera position
const screenCenter = {
x: profile.width / 2,
y: profile.height / 2 + CAMERA_Y_OFFSET,
}
const unprojected = screenToSubTile(screenCenter.x, screenCenter.y, camSubX, camSubY, profile)
expect(unprojected.x).toBeCloseTo(camSubX, 6)
expect(unprojected.y).toBeCloseTo(camSubY, 6)
// Inverse round-trip holds for all sub-tiles
for (let dx = -20; dx <= 20; dx += 10) {
for (let dy = -20; dy <= 20; dy += 10) {
const sX = camSubX + dx
const sY = camSubY + dy
const screen = subTileToScreen(sX, sY, camSubX, camSubY, profile)
const back = screenToSubTile(screen.x, screen.y, camSubX, camSubY, profile)
expect(back.x).toBeCloseTo(sX, 6)
expect(back.y).toBeCloseTo(sY, 6)
}
}
}
})
it('Issue #608: activeCastState derives elapsed time strictly from elapsedMs without performance.now()', () => {
const castUnit: RenderableUnitInput = {
kind: 'player',
id: 301,
classId: 0,
x: 10,
y: 10,
castState: {
skillId: 1,
mode: 10,
targetX: 10,
targetY: 10,
startedAtMs: 0,
untilMs: 1000,
elapsedMs: 200,
castOverlayToken: 'ShrineShimmer00',
},
}
const built = buildSceneDrawables([castUnit])
const overlay = built.overlayDrawables.find(o => o.token.toLowerCase() === 'shrineshimmer00')
expect(overlay).toBeDefined()
// elapsedMs = 200ms -> elapsedTicks = 5 -> (5 * 16) / 16 = 5
expect(overlay!.frame).toBe(5)
// overlay blendMode uses transToBlendMode
expect(overlay!.blendMode).toBeDefined()
})
it('Hitbox Selection: Cursor inside unit bounding box takes precedence over closer unit evaluated via radial fallback', () => {
// Unit 401 has an authentic bounding box: 100x100 around (x: 10, y: 10)
const boxedMonster: RenderableUnitInput = {
kind: 'monster',
id: 401,
classId: 10,
x: 10,
y: 10,
hitBox: { left: -50, top: -50, width: 100, height: 100 },
}
// Unit 402 has NO bounding box, positioned slightly offset (x: 12, y: 10)
const fallbackMonster: RenderableUnitInput = {
kind: 'monster',
id: 402,
classId: 10,
x: 12,
y: 10,
}
const sceneSource = new ClientWorldSceneSource(
() => ({
tick: 0,
playerSubX: 0,
playerSubY: 0,
units: [boxedMonster, fallbackMonster],
levelView: null,
}),
VIEWPORT_800x600,
)
// Compute click world position that is inside Unit 401's bounding box,
// but geometrically closer to Unit 402's center
const w1 = subTileToWorldOrtho(10, 10, 0, 0)
const clickWorldX = w1.x + 40
const clickWorldY = w1.y
const screen = worldToScreen(clickWorldX, clickWorldY, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
const picked = sceneSource.pickUnitAtScreen(screen.x, screen.y, 50)
expect(picked).not.toBeNull()
expect(picked?.unitId).toBe(401) // Unit 401 MUST be picked due to hitbox precedence!
})
}) })

View File

@ -131,7 +131,7 @@ describe('Issue #539: player spawn is rendered at the server position inside the
expect(subTileToWorldOrtho(5, 0)).toEqual({ x: 80, y: 40 }) expect(subTileToWorldOrtho(5, 0)).toEqual({ x: 80, y: 40 })
// Screen picking is the exact inverse of screen projection around the camera. // Screen picking is the exact inverse of screen projection around the camera.
const s = subTileToScreen(5737, 4523, 5730, 4520, VIEWPORT_800x600) const s = subTileToScreen(5737, 4523, 5730, 4520, VIEWPORT_800x600)
expect(s).toEqual({ x: 400 + (7 - 3) * 16, y: 300 + (7 + 3) * 8 }) expect(s).toEqual({ x: 400 + (7 - 3) * 16, y: 300 + CAMERA_Y_OFFSET + (7 + 3) * 8 })
const p = screenToSubTile(s.x, s.y, 5730, 4520, VIEWPORT_800x600) const p = screenToSubTile(s.x, s.y, 5730, 4520, VIEWPORT_800x600)
expect(p.x).toBeCloseTo(5737, 9) expect(p.x).toBeCloseTo(5737, 9)
expect(p.y).toBeCloseTo(4523, 9) expect(p.y).toBeCloseTo(4523, 9)

View File

@ -1533,7 +1533,7 @@ describe('Tier 1 — F17: Viewport, Camera, Lighting & World Scene Integration',
const drawables = buildSceneDrawables(units, { tick: 5 }) const drawables = buildSceneDrawables(units, { tick: 5 })
expect(drawables.unitDrawables.length).toBe(2) expect(drawables.unitDrawables.length).toBe(2)
expect(drawables.missileDrawables.length).toBe(1) expect(drawables.missileDrawables.length).toBe(1)
expect(drawables.missileDrawables[0]!.blendMode).toBe('additive') expect(['additive', 'highlight']).toContain(drawables.missileDrawables[0]!.blendMode)
}) })
it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => { it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => {