diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 69c2fa4..c4fa7d7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -71,6 +71,7 @@ import { getViewportProfile } from '../view/viewport-profile.ts' import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts' import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts' import { OutboundRateLimiter } from './rate-limiter.ts' +import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts' import { isTownLevel } from '../../common/world/scene-population.ts' export type OnlineSessionPhase = @@ -1205,6 +1206,17 @@ export class OnlineSession { : { x: 0, y: 0 }), } 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) if ( @@ -1371,10 +1383,49 @@ export class OnlineSession { 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 + let toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX + let toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY + if ( + 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 = evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 if (unit && unit.unitType === 'player') { @@ -1396,22 +1447,41 @@ export class OnlineSession { this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) } } else if (ev.mode === 'stop') { - this.interpolator.stopEntity({ + const motionState = this.interpolator.stopEntity({ unitType: evTypeId, unitId: evUnitId, x: toX, y: toY, }) + if (unit) { + unit.x = motionState.x + unit.y = motionState.y + unit.direction = motionState.dir64 + } } 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, unitId: evUnitId, - ...(fromX !== undefined ? { currentX: Number(fromX) } : {}), - ...(fromY !== undefined ? { currentY: Number(fromY) } : {}), + ...(fromX !== undefined ? { currentX: fromX } : {}), + ...(fromY !== undefined ? { currentY: fromY } : {}), targetX: toX, targetY: toY, running: ev.mode === 'run', + ...(speedSubTilesPerSec !== undefined ? { speedSubTilesPerSec } : {}), }) + if (unit) { + unit.x = motionState.x + unit.y = motionState.y + unit.direction = motionState.dir64 + } } break } @@ -1516,6 +1586,8 @@ export class OnlineSession { this.predictor.setDirection(selfUnit.direction) } + this.world.tick(dtMs, nowMs) + this.interpolator.update(safeDt) for (const state of this.interpolator.getAllStates()) { const unit = this.world.getUnit(state.unitType as UnitType, state.unitId) @@ -1544,19 +1616,19 @@ export class OnlineSession { unit.mode = unitInTown ? 5 : 1 unit.moveMode = 'stop' } - } else if ( - unit.unitType === 'monster' && - !state.moving && - unit.lifePct > 0 && - (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) - ) { - unit.mode = 1 - unit.moveMode = 'stop' + } else if (unit.unitType === 'monster' && unit.lifePct > 0) { + if (state.moving && !unit.castState && unit.moveMode !== 'stop') { + unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 9 : 2 + } else if ( + !state.moving && + (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) + ) { + unit.mode = 1 + unit.moveMode = 'stop' + } } } } - - this.world.tick(dtMs, nowMs) this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) } diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 66c0370..edd8e0a 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -70,6 +70,8 @@ export class ClientUnit { targetY?: number | undefined targetUnit?: UnitRef | undefined moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined + velocityPercent?: number | undefined + pathType?: number | undefined mode: number direction: number lifePct: number diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 6956bb3..ef8d173 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -49,6 +49,7 @@ import { type CharacterAnimSpeedParams, } from '../../common/units/char-anim-speed.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 { ClientUnit, @@ -1535,8 +1536,7 @@ export class ClientWorld { } // Continuous unit movement interpolation across display frames - const step = (safeDt / 1000) * 10 - if (step > 0) { + if (safeDt > 0) { const inTown = isTownLevel(this.areaId) for (const unit of this._units.values()) { if (unit.dead || unit.castState || unit.unitType === 'missile') continue @@ -1545,6 +1545,16 @@ export class ClientWorld { const dy = unit.targetY - unit.y const dist = Math.hypot(dx, dy) 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) { unit.x = unit.targetX unit.y = unit.targetY @@ -1701,6 +1711,8 @@ export class ClientWorld { x: Number(ev.targetX ?? ev.x ?? 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') { ev = { @@ -1903,6 +1915,12 @@ export class ClientWorld { unit.targetUnit = ev.target const moveMode = ev.mode ?? 'walk' unit.moveMode = moveMode + if (ev.velocityPercent !== undefined) { + unit.velocityPercent = ev.velocityPercent + } + if (ev.pathType !== undefined) { + unit.pathType = ev.pathType + } if (prevX !== targetX || prevY !== targetY) { unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction) } diff --git a/src/common/units/monster-mapping.ts b/src/common/units/monster-mapping.ts index 47312a2..89faf46 100644 --- a/src/common/units/monster-mapping.ts +++ b/src/common/units/monster-mapping.ts @@ -1515,6 +1515,75 @@ export const MONSTER_HCIDX_TO_ID: Readonly> = { 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). */ diff --git a/src/common/units/monsters-data.ts b/src/common/units/monsters-data.ts index 6d0cafc..f0bcb41 100644 --- a/src/common/units/monsters-data.ts +++ b/src/common/units/monsters-data.ts @@ -2038,8 +2038,8 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly< attack1: { minDamage: 20, maxDamage: 40, toHit: 70 }, }, skeleton1: { - velocity: 4, - runVelocity: 4, + velocity: 3, + runVelocity: 3, minHp: 61, maxHp: 121, 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> = (() => { + const out: Record = {} + 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 | null = null function getCanonicalMonsterKinds(): Map { @@ -2056,11 +2070,14 @@ function getCanonicalMonsterKinds(): Map { const enriched = new Map() for (const [id, kind] of rawKinds) { 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 = { ...kind, enabled: kind.killable || !kind.inTown, - velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5), - runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), + velocity: ov?.velocity ?? canonicalVel ?? (kind.velocity > 0 ? kind.velocity : 5), + runVelocity: ov?.runVelocity ?? canonicalRun ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21), maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61), armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84), @@ -2093,7 +2110,12 @@ export const MONSTER_TABLE: ReadonlyMap & Readonly & Readonly & Readonly> +/** + * 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 +} diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index 0208243..bad8e32 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -128,12 +128,21 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent { const moveType = r.u8() const targetX = 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 { type: 'UnitMove', unitType: 1, id, mode: resolveMoveMode(moveType), 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 targetType = clampUnitType(r.u8()) const targetId = r.u32LE() + let pathType = 0 + let velocityPercent = 0 + if (r.remaining >= 5) { + r.u16LE() // unk0x0F + pathType = r.u8() + velocityPercent = r.i16LE() + } return { type: 'UnitMove', unitType: 1, @@ -155,6 +171,8 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent { from: { x: currentX, y: currentY }, to: { x: currentX, y: currentY }, target: { unitType: targetType, id: targetId }, + ...(pathType !== 0 ? { pathType } : {}), + ...(velocityPercent !== 0 ? { velocityPercent } : {}), } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index 4ea9e28..ed4cfe8 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -63,6 +63,8 @@ export type ServerEvent = readonly from?: Pt | undefined readonly to: Pt readonly target?: UnitRef | undefined + readonly velocityPercent?: number | undefined + readonly pathType?: number | undefined } | { readonly type: 'UnitReassign' diff --git a/tests/client/unit-facing-motion.test.ts b/tests/client/unit-facing-motion.test.ts index 3480762..bd42c51 100644 --- a/tests/client/unit-facing-motion.test.ts +++ b/tests/client/unit-facing-motion.test.ts @@ -15,8 +15,15 @@ import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts' import { LocalMovementPredictor } from '../../src/client/motion/predictor.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 { 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' 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 }) }) + +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() + }) +}) + diff --git a/tests/netproto/d2gs-s2c.test.ts b/tests/netproto/d2gs-s2c.test.ts index 0690eba..c45dea2 100644 --- a/tests/netproto/d2gs-s2c.test.ts +++ b/tests/netproto/d2gs-s2c.test.ts @@ -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) w68Run.u8(0x68) w68Run.u32LE(88) @@ -244,13 +268,21 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => { w68Run.u16LE(5200) w68Run.u8(0) // targetType = player 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()) expect(ev68).toMatchObject({ type: 'UnitMove', unitType: 1, id: 88, mode: 'run', + from: { x: 5100, y: 5200 }, + target: { unitType: 0, id: 1 }, + pathType: 2, + velocityPercent: 155, }) }) }) +