fix(client/netproto): align monster movement speed with 1.13c MonStats velocity and D2GS 0x67/0x68 nVelocity

This commit is contained in:
troytt 2026-10-02 12:02:44 +00:00
parent 77a20c742d
commit 778bd57806
9 changed files with 467 additions and 27 deletions

View File

@ -71,6 +71,7 @@ import { getViewportProfile } from '../view/viewport-profile.ts'
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts' import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts' import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
import { OutboundRateLimiter } from './rate-limiter.ts' import { OutboundRateLimiter } from './rate-limiter.ts'
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
import { isTownLevel } from '../../common/world/scene-population.ts' import { isTownLevel } from '../../common/world/scene-population.ts'
export type OnlineSessionPhase = export type OnlineSessionPhase =
@ -1205,6 +1206,17 @@ export class OnlineSession {
: { x: 0, y: 0 }), : { x: 0, y: 0 }),
} as ServerEvent } as ServerEvent
} }
const preMoveUnit =
ev.type === 'UnitMove'
? this.world.getUnit(
(evAny.unitType ?? ev.unitType) as UnitType,
Number(evAny.id ?? evAny.unitId ?? 1),
)
: undefined
const preMoveUnitPos =
preMoveUnit !== undefined && (preMoveUnit.x !== 0 || preMoveUnit.y !== 0)
? { x: preMoveUnit.x, y: preMoveUnit.y }
: undefined
this.world.apply(ev) this.world.apply(ev)
if ( if (
@ -1371,10 +1383,49 @@ export class OnlineSession {
const unit = this.world.getUnit(evTypeId as UnitType, evUnitId) const unit = this.world.getUnit(evTypeId as UnitType, evUnitId)
const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 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 let 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 let toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x if (
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y ev.target &&
((rawToX === 0 && rawToY === 0) ||
(ev.from !== undefined && rawToX === ev.from.x && rawToY === ev.from.y))
) {
const targetTypeId = unitTypeToId(ev.target.unitType)
const isTargetLocalPlayer =
targetTypeId === 0 && (ev.target.id === this.world.selfId || this.world.selfId === 0)
if (isTargetLocalPlayer) {
const predSnap = this.predictor.getSnapshot()
if (predSnap.x !== 0 || predSnap.y !== 0) {
toX = predSnap.x
toY = predSnap.y
}
} else {
const targetInterp = this.interpolator.getEntityState(targetTypeId, ev.target.id)
if (targetInterp && (targetInterp.x !== 0 || targetInterp.y !== 0)) {
toX = targetInterp.x
toY = targetInterp.y
}
}
if (unit && (toX !== 0 || toY !== 0)) {
unit.targetX = toX
unit.targetY = toY
}
}
const explicitFromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
const explicitFromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
const existingInterp = this.interpolator.getEntityState(evTypeId, evUnitId)
const fromX =
explicitFromX !== undefined
? Number(explicitFromX)
: !existingInterp && preMoveUnitPos !== undefined
? preMoveUnitPos.x
: undefined
const fromY =
explicitFromY !== undefined
? Number(explicitFromY)
: !existingInterp && preMoveUnitPos !== undefined
? preMoveUnitPos.y
: undefined
const isLocalPlayer = const isLocalPlayer =
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
if (unit && unit.unitType === 'player') { if (unit && unit.unitType === 'player') {
@ -1396,22 +1447,41 @@ export class OnlineSession {
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
} }
} else if (ev.mode === 'stop') { } else if (ev.mode === 'stop') {
this.interpolator.stopEntity({ const motionState = this.interpolator.stopEntity({
unitType: evTypeId, unitType: evTypeId,
unitId: evUnitId, unitId: evUnitId,
x: toX, x: toX,
y: toY, y: toY,
}) })
if (unit) {
unit.x = motionState.x
unit.y = motionState.y
unit.direction = motionState.dir64
}
} else { } else {
this.interpolator.startMove({ const speedSubTilesPerSec =
evTypeId === 1
? computeMonsterMoveSpeedSubTilesPerSec(
unit?.classId ?? 0,
ev.mode === 'run',
ev.velocityPercent,
)
: undefined
const motionState = this.interpolator.startMove({
unitType: evTypeId, unitType: evTypeId,
unitId: evUnitId, unitId: evUnitId,
...(fromX !== undefined ? { currentX: Number(fromX) } : {}), ...(fromX !== undefined ? { currentX: fromX } : {}),
...(fromY !== undefined ? { currentY: Number(fromY) } : {}), ...(fromY !== undefined ? { currentY: fromY } : {}),
targetX: toX, targetX: toX,
targetY: toY, targetY: toY,
running: ev.mode === 'run', running: ev.mode === 'run',
...(speedSubTilesPerSec !== undefined ? { speedSubTilesPerSec } : {}),
}) })
if (unit) {
unit.x = motionState.x
unit.y = motionState.y
unit.direction = motionState.dir64
}
} }
break break
} }
@ -1516,6 +1586,8 @@ export class OnlineSession {
this.predictor.setDirection(selfUnit.direction) this.predictor.setDirection(selfUnit.direction)
} }
this.world.tick(dtMs, nowMs)
this.interpolator.update(safeDt) this.interpolator.update(safeDt)
for (const state of this.interpolator.getAllStates()) { for (const state of this.interpolator.getAllStates()) {
const unit = this.world.getUnit(state.unitType as UnitType, state.unitId) const unit = this.world.getUnit(state.unitType as UnitType, state.unitId)
@ -1544,19 +1616,19 @@ export class OnlineSession {
unit.mode = unitInTown ? 5 : 1 unit.mode = unitInTown ? 5 : 1
unit.moveMode = 'stop' unit.moveMode = 'stop'
} }
} else if ( } else if (unit.unitType === 'monster' && unit.lifePct > 0) {
unit.unitType === 'monster' && if (state.moving && !unit.castState && unit.moveMode !== 'stop') {
!state.moving && unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 9 : 2
unit.lifePct > 0 && } else if (
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15) !state.moving &&
) { (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
unit.mode = 1 ) {
unit.moveMode = 'stop' unit.mode = 1
unit.moveMode = 'stop'
}
} }
} }
} }
this.world.tick(dtMs, nowMs)
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
} }

View File

@ -70,6 +70,8 @@ export class ClientUnit {
targetY?: number | undefined targetY?: number | undefined
targetUnit?: UnitRef | undefined targetUnit?: UnitRef | undefined
moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined
velocityPercent?: number | undefined
pathType?: number | undefined
mode: number mode: number
direction: number direction: number
lifePct: number lifePct: number

View File

@ -49,6 +49,7 @@ import {
type CharacterAnimSpeedParams, type CharacterAnimSpeedParams,
} from '../../common/units/char-anim-speed.ts' } from '../../common/units/char-anim-speed.ts'
import { dir64FromPoints } from '../../common/units/direction.ts' import { dir64FromPoints } from '../../common/units/direction.ts'
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
import { isTownLevel } from '../../common/world/scene-population.ts' import { isTownLevel } from '../../common/world/scene-population.ts'
import { import {
ClientUnit, ClientUnit,
@ -1535,8 +1536,7 @@ export class ClientWorld {
} }
// Continuous unit movement interpolation across display frames // Continuous unit movement interpolation across display frames
const step = (safeDt / 1000) * 10 if (safeDt > 0) {
if (step > 0) {
const inTown = isTownLevel(this.areaId) const inTown = isTownLevel(this.areaId)
for (const unit of this._units.values()) { for (const unit of this._units.values()) {
if (unit.dead || unit.castState || unit.unitType === 'missile') continue if (unit.dead || unit.castState || unit.unitType === 'missile') continue
@ -1545,6 +1545,16 @@ export class ClientWorld {
const dy = unit.targetY - unit.y const dy = unit.targetY - unit.y
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
if (dist === 0) continue if (dist === 0) continue
const speedSubTilesPerSec =
unit.unitType === 'monster'
? computeMonsterMoveSpeedSubTilesPerSec(
unit.classId,
unit.moveMode === 'run',
unit.velocityPercent,
)
: 10
const step = (safeDt / 1000) * speedSubTilesPerSec
if (step <= 0) continue
if (dist <= step) { if (dist <= step) {
unit.x = unit.targetX unit.x = unit.targetX
unit.y = unit.targetY unit.y = unit.targetY
@ -1701,6 +1711,8 @@ export class ClientWorld {
x: Number(ev.targetX ?? ev.x ?? 0), x: Number(ev.targetX ?? ev.x ?? 0),
y: Number(ev.targetY ?? ev.y ?? 0), y: Number(ev.targetY ?? ev.y ?? 0),
}, },
...(ev.velocityPercent !== undefined ? { velocityPercent: Number(ev.velocityPercent) } : {}),
...(ev.pathType !== undefined ? { pathType: Number(ev.pathType) } : {}),
} }
} else if (ev.type === 'ChatMessage') { } else if (ev.type === 'ChatMessage') {
ev = { ev = {
@ -1903,6 +1915,12 @@ export class ClientWorld {
unit.targetUnit = ev.target unit.targetUnit = ev.target
const moveMode = ev.mode ?? 'walk' const moveMode = ev.mode ?? 'walk'
unit.moveMode = moveMode unit.moveMode = moveMode
if (ev.velocityPercent !== undefined) {
unit.velocityPercent = ev.velocityPercent
}
if (ev.pathType !== undefined) {
unit.pathType = ev.pathType
}
if (prevX !== targetX || prevY !== targetY) { if (prevX !== targetX || prevY !== targetY) {
unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction) unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction)
} }

View File

@ -1515,6 +1515,75 @@ export const MONSTER_HCIDX_TO_ID: Readonly<Record<number, string>> = {
733: "willowisp8", 733: "willowisp8",
} }
/**
* Canonical 1.13c `MonStats.txt` `[Velocity, Run]` pairs indexed by `hcIdx` (0..733),
* extracted directly from `Patch_D2.mpq` (`data/global/excel/MonStats.txt`).
*/
export const MONSTER_HCIDX_VELOCITIES: readonly (readonly [number, number])[] = [
[3, 3], [4, 4], [4, 4], [5, 5], [5, 5], [1, 3], [1, 4], [1, 4], [1, 5], [1, 5], [6, 6], [6, 6],
[5, 5], [5, 5], [7, 7], [4, 4], [4, 4], [5, 5], [6, 6], [5, 5], [5, 5], [6, 6], [6, 6], [8, 8],
[4, 4], [4, 4], [5, 5], [5, 5], [3, 3], [5, 5], [5, 5], [6, 6], [6, 6], [7, 7], [0, 0], [0, 0],
[0, 0], [0, 0], [5, 5], [6, 6], [6, 6], [7, 7], [8, 8], [5, 5], [6, 8], [6, 10], [7, 12], [8, 12],
[7, 12], [7, 12], [7, 13], [7, 13], [7, 14], [5, 5], [6, 6], [7, 7], [9, 9], [8, 8], [4, 4], [4, 4],
[5, 5], [5, 5], [6, 6], [3, 3], [3, 3], [4, 4], [4, 4], [5, 5], [4, 4], [4, 4], [4, 4], [4, 4],
[4, 4], [4, 4], [4, 10], [5, 12], [5, 12], [6, 14], [10, 10], [8, 8], [8, 8], [8, 8], [8, 8], [5, 10],
[6, 11], [7, 12], [8, 13], [5, 5], [6, 6], [7, 7], [8, 8], [4, 4], [5, 5], [5, 5], [6, 6], [6, 6],
[3, 3], [3, 3], [3, 3], [3, 3], [3, 3], [2, 2], [2, 2], [3, 3], [4, 4], [5, 5], [3, 3], [3, 3],
[3, 3], [3, 3], [4, 4], [4, 4], [5, 5], [5, 5], [6, 12], [6, 12], [6, 12], [6, 12], [15, 15], [15, 15],
[15, 15], [15, 15], [6, 6], [7, 7], [8, 8], [8, 9], [8, 11], [3, 3], [3, 3], [3, 3], [3, 3], [6, 6],
[7, 7], [7, 7], [8, 8], [9, 9], [7, 7], [8, 8], [9, 9], [10, 10], [12, 12], [6, 6], [7, 7], [8, 8],
[9, 9], [10, 10], [2, 2], [4, 4], [1, 1], [2, 2], [3, 3], [5, 5], [8, 8], [0, 0], [4, 4], [3, 3],
[8, 8], [7, 7], [4, 4], [7, 7], [4, 4], [5, 6], [5, 8], [6, 9], [7, 10], [4, 4], [5, 6], [5, 8],
[6, 10], [7, 12], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [3, 3], [3, 3], [3, 3], [3, 3], [2, 2],
[4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [5, 5], [3, 3], [4, 4], [6, 6], [8, 8], [1, 1], [1, 1],
[1, 1], [1, 1], [1, 1], [2, 2], [2, 2], [2, 2], [4, 4], [3, 3], [2, 2], [4, 4], [3, 3], [4, 7],
[4, 4], [4, 4], [0, 0], [0, 0], [0, 0], [0, 0], [4, 4], [10, 15], [6, 6], [7, 7], [8, 8], [9, 9],
[10, 10], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [3, 3], [3, 3], [4, 4], [4, 4], [4, 4], [1, 1],
[0, 0], [2, 2], [4, 4], [4, 4], [4, 4], [4, 4], [4, 4], [6, 8], [6, 9], [6, 11], [6, 6], [6, 6],
[6, 6], [6, 6], [8, 8], [6, 6], [2, 2], [2, 2], [2, 2], [9, 9], [10, 10], [11, 11], [5, 5], [0, 0],
[2, 2], [2, 2], [2, 2], [2, 2], [10, 16], [2, 2], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0],
[2, 2], [2, 2], [8, 8], [6, 8], [2, 2], [5, 5], [8, 8], [7, 11], [8, 8], [0, 0], [4, 4], [4, 4],
[5, 5], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [1, 1], [4, 4], [4, 4], [4, 4], [4, 4],
[4, 4], [8, 8], [9, 9], [9, 9], [10, 10], [12, 6], [3, 2], [10, 16], [3, 2], [2, 2], [7, 7], [7, 7],
[7, 7], [8, 10], [9, 11], [10, 12], [10, 16], [10, 16], [10, 16], [6, 12], [7, 12], [8, 12], [6, 7], [7, 7],
[8, 7], [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [2, 2], [5, 5], [5, 5], [0, 0], [0, 0], [0, 0],
[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 6], [0, 0], [8, 8], [0, 0], [0, 0],
[0, 0], [0, 0], [7, 11], [1, 2], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 12], [8, 12], [10, 12],
[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [9, 9], [11, 11], [8, 8], [7, 11],
[10, 16], [10, 16], [10, 16], [13, 15], [12, 14], [3, 4], [0, 0], [0, 0], [2, 2], [0, 0], [10, 16], [0, 0],
[0, 0], [5, 10], [5, 10], [5, 10], [5, 10], [6, 6], [6, 6], [4, 4], [4, 4], [5, 5], [5, 5], [4, 4],
[4, 4], [5, 5], [5, 5], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [0, 0], [0, 0], [5, 10], [5, 10],
[6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [0, 0], [7, 7], [0, 0], [0, 0], [2, 2], [0, 0], [6, 6],
[2, 2], [8, 8], [0, 0], [0, 0], [0, 0], [10, 10], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [10, 20],
[5, 10], [5, 10], [6, 6], [6, 6], [6, 6], [7, 7], [7, 7], [7, 7], [5, 9], [6, 6], [6, 6], [6, 6],
[0, 0], [0, 0], [0, 0], [0, 0], [1, 5], [1, 5], [2, 5], [2, 5], [3, 5], [3, 7], [3, 8], [4, 9],
[5, 10], [6, 11], [5, 8], [6, 6], [7, 7], [7, 7], [1, 1], [1, 1], [1, 1], [7, 10], [7, 10], [7, 10],
[7, 10], [7, 10], [7, 10], [7, 10], [7, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10],
[10, 10], [10, 10], [11, 11], [12, 12], [13, 13], [14, 14], [7, 7], [8, 8], [9, 9], [10, 10], [12, 12], [4, 4],
[4, 4], [5, 5], [5, 5], [6, 6], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0],
[7, 7], [7, 7], [7, 7], [7, 7], [7, 7], [0, 0], [0, 0], [0, 0], [0, 0], [3, 3], [3, 3], [3, 3],
[3, 3], [3, 3], [9, 9], [9, 9], [9, 9], [9, 9], [9, 9], [3, 3], [3, 3], [1, 1], [2, 2], [3, 3],
[0, 0], [0, 0], [0, 0], [0, 0], [2, 2], [0, 0], [5, 14], [5, 14], [0, 0], [0, 0], [7, 7], [2, 2],
[0, 0], [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [4, 7], [5, 14], [5, 14], [5, 14], [5, 14], [5, 14],
[10, 10], [10, 10], [10, 10], [6, 6], [6, 6], [12, 12], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], [8, 10],
[8, 10], [8, 10], [8, 10], [8, 10], [2, 2], [10, 12], [10, 16], [6, 6], [8, 14], [8, 14], [0, 0], [0, 0],
[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 6], [8, 8], [8, 8], [8, 8], [12, 12], [4, 4],
[4, 4], [5, 5], [5, 5], [5, 5], [9, 9], [9, 9], [10, 10], [11, 11], [12, 12], [10, 10], [10, 10], [10, 10],
[11, 11], [12, 12], [9, 9], [9, 9], [10, 10], [12, 12], [6, 10], [6, 10], [8, 12], [8, 12], [9, 14], [15, 15],
[15, 15], [15, 15], [15, 15], [15, 15], [4, 4], [6, 6], [6, 6], [6, 6], [4, 4], [6, 6], [12, 16], [12, 16],
[12, 16], [8, 12], [8, 12], [8, 12], [8, 12], [8, 12], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6],
[6, 6], [5, 5], [5, 5], [9, 12], [9, 12], [8, 8], [8, 8], [8, 8], [8, 8], [8, 8], [14, 14], [14, 14],
[12, 12], [12, 12], [12, 12], [15, 15], [15, 15], [15, 15], [10, 10], [10, 10], [10, 10], [6, 6], [6, 6], [6, 6],
[5, 5], [5, 5], [12, 12], [12, 12], [9, 9], [9, 9], [6, 6], [6, 6], [10, 10], [10, 10], [10, 10], [10, 10],
[10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [7, 14], [7, 14], [5, 5], [5, 5], [5, 5], [5, 5], [6, 11],
[6, 11], [6, 6], [6, 6], [7, 7], [7, 7], [10, 12], [10, 12], [4, 4], [6, 6], [1, 1], [7, 10], [7, 10],
[7, 10], [8, 11], [10, 16], [10, 16], [7, 7], [7, 7], [10, 10], [10, 10], [10, 16], [10, 16], [8, 12], [8, 8],
[8, 8], [8, 8], [3, 5], [6, 7], [6, 7], [8, 7], [8, 7], [3, 3], [8, 8], [8, 8], [10, 16], [8, 8],
[10, 15], [6, 6], [0, 0], [0, 0], [10, 16], [8, 7], [7, 7], [8, 8], [4, 4], [8, 11], [6, 6], [14, 14],
[12, 12], [8, 12], [8, 12], [8, 12], [6, 6], [5, 5], [5, 5], [6, 6], [6, 6], [6, 6], [5, 5], [8, 8],
[8, 8], [15, 15],
]
/** /**
* Resolve the visual token and weapon class for a numeric MonStats.txt hcIdx (0..733). * Resolve the visual token and weapon class for a numeric MonStats.txt hcIdx (0..733).
*/ */

View File

@ -2038,8 +2038,8 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly<
attack1: { minDamage: 20, maxDamage: 40, toHit: 70 }, attack1: { minDamage: 20, maxDamage: 40, toHit: 70 },
}, },
skeleton1: { skeleton1: {
velocity: 4, velocity: 3,
runVelocity: 4, runVelocity: 3,
minHp: 61, minHp: 61,
maxHp: 121, maxHp: 121,
armour: 100, armour: 100,
@ -2048,6 +2048,20 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly<
}, },
} }
import { MONSTER_HCIDX_TO_ID, MONSTER_HCIDX_VELOCITIES } from './monster-mapping.ts'
const CANONICAL_113C_VELOCITIES_BY_ID: Readonly<Record<string, readonly [number, number]>> = (() => {
const out: Record<string, readonly [number, number]> = {}
for (let hcIdx = 0; hcIdx < MONSTER_HCIDX_VELOCITIES.length; hcIdx++) {
const id = MONSTER_HCIDX_TO_ID[hcIdx]
const pair = MONSTER_HCIDX_VELOCITIES[hcIdx]
if (id && pair) {
out[id] = pair
}
}
return out
})()
let cachedCanonicalMonsterKinds: Map<string, MonsterKind> | null = null let cachedCanonicalMonsterKinds: Map<string, MonsterKind> | null = null
function getCanonicalMonsterKinds(): Map<string, MonsterKind> { function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
@ -2056,11 +2070,14 @@ function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
const enriched = new Map<string, MonsterKind>() const enriched = new Map<string, MonsterKind>()
for (const [id, kind] of rawKinds) { for (const [id, kind] of rawKinds) {
const ov = CANONICAL_113C_MONSTER_OVERRIDES[id] const ov = CANONICAL_113C_MONSTER_OVERRIDES[id]
const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()]
const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined
const canonicalRun = canonicalSpeed && canonicalSpeed[1] > 0 ? canonicalSpeed[1] : undefined
const enrichedKind: MonsterKind = { const enrichedKind: MonsterKind = {
...kind, ...kind,
enabled: kind.killable || !kind.inTown, enabled: kind.killable || !kind.inTown,
velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5), velocity: ov?.velocity ?? canonicalVel ?? (kind.velocity > 0 ? kind.velocity : 5),
runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), runVelocity: ov?.runVelocity ?? canonicalRun ?? (kind.runVelocity > 0 ? kind.runVelocity : 5),
minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21), minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21),
maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61), maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61),
armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84), armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84),
@ -2093,7 +2110,12 @@ export const MONSTER_TABLE: ReadonlyMap<string, MonsterKind> & Readonly<Record<s
return typeof value === 'function' ? value.bind(map) : value return typeof value === 'function' ? value.bind(map) : value
} }
if (typeof prop === 'string') { if (typeof prop === 'string') {
return map.get(prop) ?? map.get(prop.toLowerCase()) const direct = map.get(prop) ?? map.get(prop.toLowerCase())
if (direct !== undefined) return direct
if (/^\d+$/.test(prop)) {
const id = MONSTER_HCIDX_TO_ID[Number(prop)]
if (id) return map.get(id)
}
} }
return undefined return undefined
}, },
@ -2104,4 +2126,62 @@ export const MONSTER_TABLE: ReadonlyMap<string, MonsterKind> & Readonly<Record<s
}, },
) as unknown as ReadonlyMap<string, MonsterKind> & Readonly<Record<string, MonsterKind>> ) as unknown as ReadonlyMap<string, MonsterKind> & Readonly<Record<string, MonsterKind>>
/**
* Look up a canonical 1.13c `MonsterKind` by numeric `MonStats.txt` `hcIdx` (`0..733`).
*/
export function getMonsterKindByClassId(classId: number): MonsterKind | undefined {
const id = MONSTER_HCIDX_TO_ID[classId]
if (!id) return undefined
return getCanonicalMonsterKinds().get(id)
}
/**
* Compute D2 1.13c monster movement speed in sub-tiles per second (`D2Common.dll`
* `UNITS_GetBaseVelocity` 0x6FDB1170, `UNITS_GetVelocity` 0x6FDB1B60, and `PathMisc.cpp`
* `sub_6FDAB8A0` 0x6FDAB8A0).
*
* - `nBaseVelocity = pMonStatsTxt->nVelocity << 8`
* - In `D2Game.dll` (`Monster.cpp:214`), monsters start with base `STAT_VELOCITYPERCENT = 75`.
* When running (`MONMODE_RUN`), `D2Game.dll` (`AiThink.cpp:912`, `MonsterMode.cpp:1925`) adds
* `100 * nRun / nVelocity - 100` to `STAT_VELOCITYPERCENT` and transmits the total in S2C
* `0x67` / `0x68` `nVelocity`.
* - `nVelocityPercent = Math.max(25, effectiveVelocityPercent)`
* - `dwVelocity = Math.floor((nVelocityPercent * nBaseVelocity) / 100)`
* - Per-tick displacement (25 ticks/s): `nVelocityMagnitude = (1024 * dwVelocity) >> 6 = 16 * dwVelocity`
* in `1/65536` sub-tiles/tick -> `(25 * dwVelocity) / 4096` sub-tiles/s.
*/
export function computeMonsterMoveSpeedSubTilesPerSec(
classId: number,
isRun = false,
velocityPercent?: number,
): number {
const pair = MONSTER_HCIDX_VELOCITIES[classId]
const kind = getMonsterKindByClassId(classId)
const baseVelocity =
pair && pair[0] > 0
? pair[0]
: kind && kind.velocity > 0
? kind.velocity
: 5
const runVelocity =
pair && pair[1] > 0
? pair[1]
: kind && kind.runVelocity > 0
? kind.runVelocity
: baseVelocity
let rawPercent: number
if (velocityPercent !== undefined && velocityPercent !== 0) {
rawPercent = velocityPercent
} else if (isRun && baseVelocity > 0 && runVelocity > baseVelocity) {
const runBonus = Math.floor((100 * runVelocity) / baseVelocity) - 100
rawPercent = 75 + Math.max(0, runBonus)
} else {
rawPercent = 75
}
const effectivePercent = Math.max(25, rawPercent)
const nBaseVelocity = baseVelocity << 8
const dwVelocity = Math.floor((effectivePercent * nBaseVelocity) / 100)
return (25 * dwVelocity) / 4096
}

View File

@ -128,12 +128,21 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent {
const moveType = r.u8() const moveType = r.u8()
const targetX = r.u16LE() const targetX = r.u16LE()
const targetY = r.u16LE() const targetY = r.u16LE()
let pathType = 0
let velocityPercent = 0
if (r.remaining >= 5) {
r.u16LE() // unk0x0A
pathType = r.u8()
velocityPercent = r.i16LE()
}
return { return {
type: 'UnitMove', type: 'UnitMove',
unitType: 1, unitType: 1,
id, id,
mode: resolveMoveMode(moveType), mode: resolveMoveMode(moveType),
to: { x: targetX, y: targetY }, to: { x: targetX, y: targetY },
...(pathType !== 0 ? { pathType } : {}),
...(velocityPercent !== 0 ? { velocityPercent } : {}),
} }
} }
@ -147,6 +156,13 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent {
const currentY = r.u16LE() const currentY = r.u16LE()
const targetType = clampUnitType(r.u8()) const targetType = clampUnitType(r.u8())
const targetId = r.u32LE() const targetId = r.u32LE()
let pathType = 0
let velocityPercent = 0
if (r.remaining >= 5) {
r.u16LE() // unk0x0F
pathType = r.u8()
velocityPercent = r.i16LE()
}
return { return {
type: 'UnitMove', type: 'UnitMove',
unitType: 1, unitType: 1,
@ -155,6 +171,8 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent {
from: { x: currentX, y: currentY }, from: { x: currentX, y: currentY },
to: { x: currentX, y: currentY }, to: { x: currentX, y: currentY },
target: { unitType: targetType, id: targetId }, target: { unitType: targetType, id: targetId },
...(pathType !== 0 ? { pathType } : {}),
...(velocityPercent !== 0 ? { velocityPercent } : {}),
} }
} }

View File

@ -63,6 +63,8 @@ export type ServerEvent =
readonly from?: Pt | undefined readonly from?: Pt | undefined
readonly to: Pt readonly to: Pt
readonly target?: UnitRef | undefined readonly target?: UnitRef | undefined
readonly velocityPercent?: number | undefined
readonly pathType?: number | undefined
} }
| { | {
readonly type: 'UnitReassign' readonly type: 'UnitReassign'

View File

@ -15,8 +15,15 @@ import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts
import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts' import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts' import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts' import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts' import { ClientWorld } from '../../src/client/world/client-world.ts'
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts' import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts'
import { MONSTER_HCIDX_VELOCITIES } from '../../src/common/units/monster-mapping.ts'
import {
computeMonsterMoveSpeedSubTilesPerSec,
getMonsterKindByClassId,
} from '../../src/common/units/monsters-data.ts'
import { ByteWriter, decodeD2gsServerPacket } from '../../src/netproto/index.ts'
import type { ClientCommand } from '../../src/netproto/domain/client-command.ts' import type { ClientCommand } from '../../src/netproto/domain/client-command.ts'
const FRAME_MS = 1000 / 60 const FRAME_MS = 1000 / 60
@ -275,3 +282,143 @@ describe('skill-cast facing (ClientWorld)', () => {
expect(castAt(4990, 4990)).toBe(32) // world −X−Y: screen north expect(castAt(4990, 4990)).toBe(32) // world −X−Y: screen north
}) })
}) })
describe('1.13c monster movement velocity & anti-rubber-banding (MonStats.txt + D2Common 0x6FD5D240)', () => {
it('populates all 734 MonStats.txt [Velocity, Run] pairs and computes exact 1.13c sub-tile/sec speeds', () => {
expect(MONSTER_HCIDX_VELOCITIES.length).toBe(734)
// Zombies (zombie1..zombie5, hcIdx = 5..9): Velocity = 1 in MonStats.txt ([1,3], [1,4], [1,4], [1,5], [1,5])
const expectedZombieRun = [3, 4, 4, 5, 5] as const
for (let hcIdx = 5; hcIdx <= 9; hcIdx++) {
expect(MONSTER_HCIDX_VELOCITIES[hcIdx]).toEqual([1, expectedZombieRun[hcIdx - 5]])
const kind = getMonsterKindByClassId(hcIdx)
expect(kind).toBeDefined()
expect(kind?.velocity).toBe(1)
expect(kind?.runVelocity).toBe(expectedZombieRun[hcIdx - 5])
// dwVelocity = floor((75 * 256) / 100) = 192 => 192 * 25 / 4096 = 1.171875 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false, 75)).toBe(1.171875)
expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false)).toBe(1.171875)
}
// Skeleton (skeleton1, hcIdx = 0): Velocity = 3, Run = 3
// dwVelocity = floor((75 * 768) / 100) = 576 => 576 * 25 / 4096 = 3.515625 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(0, false, 75)).toBe(3.515625)
// Fallen (fallen1, hcIdx = 19): Velocity = 5, Run = 5
// Walk (nVelocity = 75): dwVelocity = floor((75 * 1280) / 100) = 960 => 960 * 25 / 4096 = 5.859375 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(19, false, 75)).toBe(5.859375)
// With explicit run velocityPercent = 155: dwVelocity = floor((155 * 1280) / 100) = 1984 => 12.109375 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(19, true, 155)).toBe(12.109375)
// Corrupt Rogue (corruptrogue2, hcIdx = 44): Velocity = 6, Run = 8
// Fallback run without velocityPercent: runBonus = floor(800/6) - 100 = 33 => rawPercent = 108 => dwVelocity = floor((108 * 1536) / 100) = 1658 => 1658 * 25 / 4096 = 10.11962890625
expect(computeMonsterMoveSpeedSubTilesPerSec(44, true)).toBeCloseTo((25 * 1658) / 4096, 8)
// Akara (hcIdx = 148, Velocity = 1) and Kashya (hcIdx = 150, Velocity = 3) from 04-d2gs-idle-60s.d2cap
expect(computeMonsterMoveSpeedSubTilesPerSec(148, false, 75)).toBe(1.171875)
expect(computeMonsterMoveSpeedSubTilesPerSec(150, false, 75)).toBe(3.515625)
})
it('interpolates Zombie (hcIdx=5) 0x67 NPCMove and 0x68 NPCMoveToTarget at 1.171875 sub-tiles/s without rubber-banding', () => {
const session = new OnlineSession()
// 1. Spawn local player at (5010, 5000) and Zombie (classId=5) at (5000, 5000)
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'Hero', x: 5010, y: 5000, mode: 1, lifePct: 128 },
})
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 200, classId: 5, x: 5000, y: 5000, mode: 1, lifePct: 128 },
})
// 2. Server sends 0x67 NPCMove for Zombie to walk 3 sub-tiles to (5003, 5000) with nVelocity = 75 (takes 64 ticks = 2560ms)
const w67 = new ByteWriter(16)
w67.u8(0x67)
w67.u32LE(200)
w67.u8(0x01) // walk
w67.u16LE(5003)
w67.u16LE(5000)
w67.u16LE(0)
w67.u8(2)
w67.i16LE(75)
w67.u8(0)
session.handleServerEvent(decodeD2gsServerPacket(w67.toUint8Array()))
// Immediately after packet ingestion, Zombie must still be at (5000, 5000), NOT teleported to 5003
expect(session.world.getUnit('monster', 200)?.x).toBe(5000)
expect(session.interpolator.getEntityState(1, 200)?.x).toBe(5000)
// 3. Advance 32 ticks (1280ms = half of 2560ms): Zombie must be at 5001.5 (NOT prematurely snapped to 5003 as with old 9 sub-tiles/s!)
for (let i = 0; i < 32; i++) {
session.tick(40, 1000 + (i + 1) * 40)
}
expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5001.5, 5)
expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5001.5, 5)
expect(session.world.getUnit('monster', 200)?.mode).toBe(2) // still walking (WL)
// 4. Advance remaining 32 ticks (total 2560ms): Zombie reaches (5003, 5000) exactly when server 0x6D NPCStop arrives
for (let i = 32; i < 64; i++) {
session.tick(40, 1000 + (i + 1) * 40)
}
expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5003, 5)
// Server sends 0x6D NPCStop at (5003, 5000) -> zero rubber-band jump!
session.handleServerEvent({
type: 'UnitMove',
unitType: 'monster',
id: 200,
mode: 'stop',
to: { x: 5003, y: 5000 },
})
expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5003, 5)
// 5. Server sends 0x68 NPCMoveToTarget chasing local player (id=1 at 5010, 5000) from current (5003, 5000) with nVelocity=75
const w68 = new ByteWriter(21)
w68.u8(0x68)
w68.u32LE(200)
w68.u8(0x00) // walk in 0x68
w68.u16LE(5003)
w68.u16LE(5000)
w68.u8(0) // targetType = player
w68.u32LE(1) // targetId = 1
w68.u16LE(0)
w68.u8(2)
w68.i16LE(75)
w68.u8(0)
session.handleServerEvent(decodeD2gsServerPacket(w68.toUint8Array()))
// Step 25 ticks (1000ms): Zombie advances monotonically by 1.171875 sub-tiles to 5004.171875
let prevX = session.world.getUnit('monster', 200)!.x
for (let i = 0; i < 25; i++) {
session.tick(40, 4000 + (i + 1) * 40)
const curX = session.world.getUnit('monster', 200)!.x
expect(curX).toBeGreaterThanOrEqual(prevX)
prevX = curX
}
expect(prevX).toBeCloseTo(5003 + 1.171875, 4)
// Server sends next 0x68 re-sync after ~21 ticks (from X = 5004, Y = 5000): Zombie must NOT jump backward or teleport!
const w68Next = new ByteWriter(21)
w68Next.u8(0x68)
w68Next.u32LE(200)
w68Next.u8(0x00)
w68Next.u16LE(5004)
w68Next.u16LE(5000)
w68Next.u8(0)
w68Next.u32LE(1)
w68Next.u16LE(0)
w68Next.u8(2)
w68Next.i16LE(75)
w68Next.u8(0)
const xBeforeResync = session.world.getUnit('monster', 200)!.x
session.handleServerEvent(decodeD2gsServerPacket(w68Next.toUint8Array()))
const xAfterResync = session.world.getUnit('monster', 200)!.x
// Drift is ~0.17 sub-tiles (< 1.5 soft-reconcile threshold), so visual position stays at xBeforeResync without rubber-banding!
expect(xAfterResync).toBeCloseTo(xBeforeResync, 5)
session.destroy()
})
})

View File

@ -235,7 +235,31 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => {
}) })
}) })
it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => { it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType, pathType, and velocityPercent (1.13c D2PacketDef.h)', () => {
// Real 0x67 NPCMove from 04-d2gs-idle-60s.d2cap (Akara id=11 walking to (5313,6294) with pathType=2, nVelocity=75):
// 67 0b000000 01 c114 9618 0000 02 4b00 00
const akara67 = new Uint8Array([
0x67,
0x0b, 0x00, 0x00, 0x00, // id = 11
0x01, // moveType = 0x01 (walk)
0xc1, 0x14, // targetX = 5313
0x96, 0x18, // targetY = 6294
0x00, 0x00, // unk0x0A
0x02, // pathType = 2 (PATHTYPE_TOWARD)
0x4b, 0x00, // velocityPercent = 75 (0x004b)
0x00, // unk0x0F
])
const ev67 = decodeD2gsServerPacket(akara67)
expect(ev67).toEqual({
type: 'UnitMove',
unitType: 1,
id: 11,
mode: 'walk',
to: { x: 5313, y: 6294 },
pathType: 2,
velocityPercent: 75,
})
const w68Run = new ByteWriter(21) const w68Run = new ByteWriter(21)
w68Run.u8(0x68) w68Run.u8(0x68)
w68Run.u32LE(88) w68Run.u32LE(88)
@ -244,13 +268,21 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => {
w68Run.u16LE(5200) w68Run.u16LE(5200)
w68Run.u8(0) // targetType = player w68Run.u8(0) // targetType = player
w68Run.u32LE(1) // targetId = 1 w68Run.u32LE(1) // targetId = 1
w68Run.bytes(new Uint8Array(6)) w68Run.u16LE(0) // unk0x0F
w68Run.u8(2) // pathType = 2
w68Run.i16LE(155) // velocityPercent = 155 (e.g. Fallen run: 75 + (100*9/5 - 100) = 155)
w68Run.u8(0) // unk0x14
const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array()) const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array())
expect(ev68).toMatchObject({ expect(ev68).toMatchObject({
type: 'UnitMove', type: 'UnitMove',
unitType: 1, unitType: 1,
id: 88, id: 88,
mode: 'run', mode: 'run',
from: { x: 5100, y: 5200 },
target: { unitType: 0, id: 1 },
pathType: 2,
velocityPercent: 155,
}) })
}) })
}) })