fix(client): isolate per-unit player anim modes (TN/TW/RN/DD) and use 1.13c AnimData.d2 25Hz frame speed (Fixes #544)

This commit is contained in:
troytt 2026-10-02 08:15:00 +00:00
parent 8fcd2238cc
commit 71610311fe
8 changed files with 738 additions and 70 deletions

View File

@ -958,7 +958,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
automapView: session.automapView,
hudManager: session.hudManager,
nowMs,
playerMoving: session.predictor.getSnapshot().moving,
})
}

View File

@ -88,7 +88,7 @@ export function resolveRemoteMoveSpeed(options: {
const running =
options.running ??
(options.moveType !== undefined
? options.moveType === 0x17 || (options.moveType & 0x02) !== 0
? options.moveType === 0x17 || options.moveType === 0x18 || (options.moveType & 0x02) !== 0
: false)
if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) {
return { running, speedSubTilesPerSec: options.speedSubTilesPerSec }

View File

@ -1,4 +1,5 @@
import { dir64ToClipDirection } from '../../common/units/direction.ts'
import { PLR_MODES } from '../../common/units/plr-mode.ts'
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
import { npcDisplayName } from '../../common/units/npc-table.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
@ -175,19 +176,41 @@ 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',
/**
* Secondary `PlrMode.txt` tokens that share HTH DCC sequences in `d2char.mpq` when no dedicated
* HTH DCC file exists for that mode (`KB`/`BL` -> `gh`, `TH`/`KK` -> `a1`, `S1..S4`/`SQ` -> `sc`).
* Primary modes (`dt`, `nu`, `wl`, `rn`, `gh`, `tn`, `tw`, `a1`, `a2`, `sc`, `dd`) never alias.
*/
const PLAYER_HTH_FALLBACK_CLIP: Readonly<Record<string, string>> = {
bl: 'gh',
th: 'a1',
kk: 'a1',
s1: 'sc',
s2: 'sc',
s3: 'sc',
s4: 'sc',
sq: 'sc',
kb: 'gh',
}
/**
* Compute 0-based animation frame index from elapsed time `nowMs` and 1.13c 8.8 fixed-point
* animation speed (`speed256`, where `256 = 1.0` frame per 25Hz / 40ms game tick;
* `D2Common.dll` `0x6FDBE510` / `0x6FDBEFF0`).
*/
export function computeAnimDataFrameIndex(
nowMs: number,
speed256: number,
frameCount: number,
loop = true,
): number {
if (frameCount <= 1) return 0
const safeTicks = Math.max(0, nowMs) / 40
const rawFrame = Math.floor((safeTicks * speed256) / 256)
if (!loop) {
return Math.min(frameCount - 1, Math.max(0, rawFrame))
}
return ((rawFrame % frameCount) + frameCount) % frameCount
}
const MONSTER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
@ -345,7 +368,6 @@ export interface SceneSourceRenderContext {
readonly automapView?: AutomapView | null | undefined
readonly hudManager?: HudManager | null | undefined
readonly nowMs?: number | undefined
readonly playerMoving?: boolean | undefined
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
}
@ -407,32 +429,42 @@ function drawSceneUnitDrawable(
ctx.characterArt ??
null
if (charArt !== null) {
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 modeToken = PLR_MODES[unit.mode]?.token.toLowerCase()
const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken
if (!requestedClipKey) {
reportMissingArt(
'player-mode',
`${unit.token}:${unit.mode}`,
`Invalid player mode=${unit.mode} for token=${unit.token}`,
)
return
}
const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey]
const clip =
charArt.clips?.[requestedClipKey] ??
(isMoving ? charArt.clips?.wl : charArt.clips?.nu)
const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
(fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined)
if (!clip || !(clip.speed > 0)) {
reportMissingArt(
'player-clip',
`${unit.token}:${requestedClipKey}`,
`Missing player clip or AnimData.d2 speed for token=${unit.token} clip=${requestedClipKey} mode=${unit.mode}`,
)
return
}
const dirCount = Math.max(1, clip.directions || charArt.directions)
const direction = dir64ToClipDirection(unit.direction, dirCount)
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 groupIndex = clip.group + direction
const group = charArt.groups[groupIndex]
const frameCount = Math.max(1, group?.length ?? 1)
const shouldLoop =
!unit.isCorpse &&
requestedClipKey !== 'dd' &&
requestedClipKey !== 'dt' &&
clip.loop !== false
const frameIndex =
unit.isCorpse || requestedClipKey === 'dd'
unit.isCorpse && requestedClipKey !== 'dt'
? frameCount - 1
: isMoving
? Math.floor(nowMs / 65) % frameCount
: Math.floor(nowMs / 140) % frameCount
: computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop)
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX =
@ -485,19 +517,29 @@ function drawSceneUnitDrawable(
art.clips?.[requestedClipKey] ??
(isMoving ? art.clips?.wl : undefined) ??
art.clips?.nu
const dirCount = Math.max(1, clip?.directions ?? art.directions)
if (!clip || !(clip.speed > 0)) {
reportMissingArt(
'monster-clip',
`${unit.classId}:${requestedClipKey}`,
`Missing monster clip or AnimData.d2 speed for classId=${unit.classId} token=${unit.token} clip=${requestedClipKey}`,
)
return
}
const dirCount = Math.max(1, clip.directions || art.directions)
const direction = dir64ToClipDirection(unit.direction, dirCount)
const baseGroup =
clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset
const baseGroup = clip.group
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 shouldLoop =
!unit.isCorpse &&
requestedClipKey !== 'dd' &&
requestedClipKey !== 'dt' &&
clip.loop !== false
const frameIndex =
unit.isCorpse || requestedClipKey === 'dd'
unit.isCorpse && requestedClipKey !== 'dt'
? frameCount - 1
: isMoving
? Math.floor(nowMs / 75) % frameCount
: Math.floor(nowMs / 130) % frameCount
: computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop)
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX =
@ -682,9 +724,16 @@ function drawSceneUnitDrawable(
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 speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0
if (!(speed > 0)) {
reportMissingArt(
'object-clip',
`${unit.classId}:${clipKey}`,
`Missing frameDelta/speed for object classId=${unit.classId} mode=${modeIdx}`,
)
return
}
frameIndex = computeAnimDataFrameIndex(nowMs, speed, frameCount, true)
}
}
const frame = group?.[frameIndex] ?? group?.[0]
@ -738,11 +787,22 @@ function drawSceneMissileDrawable(
ctx.missileArtMap?.get(missile.missileKey) ??
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
if (art !== undefined) {
const animSpeed = art.meta.animSpeed
if (!(animSpeed > 0)) {
reportMissingArt(
'missile-speed',
missile.missileKey || missile.celFile || String(missile.missileClassId),
`Missing animSpeed for missile key=${missile.missileKey}`,
)
return
}
const dirCount = Math.max(1, art.meta.directions)
const dir = ((missile.direction % dirCount) + dirCount) % dirCount
const group = art.frames[dir] ?? art.frames[0]
const frameCount = Math.max(1, group?.length ?? 1)
const frameIdx = (missile.frame + Math.floor(nowMs / 50)) % frameCount
const frameIdx =
(missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) %
frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX =
@ -777,9 +837,20 @@ function drawSceneOverlayDrawable(
ctx.overlayArtMap?.get(overlay.token) ??
ctx.overlayArtMap?.get(overlay.token.toLowerCase())
if (art !== undefined) {
const animRate = art.meta.animRate
if (!(animRate > 0)) {
reportMissingArt(
'overlay-speed',
overlay.token || String(overlay.overlayId),
`Missing animRate for overlay token=${overlay.token}`,
)
return
}
const group = art.frames[0]
const frameCount = Math.max(1, group?.length ?? 1)
const frameIdx = (overlay.frame + Math.floor(nowMs / 60)) % frameCount
const frameIdx =
(overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) %
frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX =
@ -826,7 +897,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
renderer.flush()
}
if (ctx.hudManager) {
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
ctx.hudManager.render(nowMs)
}
ctx.onFrameSnapshot?.(snapshot)
return snapshot
@ -1011,7 +1082,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
}
if (ctx.hudManager) {
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
ctx.hudManager.render(nowMs)
}
ctx.onFrameSnapshot?.(snapshot)
return snapshot

View File

@ -937,6 +937,32 @@ export class OnlineSession {
return levelView === null ? null : isTownLevel(levelView.areaId)
}
/**
* Resolve whether `(subX, subY)` is in a town level (`DUNGEON_IsRoomInTown` equivalent):
* prefers the exact `LevelView` containing `(subX, subY)` when `mapService` is loaded,
* and falls back to the active `world.areaId` / `levelView.areaId`.
*/
private isSubTileInTown(subX: number, subY: number): boolean {
if (this.mapService && this.world.areaId > 0 && (subX !== 0 || subY !== 0)) {
const levelView = this.mapService.findLevelViewAtWorldSubTile(
Math.round(subX),
Math.round(subY),
[this.world.areaId, ...this.world.getRevealedAreaIds()],
)
if (levelView !== null) {
return isTownLevel(levelView.areaId)
}
}
const activeAreaId =
this.world.areaId > 0
? this.world.areaId
: (this.levelView?.areaId ??
this.mapService?.getActiveLevelView()?.areaId ??
this.automapView.getLevelView()?.areaId ??
0)
return isTownLevel(activeAreaId)
}
// ─── Outbound ClientCommand Dispatch ─────────────────────────
/**
@ -1186,12 +1212,29 @@ export class OnlineSession {
const evAny = ev as any
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
const unit = this.world.getUnit(evTypeId as UnitType, evUnitId)
const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX
const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
const isLocalPlayer =
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
if (unit && unit.unitType === 'player') {
const posForTownX = ev.mode === 'stop' ? toX : Number(fromX ?? unit.x ?? toX)
const posForTownY = ev.mode === 'stop' ? toY : Number(fromY ?? unit.y ?? toY)
const inTown = this.isSubTileInTown(posForTownX, posForTownY)
if (ev.mode === 'stop') {
unit.mode = inTown ? 5 : 1
} else if (ev.mode === 'run') {
unit.mode = 3
} else if (ev.mode === 'knockback') {
unit.mode = 19
} else {
unit.mode = inTown ? 6 : 2
}
}
if (isLocalPlayer) {
if (ev.mode === 'stop') {
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
@ -1303,6 +1346,24 @@ export class OnlineSession {
unit.x = state.x
unit.y = state.y
unit.direction = state.dir64
if (unit.unitType === 'player' && unit.id !== this.world.selfId) {
const unitInTown = this.isSubTileInTown(state.x, state.y)
if (state.moving && unit.moveMode !== 'stop') {
if (state.running || unit.mode === 3) {
unit.mode = 3
} else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) {
unit.mode = unitInTown ? 6 : 2
}
} else if (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) {
unit.mode = unitInTown ? 5 : 1
unit.moveMode = 'stop'
}
} else if (unit.unitType === 'monster' && !state.moving) {
if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) {
unit.mode = 1
unit.moveMode = 'stop'
}
}
}
}
}
@ -1322,17 +1383,31 @@ export class OnlineSession {
const selfUnit = this.world.getUnit('player', this.world.selfId)
const isSelfDead = this.world.isSelfDead || this.world.isDead
const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined
const selfInTown = this.isSubTileInTown(selfX, selfY)
const neutralMode = selfInTown ? 5 : 1
const walkMode = selfInTown ? 6 : 2
const unitExplicitMode =
selfUnit?.mode !== undefined &&
selfUnit.mode !== 0 &&
selfUnit.mode !== 1 &&
selfUnit.mode !== 2 &&
selfUnit.mode !== 3 &&
selfUnit.mode !== 5 &&
selfUnit.mode !== 6
? selfUnit.mode
: undefined
const selfMode = isSelfDead
? 12
? (selfUnit?.mode === 0 ? 0 : 17)
: selfCastState
? selfCastState.mode || 10
: predSnap.moving
? predSnap.running
? 3
: 2
: selfUnit?.mode && selfUnit.mode > 1
? selfUnit.mode
: 1
: walkMode
: (unitExplicitMode ?? neutralMode)
if (selfUnit !== undefined && !selfCastState) {
selfUnit.mode = selfMode
}
const selfDirection =
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64
out.push({
@ -1435,9 +1510,26 @@ export class OnlineSession {
continue
}
const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)
const isCorpse =
(u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) ||
(u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0))
const castState = !isCorpse ? u.castState : undefined
const mode = castState ? castState.mode || u.mode : u.mode
let mode = castState ? castState.mode || u.mode : u.mode
if (u.unitType === 'player' && !isCorpse && !castState) {
const unitInTown = this.isSubTileInTown(ux, uy)
if (interp?.moving && u.moveMode !== 'stop') {
mode = interp.running || u.mode === 3 ? 3 : unitInTown ? 6 : 2
} else if (
u.mode === 1 ||
u.mode === 5 ||
(interp !== null && interp !== undefined && !interp.moving && (u.mode === 2 || u.mode === 3 || u.mode === 6))
) {
mode = unitInTown ? 5 : 1
} else if (u.mode === 2 || u.mode === 6) {
mode = unitInTown ? 6 : 2
}
u.mode = mode
}
const targetX = castState?.targetX ?? u.targetX
const targetY = castState?.targetY ?? u.targetY

View File

@ -73,7 +73,7 @@ export class ClientUnit {
this.name = snapshot.name
this.x = snapshot.x
this.y = snapshot.y
this.mode = snapshot.mode ?? 0
this.mode = snapshot.mode ?? (this.unitType === 'player' || this.unitType === 'monster' ? 1 : 0)
this.direction = snapshot.direction ?? 0
this.lifePct = snapshot.lifePct ?? 128
this.flags = snapshot.flags ?? 0

View File

@ -19,6 +19,7 @@ import {
} from '../../netproto/index.ts'
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
import { dir64FromPoints } from '../../common/units/direction.ts'
import { isTownLevel } from '../../common/world/scene-population.ts'
import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts'
import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
@ -430,9 +431,22 @@ export class ClientWorld {
return this._soundQueue.splice(0, this._soundQueue.length)
}
private syncPlayerTownModes(): void {
const inTown = isTownLevel(this.areaId)
for (const unit of this._units.values()) {
if (unit.unitType !== 'player') continue
if (unit.mode === 1 || unit.mode === 5) {
unit.mode = inTown ? 5 : 1
} else if (unit.mode === 2 || unit.mode === 6) {
unit.mode = inTown ? 6 : 2
}
}
}
tick(dtMs = 40): void {
this.frameCount++
const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10)
const inTown = isTownLevel(this.areaId)
for (const unit of this._units.values()) {
if (unit.dead) continue
if (unit.targetX === undefined || unit.targetY === undefined) continue
@ -443,6 +457,13 @@ export class ClientWorld {
if (dist <= step) {
unit.x = unit.targetX
unit.y = unit.targetY
if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6)) {
unit.mode = inTown ? 5 : 1
unit.moveMode = 'stop'
} else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)) {
unit.mode = 1
unit.moveMode = 'stop'
}
} else {
unit.x += (dx / dist) * step
unit.y += (dy / dist) * step
@ -469,12 +490,14 @@ export class ClientWorld {
const key = makeUnitKey(unitType, id)
let unit = this._units.get(key)
if (!unit) {
const typeName = unitTypeFromId(unitType)
unit = new ClientUnit({
unitType: unitTypeFromId(unitType),
unitType: typeName,
id,
classId: 0,
x: 0,
y: 0,
...(typeName === 'player' ? { mode: isTownLevel(this.areaId) ? 5 : 1 } : {}),
})
this._units.set(key, unit)
}
@ -527,6 +550,7 @@ export class ClientWorld {
type: 'UnitMove',
unitType: unitTypeFromId(ev.unitType ?? 0),
id: Number(ev.unitId ?? ev.id ?? 1),
mode: ev.mode ?? 'walk',
from: {
x: Number(ev.currentX ?? ev.x ?? 0),
y: Number(ev.currentY ?? ev.y ?? 0),
@ -567,11 +591,12 @@ export class ClientWorld {
value: Number(ev.value ?? 0),
}
} else if (ev.type === 'Damage') {
const isPlayerTarget = unitTypeFromId(ev.unitType ?? 1) === 'player'
ev = {
type: 'UnitMode',
unitType: ev.unitType ?? 1,
id: Number(ev.unitId ?? ev.id ?? 0),
mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)),
mode: Number(ev.mode ?? (ev.lifePct === 0 ? (isPlayerTarget ? 17 : 12) : (isPlayerTarget ? 4 : 3))),
lifePct: Number(ev.lifePct ?? 0),
}
}
@ -595,6 +620,7 @@ export class ClientWorld {
if (ev.objectSeed !== undefined) {
this.objectSeed = ev.objectSeed >>> 0
}
this.syncPlayerTownModes()
break
case 'LoadComplete':
@ -613,6 +639,7 @@ export class ClientWorld {
this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`)
if (this.areaId === 0 && ev.areaId > 0) {
this.areaId = ev.areaId
this.syncPlayerTownModes()
}
break
@ -629,6 +656,13 @@ export class ClientWorld {
} else {
unit.updateFromSnapshot(ev.unit)
}
if (ev.unit.mode === undefined) {
if (unit.unitType === 'player') {
unit.mode = isTownLevel(this.areaId) ? 5 : 1
} else if (unit.unitType === 'monster') {
unit.mode = unit.lifePct <= 0 ? 12 : 1
}
}
if (unit.unitType === 'monster') {
this._monsters.set(unit.id, unit)
if (this._merc && unit.id === this._merc.mercId) {
@ -691,9 +725,56 @@ export class ClientWorld {
unit.x = ev.to.x
unit.y = ev.to.y
}
unit.targetX = ev.to.x
unit.targetY = ev.to.y
let targetX = ev.to.x
let targetY = ev.to.y
if (
ev.target &&
((targetX === 0 && targetY === 0) ||
(ev.from !== undefined && targetX === ev.from.x && targetY === ev.from.y))
) {
const targetUnit = this.getUnit(ev.target.unitType, ev.target.id)
if (targetUnit && (targetUnit.x !== 0 || targetUnit.y !== 0)) {
targetX = targetUnit.x
targetY = targetUnit.y
} else if (
unitTypeFromId(ev.target.unitType) === 'player' &&
(ev.target.id === this.selfId || this.selfId === 0) &&
(this._self.x !== 0 || this._self.y !== 0)
) {
targetX = this._self.x
targetY = this._self.y
}
}
unit.targetX = targetX
unit.targetY = targetY
unit.targetUnit = ev.target
const moveMode = ev.mode ?? 'walk'
unit.moveMode = moveMode
if (targetX !== unit.x || targetY !== unit.y) {
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction)
}
if (unit.unitType === 'player') {
const inTown = isTownLevel(this.areaId)
if (moveMode === 'stop') {
unit.mode = inTown ? 5 : 1
} else if (moveMode === 'run') {
unit.mode = 3
} else if (moveMode === 'knockback') {
unit.mode = 19
} else {
unit.mode = inTown ? 6 : 2
}
} else if (unit.unitType === 'monster') {
if (moveMode === 'stop') {
if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) {
unit.mode = 1
}
} else if (moveMode === 'run') {
unit.mode = 15
} else {
unit.mode = 2
}
}
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
this._self.x = ev.from.x
@ -702,9 +783,9 @@ export class ClientWorld {
this._self.x = ev.to.x
this._self.y = ev.to.y
}
if (ev.to.x !== 0 || ev.to.y !== 0) {
this._self.targetX = ev.to.x
this._self.targetY = ev.to.y
if (targetX !== 0 || targetY !== 0) {
this._self.targetX = targetX
this._self.targetY = targetY
}
}
break

View File

@ -14,9 +14,13 @@ function clampUnitType(raw: number): UnitType {
}
function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' {
// In D2Client.dll: 0x17 / 0x02 / 0x04 / bit 0x10 = run, 0x20 = knockback
if (rawMoveType === 0x20 || rawMoveType === 0x09) return 'knockback'
if (rawMoveType === 0x17 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) return 'run'
// In D2Client.dll 0x6fac9830: 0x17 (0x0F run) / 0x18 (0x10 run) / 0x02 = run, 0x13/0x14/0x20/0x09 = knockback
if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x13 || rawMoveType === 0x14) {
return 'knockback'
}
if (rawMoveType === 0x17 || rawMoveType === 0x18 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) {
return 'run'
}
return 'walk'
}

View File

@ -0,0 +1,421 @@
import { describe, expect, it } from 'vitest'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import {
clearMissingArtEntries,
computeAnimDataFrameIndex,
getMissingArtEntries,
renderSceneSourceFrame,
} from '../../src/client/scene/world-renderer.ts'
import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/client/view/scene-source.ts'
import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts'
import { PLR_MODES } from '../../src/common/units/plr-mode.ts'
function makeTaggedCharArt(): LoadedCharacter {
const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [
{ key: 'dt', group: 0, frames: 4, speed: 256, loop: false },
{ key: 'nu', group: 1, frames: 8, speed: 128, loop: true },
{ key: 'wl', group: 2, frames: 8, speed: 256, loop: true },
{ key: 'rn', group: 3, frames: 8, speed: 256, loop: true },
{ key: 'gh', group: 4, frames: 4, speed: 256, loop: false },
{ key: 'tn', group: 5, frames: 16, speed: 80, loop: true },
{ key: 'tw', group: 6, frames: 8, speed: 256, loop: true },
{ key: 'a1', group: 7, frames: 8, speed: 256, loop: false },
{ key: 'a2', group: 8, frames: 8, speed: 256, loop: false },
{ key: 'sc', group: 9, frames: 8, speed: 256, loop: false },
{ key: 'dd', group: 10, frames: 1, speed: 256, loop: false },
]
const groups = modes.map((m) =>
Array.from({ length: m.frames }, (_, fIdx) => ({
x: m.group * 100 + fIdx,
y: 0,
width: 32,
height: 64,
anchorX: -16,
anchorY: -56,
tag: `${m.key}_${fIdx}`,
})),
)
const clips = Object.fromEntries(
modes.map((m) => [
m.key,
{
group: m.group,
directions: 1,
frames: m.frames,
speed: m.speed,
loop: m.loop,
events: [],
},
]),
)
return {
handle: { index: 1, width: 512, height: 512 } as never,
groups: groups as never,
directions: 1,
walk: 2,
stand: 1,
members: 1,
clips,
}
}
describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isolation & AnimData.d2 Speed', () => {
it('1. isolates local player movement from remote standing players in the same scene', () => {
const session = new OnlineSession()
session.handleServerEvent({
type: 'LoadAct',
act: 0,
mapSeed: 0x11223344,
areaId: 1, // Rogue Encampment (Town)
})
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 0,
id: 1,
classId: 1, // Sorceress ('so')
name: 'LocalSorc',
x: 5100,
y: 5050,
},
})
session.handleServerEvent({
type: 'UnitAssign',
isSelf: false,
unit: {
unitType: 0,
id: 2,
classId: 1, // Remote Sorceress ('so')
name: 'RemoteSorc',
x: 5104,
y: 5050,
},
})
// Start local player walking in town (mode = 6 TW)
session.predictor.setPath([{ x: 5120, y: 5050 }], false)
session.tick(40, 100)
const renderables = session.buildRenderableUnits()
const localPlayer = renderables.find((u) => u.id === 1)!
const remotePlayer = renderables.find((u) => u.id === 2)!
expect(localPlayer.mode).toBe(6) // PLRMODE_TOWNWALK (TW)
expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN)
// Render scene frame and inspect drawn sprite tags for both players
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1)
const baseX = levelView.subOriginX + 50
const baseY = levelView.subOriginY + 50
const drawnTags: string[] = []
const fakeRenderer = {
begin() {},
draw(frame: { tag?: string }) {
if (frame.tag) drawnTags.push(frame.tag)
},
setLighting() {},
setPalette() {},
flush() {},
}
const fakeTileGpuCache = {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return undefined
},
}
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: baseX,
playerSubY: baseY,
units: [
{
kind: 'player',
id: 1,
classId: 1,
x: baseX,
y: baseY,
mode: localPlayer.mode,
direction: 0,
isLocalPlayer: true,
},
{
kind: 'player',
id: 2,
classId: 1,
x: baseX + 4,
y: baseY,
mode: remotePlayer.mode,
direction: 0,
isLocalPlayer: false,
},
],
levelView,
}),
'800x600',
)
const charArt = makeTaggedCharArt()
renderSceneSourceFrame({
source,
lightingPreset: 'fullbright',
renderer: fakeRenderer as never,
tileGpuCache: fakeTileGpuCache as never,
characterArtByToken: new Map([['so', charArt]]),
nowMs: 80,
})
// Local player drew TW frame (`tw_2`), while remote standing player drew TN frame (`tn_0`)
expect(drawnTags).toContain('tw_2')
expect(drawnTags).toContain('tn_0')
expect(drawnTags.some((t) => t.startsWith('wl_'))).toBe(false)
})
it('2. transitions remote player mode on 0x0F PlayerMove, 0x10 PlayerMoveToTarget, 0x0D PlayerStop, and segment completion', () => {
const session = new OnlineSession()
session.handleServerEvent({
type: 'LoadAct',
act: 0,
mapSeed: 0x11223344,
areaId: 1, // Town
})
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 },
})
session.handleServerEvent({
type: 'UnitAssign',
isSelf: false,
unit: { unitType: 0, id: 2, classId: 3, name: 'RemotePal', x: 5105, y: 5050 },
})
// Initially standing in town -> mode 5 (TN)
expect(session.world.getUnit('player', 2)?.mode).toBe(5)
// 0x0F PlayerMove (walk: moveType = 0x01) from (5105, 5050) to (5115, 5050)
const pkt0x0FWalk = new Uint8Array(16)
const dvWalk = new DataView(pkt0x0FWalk.buffer)
dvWalk.setUint8(0, 0x0f)
dvWalk.setUint8(1, 0) // unitType = player
dvWalk.setUint32(2, 2, true) // unitId = 2
dvWalk.setUint8(6, 0x01) // moveType = walk
dvWalk.setUint16(7, 5115, true) // targetX
dvWalk.setUint16(9, 5050, true) // targetY
dvWalk.setUint8(11, 0)
dvWalk.setUint16(12, 5105, true) // currentX
dvWalk.setUint16(14, 5050, true) // currentY
session.handleServerEvent(decode0x0FPlayerMove(pkt0x0FWalk))
expect(session.world.getUnit('player', 2)?.mode).toBe(6) // TW in town
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(6)
// 0x10 PlayerMoveToTarget (run: moveType = 0x18) targeting local player id = 1 at (5100, 5050)
const pkt0x10Run = new Uint8Array(16)
const dvRun = new DataView(pkt0x10Run.buffer)
dvRun.setUint8(0, 0x10)
dvRun.setUint8(1, 0) // unitType = player
dvRun.setUint32(2, 2, true) // unitId = 2
dvRun.setUint8(6, 0x18) // moveType = 0x18 (run to target in 1.13c D2Client 0x6fac9830)
dvRun.setUint8(7, 0) // targetType = player
dvRun.setUint32(8, 1, true) // targetId = 1
dvRun.setUint16(12, 5110, true) // currentX
dvRun.setUint16(14, 5050, true) // currentY
session.handleServerEvent(decode0x10PlayerMoveToTarget(pkt0x10Run))
expect(session.world.getUnit('player', 2)?.mode).toBe(3) // RN (even in town)
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(3)
// Advance interpolation until remote player reaches target (5100, 5050) -> returns to TN (5)
for (let i = 0; i < 30; i++) {
session.tick(100, i * 100)
}
expect(session.world.getUnit('player', 2)?.mode).toBe(5)
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5)
// Start another run, then stop immediately via 0x0D PlayerStop
session.handleServerEvent({
type: 'UnitMove',
unitType: 0,
id: 2,
mode: 'run',
from: { x: 5100, y: 5050 },
to: { x: 5120, y: 5050 },
})
expect(session.world.getUnit('player', 2)?.mode).toBe(3)
const pkt0x0DStop = new Uint8Array(13)
const dvStop = new DataView(pkt0x0DStop.buffer)
dvStop.setUint8(0, 0x0d)
dvStop.setUint8(1, 0)
dvStop.setUint32(2, 2, true)
dvStop.setUint8(6, 0x07) // actionCode = neutral
dvStop.setUint16(7, 5102, true)
dvStop.setUint16(9, 5050, true)
dvStop.setUint8(11, 0)
dvStop.setUint8(12, 128)
session.handleServerEvent(decode0x0DPlayerStop(pkt0x0DStop))
expect(session.world.getUnit('player', 2)?.mode).toBe(5) // Back to TN in town
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5)
})
it('3. switches local player between town (TN=5, TW=6, RN=3) and wilderness (NU=1, WL=2, RN=3) and uses DD=17 (never KK=12) on death', () => {
const session = new OnlineSession()
session.handleServerEvent({
type: 'LoadAct',
act: 0,
mapSeed: 0x11223344,
areaId: 1, // Rogue Encampment (Town)
})
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 },
})
// Town Idle -> TN (5)
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(5)
expect(PLR_MODES[5]?.token).toBe('TN')
// Town Walk -> TW (6)
session.predictor.setPath([{ x: 5120, y: 5050 }], false)
session.tick(40, 40)
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(6)
expect(PLR_MODES[6]?.token).toBe('TW')
// Town Run -> RN (3) (1.13c D2Game sub_6FC7F600: running in town still uses PLRMODE_RUN=3)
session.predictor.setPath([{ x: 5140, y: 5050 }], true)
session.tick(40, 80)
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3)
expect(PLR_MODES[3]?.token).toBe('RN')
// Enter Blood Moor (areaId = 2, non-town) while running -> stays RN (3)
session.handleServerEvent({
type: 'LoadAct',
act: 0,
mapSeed: 0x11223344,
areaId: 2,
})
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3)
// Switch to walking in Blood Moor -> WL (2)
session.predictor.setPath([{ x: 5160, y: 5050 }], false)
session.tick(40, 120)
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(2)
expect(PLR_MODES[2]?.token).toBe('WL')
// Stop in Blood Moor -> NU (1)
session.predictor.setPosition(5160, 5050)
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(1)
expect(PLR_MODES[1]?.token).toBe('NU')
// Local player dies -> mode must be 17 (DD = Dead/Corpse), never 12 (KK = Kick)
expect(PLR_MODES[12]?.token).toBe('KK')
expect(PLR_MODES[17]?.token).toBe('DD')
session.handleServerEvent({
type: 'LifeMana',
life: 0,
mana: 0,
stamina: 0,
at: { x: 5160, y: 5050 },
vx: 0,
vy: 0,
})
const deadSelf = session.buildRenderableUnits().find((u) => u.id === 1)!
expect(deadSelf.isCorpse).toBe(true)
expect(deadSelf.mode).toBe(17)
expect(deadSelf.mode).not.toBe(12)
})
it('4. advances frames at 1.13c 25Hz (40ms tick) 8.8 fixed-point AnimData.d2 speed and reports missing clip speed', () => {
// speed = 256 -> 1 frame per 40ms tick (25 fps)
expect(computeAnimDataFrameIndex(0, 256, 8)).toBe(0)
expect(computeAnimDataFrameIndex(39, 256, 8)).toBe(0)
expect(computeAnimDataFrameIndex(40, 256, 8)).toBe(1)
expect(computeAnimDataFrameIndex(80, 256, 8)).toBe(2)
expect(computeAnimDataFrameIndex(320, 256, 8)).toBe(0) // loops at 8 frames
// speed = 128 (SONUHTH) -> 1 frame per 80ms (12.5 fps)
expect(computeAnimDataFrameIndex(0, 128, 8)).toBe(0)
expect(computeAnimDataFrameIndex(40, 128, 8)).toBe(0)
expect(computeAnimDataFrameIndex(79, 128, 8)).toBe(0)
expect(computeAnimDataFrameIndex(80, 128, 8)).toBe(1)
expect(computeAnimDataFrameIndex(160, 128, 8)).toBe(2)
// speed = 80 (SOTNHTH) -> 1 frame per 128ms (7.8125 fps)
expect(computeAnimDataFrameIndex(0, 80, 16)).toBe(0)
expect(computeAnimDataFrameIndex(127, 80, 16)).toBe(0)
expect(computeAnimDataFrameIndex(128, 80, 16)).toBe(1)
expect(computeAnimDataFrameIndex(256, 80, 16)).toBe(2)
// Non-looping death animation (DT, loop = false) clamps to frameCount - 1
expect(computeAnimDataFrameIndex(10000, 256, 4, false)).toBe(3)
// Fail-fast diagnostic when clip speed metadata is missing
clearMissingArtEntries()
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1)
const baseX = levelView.subOriginX + 50
const baseY = levelView.subOriginY + 50
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: baseX,
playerSubY: baseY,
units: [
{
kind: 'player',
id: 1,
classId: 1,
x: baseX,
y: baseY,
mode: 5, // TN
direction: 0,
isLocalPlayer: true,
},
],
levelView,
}),
'800x600',
)
const brokenCharArt: LoadedCharacter = {
handle: { index: 1, width: 64, height: 64 } as never,
groups: [[{ x: 0, y: 0, width: 32, height: 64 }]],
directions: 1,
walk: 0,
stand: 0,
members: 1,
// Missing 'tn' clip!
clips: {},
}
const fakeRenderer = {
begin() {},
draw() {},
setLighting() {},
setPalette() {},
flush() {},
}
renderSceneSourceFrame({
source,
lightingPreset: 'fullbright',
renderer: fakeRenderer as never,
characterArtByToken: new Map([['so', brokenCharArt]]),
nowMs: 0,
})
expect(
getMissingArtEntries().some((e) => e.category === 'player-clip' && e.id.toLowerCase() === 'so:tn'),
).toBe(true)
clearMissingArtEntries()
})
})