fix(client): keep unit animation facing aligned with movement direction

The walk/run clip often faced away from the direction of travel:

- deltaToDir16 used 0 = +X with rounding, while 1.13c clips start at
  south-west; units further reduced it with dir16 % 8 and the renderer
  with % dirCount, so 8-direction monsters showed the wrong row.
- re-pathing every frame while the mouse was held made the first path
  point the centre of the current cell, so the unit stepped back and
  turned around before moving on.
- server position packets rubber-banded the local player towards a
  lagging position, and spawnOrSnap reset the facing to 0.

Unit facing is now the 1.13c dir64 (D2Common 0x6FD5CF40), set once per
path segment and kept while idle, mapped to the clip row with the D2CMP
and moveToWorldSubTile paths from the rounded cell. The local player
follows D2Client's position gate 0x6FADA1B0: out-of-tolerance positions
send C2S 0x5F instead of moving the predictor, 0x15 is the authoritative
snap, and replay follows the recorded positions.
This commit is contained in:
troytt 2026-10-02 05:13:54 +00:00
parent ea252b0414
commit dd775c8156
15 changed files with 927 additions and 306 deletions

View File

@ -8,16 +8,21 @@
* - `0x67 NPCMove` (`{ unitId, moveType, x, y, targetX, targetY, ... }`) * - `0x67 NPCMove` (`{ unitId, moveType, x, y, targetX, targetY, ... }`)
* - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`) * - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`)
* - `0x6D NPCStop` (`{ unitId, x, y, life }`) * - `0x6D NPCStop` (`{ unitId, x, y, life }`)
*
* Facings are dir64 values (`common/units/direction.ts`), set once when a segment starts
* (like D2Common 0x6FD5D240) and kept while the segment is walked and after it ends.
*/ */
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
import { import {
D2_BASE_RUN_SUBTILES_PER_SEC, D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC, D2_BASE_WALK_SUBTILES_PER_SEC,
D2_TICK_MS, D2_TICK_MS,
DEFAULT_RUBBER_BAND_THRESHOLD,
deltaToDir16,
} from './predictor.ts' } from './predictor.ts'
/** Default drift (sub-tiles) beyond which a remote unit snaps to the server position. */
const DEFAULT_REMOTE_SNAP_THRESHOLD = 5.0
export interface RemoteEntityMotionState { export interface RemoteEntityMotionState {
readonly unitType: number readonly unitType: number
readonly unitId: number readonly unitId: number
@ -28,7 +33,8 @@ export interface RemoteEntityMotionState {
readonly speedSubTilesPerSec: number readonly speedSubTilesPerSec: number
readonly moving: boolean readonly moving: boolean
readonly running: boolean readonly running: boolean
readonly dir16: number /** Facing in the engine's 64-direction space. */
readonly dir64: number
} }
export interface RemoteMoveCommand { export interface RemoteMoveCommand {
@ -61,7 +67,7 @@ interface MutableRemoteEntityMotion {
speedSubTilesPerSec: number speedSubTilesPerSec: number
moving: boolean moving: boolean
running: boolean running: boolean
dir16: number dir64: number
} }
function entityKey(unitType: number, unitId: number): string { function entityKey(unitType: number, unitId: number): string {
@ -98,20 +104,23 @@ export class RemoteEntityInterpolator {
private readonly snapThreshold: number private readonly snapThreshold: number
constructor(options: { readonly snapThreshold?: number } = {}) { constructor(options: { readonly snapThreshold?: number } = {}) {
this.snapThreshold = options.snapThreshold ?? DEFAULT_RUBBER_BAND_THRESHOLD this.snapThreshold = options.snapThreshold ?? DEFAULT_REMOTE_SNAP_THRESHOLD
} }
/** /**
* Register or hard-snap an entity at `(x, y)`. * Register or hard-snap an entity at `(x, y)`. Without `dir64` a known entity keeps its
* facing (a `0x15` reassign carries no direction); a new one faces dir64 0.
*/ */
spawnOrSnap(params: { spawnOrSnap(params: {
readonly unitType: number readonly unitType: number
readonly unitId: number readonly unitId: number
readonly x: number readonly x: number
readonly y: number readonly y: number
readonly dir16?: number readonly dir64?: number
}): RemoteEntityMotionState { }): RemoteEntityMotionState {
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
const key = entityKey(params.unitType, params.unitId) const key = entityKey(params.unitType, params.unitId)
const previous = this.entities.get(key)
const state: MutableRemoteEntityMotion = { const state: MutableRemoteEntityMotion = {
unitType: params.unitType, unitType: params.unitType,
unitId: params.unitId >>> 0, unitId: params.unitId >>> 0,
@ -122,7 +131,7 @@ export class RemoteEntityInterpolator {
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC, speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
moving: false, moving: false,
running: false, running: false,
dir16: (params.dir16 ?? 0) & 15, dir64: params.dir64 ?? previous?.dir64 ?? 0,
} }
this.entities.set(key, state) this.entities.set(key, state)
return { ...state } return { ...state }
@ -150,7 +159,7 @@ export class RemoteEntityInterpolator {
speedSubTilesPerSec, speedSubTilesPerSec,
moving: false, moving: false,
running, running,
dir16: 0, dir64: 0,
} }
this.entities.set(key, state) this.entities.set(key, state)
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) { } else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
@ -171,7 +180,7 @@ export class RemoteEntityInterpolator {
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
if (dist > 1e-4) { if (dist > 1e-4) {
state.moving = true state.moving = true
state.dir16 = deltaToDir16(dx, dy, state.dir16) state.dir64 = dir64FromPoints(state.x, state.y, state.targetX, state.targetY, state.dir64)
// If visual position lagged behind server start, slightly boost speed to catch up smoothly // If visual position lagged behind server start, slightly boost speed to catch up smoothly
if (cmd.currentX !== undefined && cmd.currentY !== undefined) { if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY) const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
@ -216,11 +225,12 @@ export class RemoteEntityInterpolator {
state.targetY = cmd.y state.targetY = cmd.y
state.moving = false state.moving = false
} else { } else {
// Smoothly finish the small remaining glide to the authoritative stop point // Smoothly finish the small remaining glide to the authoritative stop point, facing it so
// the unit never slides backwards.
state.targetX = cmd.x state.targetX = cmd.x
state.targetY = cmd.y state.targetY = cmd.y
state.moving = true state.moving = true
state.dir16 = deltaToDir16(dx, dy, state.dir16) state.dir64 = dir64FromPoints(state.x, state.y, cmd.x, cmd.y, state.dir64)
} }
return { ...state } return { ...state }
@ -235,7 +245,7 @@ export class RemoteEntityInterpolator {
} }
/** /**
* Advance all interpolated entities by `dtMs` milliseconds. * Advance all interpolated entities by `dtMs` milliseconds. Facings do not change here.
*/ */
update(dtMs: number): void { update(dtMs: number): void {
if (dtMs <= 0) return if (dtMs <= 0) return
@ -255,7 +265,6 @@ export class RemoteEntityInterpolator {
const ratio = step / dist const ratio = step / dist
state.x += dx * ratio state.x += dx * ratio
state.y += dy * ratio state.y += dy * ratio
state.dir16 = deltaToDir16(dx, dy, state.dir16)
} }
} }
} }

View File

@ -1,22 +1,24 @@
/** /**
* Local Player Movement Predictor & Server Reconciliation * Local Player Movement Predictor (`src/client/motion/predictor.ts`, Feature F12).
* (`src/client/motion/predictor.ts`, Feature F12).
* *
* Steps the local player along an A* / string-pulled waypoint path at 25Hz (40ms per tick) * Steps the local player along an A* / string-pulled waypoint path at 25Hz (40ms per tick)
* or arbitrary `dtMs` frame deltas, and reconciles predicted position against authoritative * or arbitrary `dtMs` frame deltas, and applies the D2Client 1.13c rules for authoritative
* server packets: * server positions:
* - `0x15 ReassignPlayer` (`{ unitType, unitId, x, y, flags }`) * - `0x15 ReassignPlayer` repositions the player and drops its path
* - `0x95 PlayerLifeManaChange` (`{ x, y, ... }`) * ({@link LocalMovementPredictor.setPosition}; D2Client 0x6FB5F640 → 0x6FB57E20, no
* - `0x96 WalkVerify` (`{ stamina, x, y, dX, dY }`) * tolerance check).
* - `0x18` / `0x95` / `0x96` (`LifeMana` with a position) and `0x0D` go through the
* position tolerance gate D2Client 0x6FADA1B0
* ({@link LocalMovementPredictor.verifyServerPosition}). The client never moves the local
* player toward such a position; when the drift is out of tolerance it asks the server to
* resynchronise with C2S `0x5F`, and the server answers with `0x15` if it disagrees.
* *
* Reconciliation policy: * The facing is a dir64 (`common/units/direction.ts`), set only when a segment starts — on
* - When Euclidean drift `<= rubberBandThreshold` (default: `5` sub-tiles) and * {@link LocalMovementPredictor.setPath} and on each waypoint advance — like the engine's
* `teleport !== true`, applies a smooth exponential rubber-band correction offset * D2Common 0x6FD5D240, never per frame.
* that decays to 0 over `rubberBandHalfLifeMs`.
* - When drift `> rubberBandThreshold` (e.g. `> 5` sub-tiles) or `teleport === true`,
* hard-snaps immediately to the server position and clears active path prediction.
*/ */
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
import { import {
findPathWorld, findPathWorld,
type PathfindOptions, type PathfindOptions,
@ -27,7 +29,19 @@ import {
export const D2_TICK_MS = 40 // 25Hz D2 game tick export const D2_TICK_MS = 40 // 25Hz D2 game tick
export const D2_BASE_WALK_SUBTILES_PER_SEC = 9.0 // 6 yards/s * 1.5 sub-tiles/yard export const D2_BASE_WALK_SUBTILES_PER_SEC = 9.0 // 6 yards/s * 1.5 sub-tiles/yard
export const D2_BASE_RUN_SUBTILES_PER_SEC = 13.5 // 9 yards/s * 1.5 sub-tiles/yard export const D2_BASE_RUN_SUBTILES_PER_SEC = 13.5 // 9 yards/s * 1.5 sub-tiles/yard
export const DEFAULT_RUBBER_BAND_THRESHOLD = 5.0 // sub-tiles
/**
* D2Client 0x6FADA1B0 tolerance when the caller passes flag 1 (the `0x0D` mode handler
* D2Client 0x6FAC9830, e.g. its get-hit case at 0x6FAC99FB).
*/
const GATE_FLAG1_THRESHOLD = 10
/**
* D2Client 0x6FADA1B0 skips the "client is ahead of the server" exemption once the client is
* this far from the server position, as a squared distance (D2Common #10769, 0x6FD84580,
* returns `dx² + dy²` without a square root): 100 = 10 sub-tiles.
*/
const GATE_LEAD_MAX_DIST_SQ = 100
export interface PredictorConfig { export interface PredictorConfig {
readonly initialX?: number readonly initialX?: number
@ -35,71 +49,99 @@ export interface PredictorConfig {
readonly walkSpeedSubTilesPerSec?: number readonly walkSpeedSubTilesPerSec?: number
readonly runSpeedSubTilesPerSec?: number readonly runSpeedSubTilesPerSec?: number
readonly fasterRunWalkPercent?: number readonly fasterRunWalkPercent?: number
readonly rubberBandThreshold?: number
readonly rubberBandCorrectionRate?: number
} }
export interface ServerPositionUpdate { /** Which D2Client 0x6FADA1B0 caller is checking an authoritative position. */
export type ServerPositionSource =
/**
* `0x18` / `0x95` / `0x96` (callers 0x6FB5ED53 / 0x6FB5EC73 / 0x6FB5D9B8): gate flag 0 and
* target = position + (vx, vy), the server unit's motion vector.
*/
| { readonly kind: 'life-mana'; readonly vx: number; readonly vy: number }
/**
* `0x0D` for the local player: gate flag 1. D2Game sends the owner `0x0D` only for the
* death, get-hit, block and dead modes (and message mode 0x13); its stand / walk / run
* senders skip the owning client (D2MOO `PlrMsg.cpp`, `stru_6FD30708`).
*/
| { readonly kind: 'player-stop' }
export interface ServerPositionCheck {
/** Server sub-tile X (u16). */
readonly x: number readonly x: number
/** Server sub-tile Y (u16). */
readonly y: number readonly y: number
readonly teleport?: boolean readonly source: ServerPositionSource
readonly clearPathOnHardSnap?: boolean /** Latest `0x8F` pong round trip in ms (D2Client `[0x6FBC9804]`). */
readonly rttMs: number
/**
* Whether the player's room is in town (D2Client `DUNGEON_IsRoomInTown`), which makes the
* player stand in TN instead of NU.
*/
readonly inTown: boolean
} }
export interface ReconcileResult { export type ServerPositionVerdictReason =
readonly driftDistance: number /** X or Y is 0: the gate returns before comparing anything. */
readonly mode: 'none' | 'rubber-band' | 'hard-snap' | 'no-position'
readonly x: number /** Both axes within the tolerance. */
readonly y: number | 'within-threshold'
/** Out of tolerance, but the client is ahead along the server unit's motion. */
| 'leading-server'
/** Out of tolerance: the client asks for a resync. */
| 'exceeded'
export interface ServerPositionVerdict {
/** `request-resync`: send C2S `0x5F` with (`clientX`, `clientY`). The position is untouched. */
readonly action: 'none' | 'request-resync'
readonly reason: ServerPositionVerdictReason
/** The player's integer sub-tile X the gate compared (and reports in `0x5F`). */
readonly clientX: number
/** The player's integer sub-tile Y the gate compared (and reports in `0x5F`). */
readonly clientY: number
/** Per-axis tolerance in sub-tiles; 0 when the gate returned before computing it. */
readonly threshold: number
} }
export interface PredictorSnapshot { export interface PredictorSnapshot {
/** Authoritative + predicted logical sub-tile X. */ /** Predicted logical sub-tile X. */
readonly logicalX: number readonly logicalX: number
/** Authoritative + predicted logical sub-tile Y. */ /** Predicted logical sub-tile Y. */
readonly logicalY: number readonly logicalY: number
/** Visual sub-tile X (includes decaying rubber-band offset). */ /** Drawn sub-tile X; always `logicalX` (1.13c draws the simulated position). */
readonly x: number readonly x: number
/** Visual sub-tile Y (includes decaying rubber-band offset). */ /** Drawn sub-tile Y; always `logicalY`. */
readonly y: number readonly y: number
/** Remaining rubber-band offset X. */
readonly errorOffsetX: number
/** Remaining rubber-band offset Y. */
readonly errorOffsetY: number
/** True if currently moving along a waypoint path. */ /** True if currently moving along a waypoint path. */
readonly moving: boolean readonly moving: boolean
/** True if running (false if walking). */ /** True if running (false if walking). */
readonly running: boolean readonly running: boolean
/** 16-direction isometric facing index (0..15). */ /** Facing in the engine's 64-direction space (`common/units/direction.ts`). */
readonly dir16: number readonly dir64: number
/** Current active target waypoint, or null if idle. */ /** Current active target waypoint, or null if idle. */
readonly currentWaypoint: SubTilePoint | null readonly currentWaypoint: SubTilePoint | null
/** Remaining waypoints including current target. */ /** Remaining waypoints including current target. */
readonly remainingWaypoints: readonly SubTilePoint[] readonly remainingWaypoints: readonly SubTilePoint[]
} }
/** function assertSubTile(value: number, what: string): void {
* Convert a sub-tile delta `(dx, dy)` into a 16-direction D2 facing index (`0..15`). if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
*/ throw new RangeError(`${what} must be a u16 sub-tile coordinate, got ${String(value)}`)
export function deltaToDir16(dx: number, dy: number, fallback = 0): number { }
if (Math.abs(dx) < 1e-5 && Math.abs(dy) < 1e-5) return fallback & 15 }
const angle = Math.atan2(dy, dx) // -pi .. pi
const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2) /** D2Common #10769 (0x6FD84580): squared distance, no square root. */
return Math.round(normalized * 16) & 15 function distanceSquared(x1: number, y1: number, x2: number, y2: number): number {
return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
} }
export class LocalMovementPredictor { export class LocalMovementPredictor {
private logicalX: number private logicalX: number
private logicalY: number private logicalY: number
private errorOffsetX = 0
private errorOffsetY = 0
private walkSpeed: number private walkSpeed: number
private runSpeed: number private runSpeed: number
private fasterRunWalkPercent: number private fasterRunWalkPercent: number
private readonly rubberBandThreshold: number
private readonly rubberBandCorrectionRate: number
private running = true private running = true
private dir16 = 0 private dir64 = 0
private waypoints: SubTilePoint[] = [] private waypoints: SubTilePoint[] = []
private waypointIndex = 0 private waypointIndex = 0
@ -109,8 +151,6 @@ export class LocalMovementPredictor {
this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC
this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC
this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0 this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0
this.rubberBandThreshold = config.rubberBandThreshold ?? DEFAULT_RUBBER_BAND_THRESHOLD
this.rubberBandCorrectionRate = config.rubberBandCorrectionRate ?? 12.0
} }
/** /**
@ -133,41 +173,59 @@ export class LocalMovementPredictor {
this.fasterRunWalkPercent = frwPercent this.fasterRunWalkPercent = frwPercent
} }
/**
* Place the player at `(x, y)` and drop its path, keeping the facing. This is also the
* `0x15 ReassignPlayer` behaviour: D2Client 0x6FB5F640 → 0x6FB57E20 repositions the unit
* directly, whatever the packet's flag byte.
*/
setPosition(x: number, y: number): void { setPosition(x: number, y: number): void {
this.logicalX = x this.logicalX = x
this.logicalY = y this.logicalY = y
this.errorOffsetX = 0
this.errorOffsetY = 0
this.waypoints = [] this.waypoints = []
this.waypointIndex = 0 this.waypointIndex = 0
} }
/** /**
* Assign a pre-computed waypoint list (first element may be the current position or * Set the facing (dir64), e.g. when the server assigns one.
* the first target waypoint). */
setDirection(dir64: number): void {
assertDir64(dir64, 'dir64')
this.dir64 = dir64
}
/**
* Follow `path` (world sub-tiles), as returned by `findPathWorld` from the player's cell.
*
* 1.13c path points never include the unit's own position (D2MOO `PathMisc.cpp`:
* `PathPoints[0]` is the next point, the start lives in `tStartCoord`), while our pathfinder
* returns the start cell first. Leading points in the player's current cell are therefore
* dropped; otherwise every re-path while moving would first step back to the cell centre.
* If nothing is left the player stops and keeps its facing.
*/ */
setPath(path: readonly SubTilePoint[], running = this.running): void { setPath(path: readonly SubTilePoint[], running = this.running): void {
this.running = running this.running = running
if (path.length === 0) { const cellX = Math.round(this.logicalX)
this.waypoints = [] const cellY = Math.round(this.logicalY)
this.waypointIndex = 0 let start = 0
return while (
start < path.length &&
Math.round(path[start]!.x) === cellX &&
Math.round(path[start]!.y) === cellY
) {
start++
} }
// Skip leading waypoint if it coincides with current logical position this.waypoints = path.slice(start).map(p => ({ x: p.x, y: p.y }))
const first = path[0]!
const distFirst = Math.hypot(first.x - this.logicalX, first.y - this.logicalY)
const startIdx = distFirst < 0.05 && path.length > 1 ? 1 : 0
this.waypoints = path.slice(startIdx).map(p => ({ x: p.x, y: p.y }))
this.waypointIndex = 0 this.waypointIndex = 0
if (this.waypoints.length > 0) { this.faceCurrentWaypoint()
const target = this.waypoints[0]!
this.dir16 = deltaToDir16(target.x - this.logicalX, target.y - this.logicalY, this.dir16)
}
} }
/** /**
* Compute an A* + LOS string-pulled path on `grid` (in world sub-tiles) and start * Compute an A* + LOS string-pulled path on `grid` (in world sub-tiles) and start
* moving along it. * moving along it.
*
* The path starts from the player's cell, `Math.round` of the logical position, as in
* `CommandMapper`: `findPathWorld` floors its start, so a raw fractional position of N.5 or
* more would start the path one cell behind the player, which {@link setPath} cannot drop.
*/ */
moveToWorldSubTile( moveToWorldSubTile(
grid: SubTileWalkabilityGrid, grid: SubTileWalkabilityGrid,
@ -178,7 +236,14 @@ export class LocalMovementPredictor {
if (options.running !== undefined) { if (options.running !== undefined) {
this.running = options.running this.running = options.running
} }
const result = findPathWorld(grid, this.logicalX, this.logicalY, worldGoalX, worldGoalY, options) const result = findPathWorld(
grid,
Math.round(this.logicalX),
Math.round(this.logicalY),
worldGoalX,
worldGoalY,
options,
)
this.setPath(result.path, this.running) this.setPath(result.path, this.running)
return this.getRemainingWaypoints() return this.getRemainingWaypoints()
} }
@ -203,41 +268,24 @@ export class LocalMovementPredictor {
} }
/** /**
* Advance predicted movement and rubber-band decay by `dtMs` milliseconds. * Advance predicted movement by `dtMs` milliseconds.
*/ */
update(dtMs: number): PredictorSnapshot { update(dtMs: number): PredictorSnapshot {
if (dtMs <= 0) return this.getSnapshot() if (dtMs <= 0) return this.getSnapshot()
const dtSec = dtMs / 1000
// Decay rubber-band error offset smoothly toward 0 let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * (dtMs / 1000)
if (Math.abs(this.errorOffsetX) > 1e-4 || Math.abs(this.errorOffsetY) > 1e-4) {
const decay = Math.exp(-this.rubberBandCorrectionRate * dtSec)
this.errorOffsetX *= decay
this.errorOffsetY *= decay
if (Math.abs(this.errorOffsetX) < 1e-3) this.errorOffsetX = 0
if (Math.abs(this.errorOffsetY) < 1e-3) this.errorOffsetY = 0
}
let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * dtSec
while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) { while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) {
const target = this.waypoints[this.waypointIndex]! const target = this.waypoints[this.waypointIndex]!
const dx = target.x - this.logicalX const dx = target.x - this.logicalX
const dy = target.y - this.logicalY const dy = target.y - this.logicalY
const segDist = Math.hypot(dx, dy) const segDist = Math.hypot(dx, dy)
if (segDist <= 1e-5) {
this.logicalX = target.x
this.logicalY = target.y
this.waypointIndex++
continue
}
this.dir16 = deltaToDir16(dx, dy, this.dir16)
if (remainingDist >= segDist) { if (remainingDist >= segDist) {
this.logicalX = target.x this.logicalX = target.x
this.logicalY = target.y this.logicalY = target.y
remainingDist -= segDist remainingDist -= segDist
this.waypointIndex++ this.waypointIndex++
this.faceCurrentWaypoint()
} else { } else {
const ratio = remainingDist / segDist const ratio = remainingDist / segDist
this.logicalX += dx * ratio this.logicalX += dx * ratio
@ -255,62 +303,68 @@ export class LocalMovementPredictor {
} }
/** /**
* Reconcile predicted player state against an authoritative server packet * D2Client 1.13c position tolerance gate 0x6FADA1B0, for the local player.
* (`0x15 ReassignPlayer`, `0x95 PlayerLifeManaChange`, `0x96 WalkVerify`).
* *
* - If `update.teleport === true` or `drift > rubberBandThreshold` (`5` sub-tiles), * 1. X or Y of 0 → no check.
* performs a hard snap to `(update.x, update.y)`. * 2. Client position = the player's integer sub-tile (D2Client 0x6FAB1630 / 0x6FAB1660;
* - If `0 < drift <= rubberBandThreshold`, shifts `logicalX/Y` to `(update.x, update.y)` * logical N.0 is the centre of sub-tile N, so `Math.round`).
* while preserving visual continuity via `errorOffsetX/Y`, which smoothly decays to 0. * 3. Tolerance: 10 for flag 1 (`player-stop`); otherwise the player's mode base — NU 3,
* RN 7, anything else (WL, TN, TW, …) 5 — plus `(rttMs + 50) >> 7`.
* 4. Within tolerance on both axes → no correction.
* 5. Out of tolerance with flag 0 and target X > 0, where target = position + (vx, vy):
* if `d²(client, server) < 100` and `d²(client, target) < d²(client, server)` the client
* is ahead along the server's motion → no correction.
* 6. Otherwise the local player sends C2S `0x5F` with its own position and does not move.
*
* Both "no correction" outcomes are the gate's soft branch, which only corrects units that are
* off screen at both positions (sprite test 0x6FB1E860); the local player is the camera target
* and always on screen. The hard branch also skips units without a room when the server
* position has none either (0x6FAB1400 / 0x6FABC2E0); callers handle "no room" before calling.
* The gate's early exit for dead units is not modelled: D2Game ignores `0x5F` from a dead
* player (D2MOO `PlrMsg.cpp`, 0x6FC88530).
*/ */
reconcileServerPosition(update: ServerPositionUpdate, teleport?: boolean): ReconcileResult { verifyServerPosition(check: ServerPositionCheck): ServerPositionVerdict {
const visualX = this.logicalX + this.errorOffsetX assertSubTile(check.x, 'server x')
const visualY = this.logicalY + this.errorOffsetY assertSubTile(check.y, 'server y')
const dx = update.x - this.logicalX if (!Number.isFinite(check.rttMs) || check.rttMs < 0) {
const dy = update.y - this.logicalY throw new RangeError(`rttMs must be a finite non-negative number, got ${String(check.rttMs)}`)
const driftDistance = Math.hypot(dx, dy) }
const isTeleport = teleport ?? update.teleport === true const { source } = check
if (source.kind === 'life-mana' && (!Number.isInteger(source.vx) || !Number.isInteger(source.vy))) {
throw new RangeError(`vx/vy must be integers, got ${String(source.vx)}/${String(source.vy)}`)
}
if (isTeleport || driftDistance > this.rubberBandThreshold) { const clientX = Math.round(this.logicalX)
this.logicalX = update.x const clientY = Math.round(this.logicalY)
this.logicalY = update.y if (check.x === 0 || check.y === 0) {
this.errorOffsetX = 0 return { action: 'none', reason: 'no-position', clientX, clientY, threshold: 0 }
this.errorOffsetY = 0 }
if (update.clearPathOnHardSnap !== false) {
this.waypoints = [] const threshold =
this.waypointIndex = 0 source.kind === 'player-stop'
} ? GATE_FLAG1_THRESHOLD
return { : this.gateModeBase(check.inTown) + ((Math.trunc(check.rttMs) + 50) >>> 7)
driftDistance, const exceeded =
mode: 'hard-snap', Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold
x: this.logicalX, if (!exceeded) {
y: this.logicalY, return { action: 'none', reason: 'within-threshold', clientX, clientY, threshold }
}
if (source.kind === 'life-mana') {
const targetX = (check.x + source.vx) & 0xffff
const targetY = (check.y + source.vy) & 0xffff
if (targetX > 0) {
const toServer = distanceSquared(clientX, clientY, check.x, check.y)
if (
toServer < GATE_LEAD_MAX_DIST_SQ &&
distanceSquared(clientX, clientY, targetX, targetY) < toServer
) {
return { action: 'none', reason: 'leading-server', clientX, clientY, threshold }
}
} }
} }
if (driftDistance <= 1e-4) { return { action: 'request-resync', reason: 'exceeded', clientX, clientY, threshold }
return {
driftDistance: 0,
mode: 'none',
x: visualX,
y: visualY,
}
}
// Smooth rubber-band: anchor logical position to authoritative server position,
// and store the visual delta in errorOffsetX/Y so the rendered position glides
// smoothly without a jarring pop.
this.logicalX = update.x
this.logicalY = update.y
this.errorOffsetX = visualX - update.x
this.errorOffsetY = visualY - update.y
return {
driftDistance,
mode: 'rubber-band',
x: this.logicalX + this.errorOffsetX,
y: this.logicalY + this.errorOffsetY,
}
} }
getSnapshot(): PredictorSnapshot { getSnapshot(): PredictorSnapshot {
@ -319,15 +373,32 @@ export class LocalMovementPredictor {
return { return {
logicalX: this.logicalX, logicalX: this.logicalX,
logicalY: this.logicalY, logicalY: this.logicalY,
x: this.logicalX + this.errorOffsetX, x: this.logicalX,
y: this.logicalY + this.errorOffsetY, y: this.logicalY,
errorOffsetX: this.errorOffsetX,
errorOffsetY: this.errorOffsetY,
moving: currentWaypoint !== null, moving: currentWaypoint !== null,
running: this.running, running: this.running,
dir16: this.dir16, dir64: this.dir64,
currentWaypoint, currentWaypoint,
remainingWaypoints: this.waypoints.slice(this.waypointIndex), remainingWaypoints: this.waypoints.slice(this.waypointIndex),
} }
} }
/**
* Face the current waypoint. The engine sets the facing whenever a unit starts a segment
* toward its next path point (D2Common 0x6FD5D240), not while walking it.
*/
private faceCurrentWaypoint(): void {
const target = this.waypoints[this.waypointIndex]
if (target === undefined) return
this.dir64 = dir64FromPoints(this.logicalX, this.logicalY, target.x, target.y, this.dir64)
}
/**
* The local player's mode as D2Client picks it — NU / TN standing (TN in town), WL / TW
* walking, RN running — mapped to the 0x6FADA1B0 tolerance base: NU 3, RN 7, others 5.
*/
private gateModeBase(inTown: boolean): number {
if (this.waypointIndex >= this.waypoints.length) return inTown ? 5 : 3
return this.running ? 7 : 5
}
} }

View File

@ -1,4 +1,4 @@
import { facingToDirection } from '../../common/units/character.ts' import { dir64ToClipDirection } from '../../common/units/direction.ts'
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
import { npcDisplayName } from '../../common/units/npc-table.ts' import { npcDisplayName } from '../../common/units/npc-table.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
@ -346,7 +346,6 @@ export interface SceneSourceRenderContext {
readonly hudManager?: HudManager | null | undefined readonly hudManager?: HudManager | null | undefined
readonly nowMs?: number | undefined readonly nowMs?: number | undefined
readonly playerMoving?: boolean | undefined readonly playerMoving?: boolean | undefined
readonly playerFacingRadians?: number | undefined
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
} }
@ -420,10 +419,7 @@ function drawSceneUnitDrawable(
charArt.clips?.[requestedClipKey] ?? charArt.clips?.[requestedClipKey] ??
(isMoving ? charArt.clips?.wl : charArt.clips?.nu) (isMoving ? charArt.clips?.wl : charArt.clips?.nu)
const dirCount = Math.max(1, clip?.directions ?? charArt.directions) const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
const direction = const direction = dir64ToClipDirection(unit.direction, dirCount)
ctx.playerFacingRadians !== undefined
? facingToDirection(ctx.playerFacingRadians, dirCount)
: ((unit.direction % dirCount) + dirCount) % dirCount
const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand
const groupIndex = baseGroup + direction const groupIndex = baseGroup + direction
const group = const group =
@ -490,7 +486,7 @@ function drawSceneUnitDrawable(
(isMoving ? art.clips?.wl : undefined) ?? (isMoving ? art.clips?.wl : undefined) ??
art.clips?.nu art.clips?.nu
const dirCount = Math.max(1, clip?.directions ?? art.directions) const dirCount = Math.max(1, clip?.directions ?? art.directions)
const direction = ((unit.direction % dirCount) + dirCount) % dirCount const direction = dir64ToClipDirection(unit.direction, dirCount)
const baseGroup = const baseGroup =
clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset
const groupIndex = baseGroup + direction const groupIndex = baseGroup + direction

View File

@ -58,7 +58,7 @@ import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-in
import type { LevelView } from '../map/level-view.ts' import type { LevelView } from '../map/level-view.ts'
import type { MapService } from '../map/map-service.ts' import type { MapService } from '../map/map-service.ts'
import { RemoteEntityInterpolator } from '../motion/interpolate.ts' import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
import { LocalMovementPredictor } from '../motion/predictor.ts' import { LocalMovementPredictor, type ServerPositionSource } from '../motion/predictor.ts'
import { import {
getGlobalSettingsStore, getGlobalSettingsStore,
type SettingsStore, type SettingsStore,
@ -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 { isTownLevel } from '../../common/world/scene-population.ts'
export type OnlineSessionPhase = export type OnlineSessionPhase =
| 'idle' | 'idle'
@ -883,6 +884,59 @@ export class OnlineSession {
return error return error
} }
// ─── Local Player Server Position (D2Client 1.13c) ───────────
/**
* Check an authoritative local-player position with the 1.13c tolerance gate (D2Client
* 0x6FADA1B0, `LocalMovementPredictor.verifyServerPosition`). The player is never moved toward
* it: out of tolerance the client sends C2S `0x5F` with its own sub-tile, and the server
* answers with `0x15` if it disagrees.
*
* `.d2cap` replay is not part of the game: it feeds no local input to the predictor and has no
* server to answer `0x5F`, so there the local player takes the captured position directly.
*/
private verifyLocalPlayerPosition(x: number, y: number, source: ServerPositionSource): void {
if (this.phaseValue === 'replay') {
if (x !== 0 && y !== 0) this.predictor.setPosition(x, y)
return
}
const inTown = this.isLocalPlayerInTown()
// No loaded level contains the player, i.e. it has no room yet. The gate's hard branch
// skips a unit without a room when the server position has none either
// (0x6FAB1400 / 0x6FABC2E0), so there is nothing to verify against.
if (inTown === null) return
const verdict = this.predictor.verifyServerPosition({
x,
y,
source,
rttMs: this.pingMs,
inTown,
})
if (verdict.action === 'request-resync') {
this.forwardCommandToAdapter({
type: 'UpdatePlayerPos',
x: verdict.clientX,
y: verdict.clientY,
})
}
}
/**
* `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
* compares, `Math.round` of the logical position (logical N.0 is the centre of sub-tile N).
*/
private isLocalPlayerInTown(): boolean | null {
if (!this.mapService || this.world.areaId <= 0) return null
const snap = this.predictor.getSnapshot()
const levelView = this.mapService.findLevelViewAtWorldSubTile(
Math.round(snap.logicalX),
Math.round(snap.logicalY),
[this.world.areaId, ...this.world.getRevealedAreaIds()],
)
return levelView === null ? null : isTownLevel(levelView.areaId)
}
// ─── Outbound ClientCommand Dispatch ───────────────────────── // ─── Outbound ClientCommand Dispatch ─────────────────────────
/** /**
@ -1080,7 +1134,7 @@ export class OnlineSession {
unitId: u.id, unitId: u.id,
x: u.x, x: u.x,
y: u.y, y: u.y,
...(u.direction !== undefined ? { dir16: u.direction } : {}), ...(u.direction !== undefined ? { dir64: u.direction } : {}),
}) })
} }
break break
@ -1091,20 +1145,10 @@ export class OnlineSession {
const isLocalPlayer = const isLocalPlayer =
evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0) evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0)
if (isLocalPlayer) { if (isLocalPlayer) {
const prevSnap = this.predictor.getSnapshot() // 1.13c D2Client 0x6FB5F640 → 0x6FB57E20: `0x15` repositions the player directly, with
if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) { // no tolerance check whatever its flag byte, and drops the path.
this.predictor.setPosition(ev.at.x, ev.at.y) this.predictor.setPosition(ev.at.x, ev.at.y)
this.camera.snapTo(ev.at.x, ev.at.y) this.camera.snapTo(ev.at.x, ev.at.y)
} else {
const res = this.predictor.reconcileServerPosition({
x: ev.at.x,
y: ev.at.y,
teleport: ev.snap,
})
if (res.mode === 'hard-snap') {
this.camera.snapTo(ev.at.x, ev.at.y)
}
}
} else { } else {
this.interpolator.spawnOrSnap({ this.interpolator.spawnOrSnap({
unitType: evTypeId, unitType: evTypeId,
@ -1123,10 +1167,15 @@ export class OnlineSession {
this.predictor.setPosition(ev.at.x, ev.at.y) this.predictor.setPosition(ev.at.x, ev.at.y)
this.camera.snapTo(ev.at.x, ev.at.y) this.camera.snapTo(ev.at.x, ev.at.y)
} else { } else {
this.predictor.reconcileServerPosition({ if (ev.vx === undefined || ev.vy === undefined) {
x: ev.at.x, throw new Error(
y: ev.at.y, 'LifeMana has a position but no vx/vy motion vector (0x18/0x95/0x96 always carry both)',
teleport: false, )
}
this.verifyLocalPlayerPosition(ev.at.x, ev.at.y, {
kind: 'life-mana',
vx: ev.vx,
vy: ev.vy,
}) })
} }
} }
@ -1145,11 +1194,7 @@ export class OnlineSession {
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
if (isLocalPlayer) { if (isLocalPlayer) {
if (ev.mode === 'stop') { if (ev.mode === 'stop') {
this.predictor.reconcileServerPosition({ this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
x: toX,
y: toY,
teleport: false,
})
} }
} else if (ev.mode === 'stop') { } else if (ev.mode === 'stop') {
this.interpolator.stopEntity({ this.interpolator.stopEntity({
@ -1257,7 +1302,7 @@ export class OnlineSession {
if (unit) { if (unit) {
unit.x = state.x unit.x = state.x
unit.y = state.y unit.y = state.y
unit.direction = state.dir16 unit.direction = state.dir64
} }
} }
} }
@ -1289,7 +1334,7 @@ export class OnlineSession {
? selfUnit.mode ? selfUnit.mode
: 1 : 1
const selfDirection = const selfDirection =
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir16 selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64
out.push({ out.push({
id: this.world.selfId, id: this.world.selfId,
kind: 'player', kind: 'player',
@ -1383,7 +1428,7 @@ export class OnlineSession {
x: ux, x: ux,
y: uy, y: uy,
mode: u.mode, mode: u.mode,
direction: interp ? interp.dir16 : u.direction, direction: interp ? interp.dir64 : u.direction,
radiusX: 28, radiusX: 28,
radiusY: 20, radiusY: 20,
}) })
@ -1422,7 +1467,7 @@ export class OnlineSession {
x: ux, x: ux,
y: uy, y: uy,
mode, mode,
direction: interp ? interp.dir16 : u.direction, direction: interp ? interp.dir64 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse: false, isCorpse: false,
states: Array.from(u.states.list()), states: Array.from(u.states.list()),
@ -1464,7 +1509,7 @@ export class OnlineSession {
x: ux, x: ux,
y: uy, y: uy,
mode, mode,
direction: interp ? interp.dir16 : u.direction, direction: interp ? interp.dir64 : u.direction,
hpRatio: 1, hpRatio: 1,
isCorpse: false, isCorpse: false,
...(goldAmount !== undefined ? { goldAmount } : {}), ...(goldAmount !== undefined ? { goldAmount } : {}),
@ -1490,7 +1535,7 @@ export class OnlineSession {
...(targetX !== undefined ? { targetX } : {}), ...(targetX !== undefined ? { targetX } : {}),
...(targetY !== undefined ? { targetY } : {}), ...(targetY !== undefined ? { targetY } : {}),
mode, mode,
direction: interp ? interp.dir16 : u.direction, direction: interp ? interp.dir64 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse, isCorpse,
isChampion: u.isChampion, isChampion: u.isChampion,

View File

@ -1,5 +1,6 @@
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts' import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts' import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
import type { PointLightInput } from '../scene/lighting-presets.ts' import type { PointLightInput } from '../scene/lighting-presets.ts'
import type { UnitCastState } from '../world/client-unit.ts' import type { UnitCastState } from '../world/client-unit.ts'
@ -35,6 +36,11 @@ export interface RenderableUnitInput {
readonly targetX?: number | undefined readonly targetX?: number | undefined
readonly targetY?: number | undefined readonly targetY?: number | undefined
readonly mode?: number | undefined readonly mode?: number | undefined
/**
* Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept
* by the motion layer; 0 (south) when absent. Missiles: the 16-direction index used when no
* target is given.
*/
readonly direction?: number | undefined readonly direction?: number | undefined
readonly frame?: number | undefined readonly frame?: number | undefined
readonly lifePct?: number | undefined readonly lifePct?: number | undefined
@ -79,6 +85,7 @@ export interface SceneUnitDrawable {
readonly itemQuality?: number | undefined readonly itemQuality?: number | undefined
readonly goldAmount?: number | undefined readonly goldAmount?: number | undefined
readonly mode: number readonly mode: number
/** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */
readonly direction: number readonly direction: number
readonly frame: number readonly frame: number
readonly subX: number readonly subX: number
@ -262,6 +269,23 @@ function computeDirection8Or16(
return Math.floor(normalized * numDirs) % numDirs return Math.floor(normalized * numDirs) % numDirs
} }
/**
* The dir64 facing of a player, monster, object or item. The motion layer sets the facing
* when a unit starts a segment, so `direction` passes through as is; a unit given a target
* (its own, or its active cast's) faces it (D2Common 0x6FD5CF40 via `dir64FromPoints`) and
* keeps `direction` when already there.
*/
function unitFacingDir64(
unit: RenderableUnitInput,
targetX: number | undefined,
targetY: number | undefined,
): number {
const facing = unit.direction ?? 0
assertDir64(facing, `unit ${String(unit.id)} direction`)
if (targetX === undefined || targetY === undefined) return facing
return dir64FromPoints(unit.x, unit.y, targetX, targetY, facing)
}
export interface BuildSceneDrawablesResult { export interface BuildSceneDrawablesResult {
readonly unitDrawables: readonly SceneUnitDrawable[] readonly unitDrawables: readonly SceneUnitDrawable[]
readonly missileDrawables: readonly SceneMissileDrawable[] readonly missileDrawables: readonly SceneMissileDrawable[]
@ -484,14 +508,7 @@ export function buildSceneDrawables(
}) })
} }
const dir = computeDirection8Or16( const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY)
unit.x,
unit.y,
effectiveTargetX,
effectiveTargetY,
unit.direction ?? 0,
8,
)
const isWarp = unit.kind === 'warp' || unit.kind === 'tile' const isWarp = unit.kind === 'warp' || unit.kind === 'tile'
const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId

View File

@ -404,7 +404,7 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
} }
}) })
it('predicts 25Hz local movement and reconciles server updates via smooth rubber-band (<=5) and hard-snap (>5)', () => { it('predicts 25Hz local movement and applies the 1.13c server position rules (gate 0x6FADA1B0, 0x15)', () => {
const predictor = new LocalMovementPredictor({ const predictor = new LocalMovementPredictor({
initialX: 100, initialX: 100,
initialY: 200, initialY: 200,
@ -425,29 +425,36 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
expect(after1s.logicalY).toBeCloseTo(200, 4) expect(after1s.logicalY).toBeCloseTo(200, 4)
expect(after1s.moving).toBe(true) expect(after1s.moving).toBe(true)
// Reconcile small drift (2.0 sub-tiles <= 5 threshold): rubber-band // Server 2 sub-tiles behind a running player (RN base 7, RTT 0): within tolerance, no move.
const rb = predictor.reconcileServerPosition({ x: 111.5, y: 200 }) const within = predictor.verifyServerPosition({
expect(rb.mode).toBe('rubber-band') x: 112,
expect(rb.driftDistance).toBeCloseTo(2.0, 4) y: 200,
// Visual position remains 113.5 immediately after reconcile (no pop), logical is 111.5 source: { kind: 'life-mana', vx: 0, vy: 0 },
const snapAfterRb = predictor.getSnapshot() rttMs: 0,
expect(snapAfterRb.logicalX).toBeCloseTo(111.5, 4) inTown: false,
expect(snapAfterRb.x).toBeCloseTo(113.5, 4) })
expect(snapAfterRb.errorOffsetX).toBeCloseTo(2.0, 4) expect(within).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 7 })
expect(predictor.getSnapshot().logicalX).toBeCloseTo(113.5, 4)
// Stepping ticks decays errorOffsetX toward 0 // 10 sub-tiles behind: the client asks for a resync with its own sub-tile and keeps walking.
const decayed = predictor.stepTicks(15) const far = predictor.verifyServerPosition({
expect(Math.abs(decayed.errorOffsetX)).toBeLessThan(0.05) x: 104,
y: 200,
source: { kind: 'life-mana', vx: 0, vy: 0 },
rttMs: 0,
inTown: false,
})
expect(far).toMatchObject({ action: 'request-resync', reason: 'exceeded', clientX: 114, clientY: 200 })
const afterFar = predictor.getSnapshot()
expect(afterFar.logicalX).toBeCloseTo(113.5, 4)
expect(afterFar.moving).toBe(true)
// Reconcile large drift (8.0 sub-tiles > 5 threshold): hard-snap // 0x15 ReassignPlayer repositions directly and drops the path.
const currentLogicalX = predictor.getSnapshot().logicalX predictor.setPosition(104, 200)
const hs = predictor.reconcileServerPosition({ x: currentLogicalX + 8.0, y: 210 }) const afterReassign = predictor.getSnapshot()
expect(hs.mode).toBe('hard-snap') expect(afterReassign.logicalX).toBe(104)
const snapAfterHs = predictor.getSnapshot() expect(afterReassign.x).toBe(104)
expect(snapAfterHs.logicalX).toBeCloseTo(currentLogicalX + 8.0, 4) expect(afterReassign.moving).toBe(false)
expect(snapAfterHs.x).toBeCloseTo(currentLogicalX + 8.0, 4)
expect(snapAfterHs.errorOffsetX).toBe(0)
expect(snapAfterHs.moving).toBe(false)
// Walk speed verification // Walk speed verification
predictor.setRunning(false) predictor.setRunning(false)

View File

@ -717,6 +717,8 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
mana: 100, mana: 100,
stamina: 100, stamina: 100,
at: { x: 5100, y: 5100 }, at: { x: 5100, y: 5100 },
vx: 0,
vy: 0,
}) })
session.handleServerEvent({ session.handleServerEvent({
type: 'Merc', type: 'Merc',
@ -924,15 +926,20 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
life: 320, life: 320,
mana: 410, mana: 410,
stamina: 190, stamina: 190,
at: { x: 502, y: 501 }, // drift <= 5 -> rubber-band at: { x: 502, y: 501 },
vx: 0,
vy: 0,
}) })
} }
expect(session.world.areaId).toBe(1) expect(session.world.areaId).toBe(1)
expect(session.hudManager.hp).toBe(320) expect(session.hudManager.hp).toBe(320)
expect(session.hudManager.mana).toBe(410) expect(session.hudManager.mana).toBe(410)
expect(session.predictor.getSnapshot().logicalX).toBe(502) // 1.13c never moves the local player toward a 0x95 position (D2Client gate 0x6FADA1B0); a
expect(session.predictor.getSnapshot().logicalY).toBe(501) // 2-sub-tile drift needs no resync either.
expect(session.predictor.getSnapshot().logicalX).toBe(500)
expect(session.predictor.getSnapshot().logicalY).toBe(500)
expect(sentAdapterCommands.some(cmd => cmd.type === 'UpdatePlayerPos')).toBe(false)
// Step frame tick and build SceneSource snapshot // Step frame tick and build SceneSource snapshot
session.tick(40, 1000) session.tick(40, 1000)

View File

@ -0,0 +1,162 @@
/**
* Local player server positions in `OnlineSession` (D2Client 1.13c).
*
* `0x18` / `0x95` / `0x96` (LifeMana with a position) and a `0x0D` stop of the local player go
* through the position gate 0x6FADA1B0: the player is never moved toward them, and when the
* drift is out of tolerance the client sends C2S `0x5F` with its own sub-tile. `0x15` moves the
* player directly. A `.d2cap` replay has no server to answer `0x5F`, so it takes the captured
* positions as they are.
*/
import { describe, expect, it } from 'vitest'
import type { MapService } from '../../src/client/map/map-service.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { createSettingsStore } from '../../src/client/settings/settings-store.ts'
import { formatD2CapHeader, type ClientCommand, type ServerEvent } from '../../src/netproto/index.ts'
const ROGUE_ENCAMPMENT = 1
const BLOOD_MOOR = 2
/** A map service whose only loaded level is `areaId`, covering every sub-tile (or none). */
function stubMapService(areaId: number | null): MapService {
return {
onLoadActPacket: () => null,
onAddRoomDataPacket: () => null,
onRemoveRoomDataPacket: () => null,
getActiveLevelView: () => null,
getTileAtlas: () => null,
findLevelViewAtWorldSubTile: (_x: number, _y: number, areaIds: Iterable<number>) =>
areaId !== null && [...areaIds].includes(areaId) ? { areaId } : null,
} as unknown as MapService
}
/** A session with the local player (id 1) assigned at (5000, 5000) in `areaId`. */
function sessionInLevel(areaId: number, loadedLevel: number | null = areaId): OnlineSession {
const session = new OnlineSession({
settingsStore: createSettingsStore(),
mapService: stubMapService(loadedLevel),
})
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x1234, areaId })
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 'player', id: 1, classId: 1, name: 'gate', x: 5000, y: 5000 },
isSelf: true,
})
return session
}
function lifeMana(x: number, y: number, vx = 0, vy = 0): ServerEvent {
return { type: 'LifeMana', life: 100, mana: 100, stamina: 100, at: { x, y }, vx, vy }
}
function resyncRequests(session: OnlineSession): ClientCommand[] {
return session.getSentCommands().filter(cmd => cmd.type === 'UpdatePlayerPos')
}
describe('OnlineSession local player position gate (D2Client 0x6FADA1B0)', () => {
it('asks for a resync with its own sub-tile when a 0x95 position is out of tolerance', () => {
const session = sessionInLevel(BLOOD_MOOR)
session.handleServerEvent(lifeMana(5003, 5000)) // NU tolerance 3
expect(resyncRequests(session)).toEqual([])
session.handleServerEvent(lifeMana(5004, 5000))
expect(resyncRequests(session)).toEqual([{ type: 'UpdatePlayerPos', x: 5000, y: 5000 }])
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5000, logicalY: 5000 })
})
it('uses the town standing tolerance (TN 5) in a town level', () => {
const session = sessionInLevel(ROGUE_ENCAMPMENT)
session.handleServerEvent(lifeMana(5005, 4995))
expect(resyncRequests(session)).toEqual([])
session.handleServerEvent(lifeMana(5006, 5000))
expect(resyncRequests(session)).toEqual([{ type: 'UpdatePlayerPos', x: 5000, y: 5000 }])
})
it('widens the tolerance by the 0x8F round trip: (rtt + 50) >> 7', () => {
const session = sessionInLevel(BLOOD_MOOR)
session.handleServerEvent({ type: 'Pong', rttMs: 78, tickCount: 0 })
session.handleServerEvent(lifeMana(5004, 5000)) // 3 + 1
expect(resyncRequests(session)).toEqual([])
session.handleServerEvent(lifeMana(5005, 5000))
expect(resyncRequests(session)).toHaveLength(1)
})
it('does not ask while the client leads the server along its motion vector', () => {
const session = sessionInLevel(BLOOD_MOOR)
// The server is 5 sub-tiles behind but heading toward the client.
session.handleServerEvent(lifeMana(4995, 5000, 3, 0))
expect(resyncRequests(session)).toEqual([])
session.handleServerEvent(lifeMana(4995, 5000, -3, 0))
expect(resyncRequests(session)).toHaveLength(1)
})
it('checks a 0x0D stop of the local player with the flag-1 tolerance of 10', () => {
const session = sessionInLevel(BLOOD_MOOR)
const stop = (x: number): ServerEvent => ({
type: 'UnitMove',
unitType: 'player',
id: 1,
mode: 'stop',
to: { x, y: 5000 },
})
session.handleServerEvent(stop(5010))
expect(resyncRequests(session)).toEqual([])
session.handleServerEvent(stop(5011))
expect(resyncRequests(session)).toEqual([{ type: 'UpdatePlayerPos', x: 5000, y: 5000 }])
expect(session.predictor.getSnapshot().logicalX).toBe(5000)
})
it('skips the check while no loaded level contains the player', () => {
const session = sessionInLevel(BLOOD_MOOR, null)
session.handleServerEvent(lifeMana(5100, 5100))
expect(resyncRequests(session)).toEqual([])
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5000, logicalY: 5000 })
})
it('fails fast on a LifeMana position without its motion vector', () => {
const session = sessionInLevel(BLOOD_MOOR)
expect(() =>
session.handleServerEvent({ type: 'LifeMana', life: 1, mana: 1, stamina: 1, at: { x: 5004, y: 5000 } }),
).toThrow(/vx\/vy/)
})
it('moves the player directly on 0x15 and keeps its facing', () => {
const session = sessionInLevel(BLOOD_MOOR)
session.predictor.setDirection(40)
session.handleServerEvent({
type: 'UnitReassign',
unitType: 'player',
id: 1,
at: { x: 5100, y: 5120 },
snap: false,
})
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5100, logicalY: 5120, dir64: 40 })
expect(resyncRequests(session)).toEqual([])
})
it('follows captured positions in a .d2cap replay, which has no server to answer 0x5F', () => {
const session = new OnlineSession({
settingsStore: createSettingsStore(),
mapService: stubMapService(BLOOD_MOOR),
})
session.loadD2CapReplay(
formatD2CapHeader({
format: 'd2cap-v1',
createdAt: '2026-10-02T00:00:00Z',
server: '127.0.0.1',
account: 'gate',
character: 'gate',
}),
)
expect(session.phase).toBe('replay')
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x1234, areaId: BLOOD_MOOR })
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 'player', id: 1, classId: 1, name: 'gate', x: 5000, y: 5000 },
isSelf: true,
})
session.handleServerEvent(lifeMana(5040, 5010, 4, 1))
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5040, logicalY: 5010 })
expect(resyncRequests(session)).toEqual([])
})
})

View File

@ -149,7 +149,8 @@ describe('Issue #539: player spawn is rendered at the server position inside the
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService }) const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
session.loadD2CapReplay(text) session.loadD2CapReplay(text)
session.replayAllD2Cap() session.replayAllD2Cap()
// Let any rubber-band reconciliation settle; no movement commands are issued. // Replay places the local player at each captured position; tick so the camera follows.
// No movement commands are issued.
for (let i = 0; i < 50; i++) session.tick(40, 1000 + i * 40) for (let i = 0; i < 50; i++) session.tick(40, 1000 + i * 40)
expect(session.disconnectReason).toBeNull() expect(session.disconnectReason).toBeNull()

View File

@ -0,0 +1,263 @@
/**
* Unit facing while moving (dir64, `src/common/units/direction.ts`).
*
* D2 1.13c sets a unit's facing once, when it starts a segment toward its next path point
* (D2Common 0x6FD5D240), and draws it through D2CMP #10020. These tests pin the client's
* local predictor and remote interpolator to that rule: the facing must match the movement
* direction, must not change in the middle of a segment, and must survive re-pathing while
* the mouse is held, idle frames and server repositioning.
*/
import { describe, expect, it } from 'vitest'
import { CommandMapper } from '../../src/client/input/command-mapper.ts'
import type { MapService } from '../../src/client/map/map-service.ts'
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 { ClientWorld } from '../../src/client/world/client-world.ts'
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts'
import type { ClientCommand } from '../../src/netproto/domain/client-command.ts'
const FRAME_MS = 1000 / 60
/** dir64 for the 16 screen headings (0 = screen south, clockwise), as the engine quantises them. */
const HEADING_DIR64 = [0, 4, 7, 11, 15, 20, 23, 27, 32, 36, 40, 43, 47, 52, 56, 59] as const
/** World unit vector of screen heading `k`: screen south is world (+1, +1), 22.5° per step. */
function headingVector(k: number): readonly [number, number] {
const radians = ((45 + k * 22.5) * Math.PI) / 180
return [Math.cos(radians), Math.sin(radians)]
}
/** A walkable world sub-tile grid with its origin at (`originX`, `originY`). */
function makeGrid(
originX: number,
originY: number,
size: number,
blocked: (x: number, y: number) => boolean = () => false,
): SubTileWalkabilityGrid {
const walkable = new Uint8Array(size * size)
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
walkable[y * size + x] = blocked(originX + x, originY + y) ? 0 : 1
}
}
return { subWidth: size, subHeight: size, walkable, subOriginX: originX, subOriginY: originY }
}
/**
* The production input path: `CommandMapper` re-paths from the player's cell on every frame the
* left button is held (`triggerPrimaryWorldAction` → `startLocalPathPrediction`).
*/
function makeMapper(grid: SubTileWalkabilityGrid, predictor: LocalMovementPredictor): CommandMapper {
const hudManager = new HudManager()
hudManager.resize(800, 600)
const mapService = { getActiveLevelView: () => grid } as unknown as MapService
const sent: ClientCommand[] = []
return new CommandMapper({
world: new ClientWorld(),
hudManager,
predictor,
mapService,
sendCommand: cmd => sent.push(cmd),
})
}
describe('local player facing (LocalMovementPredictor)', () => {
it('faces each of the 16 screen headings and draws the matching clip direction', () => {
for (let k = 0; k < 16; k++) {
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 })
const [dx, dy] = headingVector(k)
predictor.setPath([{ x: 1000 + 20 * dx, y: 1000 + 20 * dy }], true)
const { dir64 } = predictor.getSnapshot()
expect(dir64, `heading ${k}`).toBe(HEADING_DIR64[k])
expect(dir64ToClipDirection(dir64, 16), `heading ${k}`).toBe(k)
if (k % 2 === 0) expect(dir64ToClipDirection(dir64, 8), `heading ${k}`).toBe(k / 2)
}
})
it('keeps one facing and always moves forward while the mouse is held (re-path every frame)', () => {
const grid = makeGrid(4900, 4900, 200)
for (let k = 0; k < 16; k++) {
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
const mapper = makeMapper(grid, predictor)
const [dx, dy] = headingVector(k)
const goalX = 5000 + Math.round(30 * dx)
const goalY = 5000 + Math.round(30 * dy)
const expectedDir64 = dir64FromPoints(5000, 5000, goalX, goalY, 0)
let previousDistance = Number.POSITIVE_INFINITY
let frames = 0
for (; frames < 600; frames++) {
mapper.triggerPrimaryWorldAction(goalX, goalY, null, frames * FRAME_MS)
const snap = predictor.update(FRAME_MS)
expect(snap.dir64, `heading ${k} frame ${frames}`).toBe(expectedDir64)
if (!snap.moving) break
const distance = Math.hypot(goalX - snap.logicalX, goalY - snap.logicalY)
expect(distance, `heading ${k} frame ${frames}`).toBeLessThan(previousDistance)
previousDistance = distance
}
expect(frames, `heading ${k}`).toBeLessThan(600)
// The player stops inside the goal cell: 1.13c path points never include the unit's cell.
const stopped = predictor.getSnapshot()
expect(Math.round(stopped.logicalX)).toBe(goalX)
expect(Math.round(stopped.logicalY)).toBe(goalY)
// Holding the button at the goal changes nothing.
for (let i = 0; i < 10; i++) {
mapper.triggerPrimaryWorldAction(goalX, goalY, null, (frames + i) * FRAME_MS)
const idle = predictor.update(FRAME_MS)
expect(idle).toMatchObject({ moving: false, dir64: expectedDir64 })
expect(idle.logicalX).toBe(stopped.logicalX)
expect(idle.logicalY).toBe(stopped.logicalY)
}
}
})
it('never steps back to the cell centre when a new click arrives mid-cell', () => {
const grid = makeGrid(4900, 4900, 200)
const cases = [
{ runTo: { x: 5030, y: 5000 }, travelled: 4.3, clickAt: { x: 5020, y: 5010 } },
{ runTo: { x: 5030, y: 5000 }, travelled: 4.7, clickAt: { x: 5025, y: 4990 } },
{ runTo: { x: 4970, y: 5000 }, travelled: 4.3, clickAt: { x: 4980, y: 5010 } },
{ runTo: { x: 5000, y: 5030 }, travelled: 2.2, clickAt: { x: 4990, y: 5020 } },
]
for (const { runTo, travelled, clickAt } of cases) {
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
const mapper = makeMapper(grid, predictor)
mapper.triggerPrimaryWorldAction(runTo.x, runTo.y, null, 0)
const speed = predictor.getEffectiveSpeedSubTilesPerSec()
const before = predictor.update((travelled / speed) * 1000)
expect(Math.hypot(before.logicalX - 5000, before.logicalY - 5000)).toBeCloseTo(travelled, 6)
mapper.triggerPrimaryWorldAction(clickAt.x, clickAt.y, null, 1000)
const turned = predictor.getSnapshot()
expect(turned.dir64).toBe(
dir64FromPoints(before.logicalX, before.logicalY, clickAt.x, clickAt.y, 0),
)
const after = predictor.update(FRAME_MS)
const toGoalX = clickAt.x - before.logicalX
const toGoalY = clickAt.y - before.logicalY
const stepX = after.logicalX - before.logicalX
const stepY = after.logicalY - before.logicalY
// The first step heads straight for the new goal, not back to the cell the path started in.
const cosine = (stepX * toGoalX + stepY * toGoalY) / (Math.hypot(stepX, stepY) * Math.hypot(toGoalX, toGoalY))
expect(cosine).toBeCloseTo(1, 9)
}
})
it('sets the facing only when a segment starts', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
predictor.setPath(
[
{ x: 110, y: 100 },
{ x: 110, y: 110 },
],
true,
)
expect(predictor.getSnapshot().dir64).toBe(56) // world +X is screen south-east
const seen: Array<{ waypointX: number; waypointY: number; dir64: number }> = []
for (let frame = 0; frame < 200; frame++) {
const snap = predictor.update(FRAME_MS)
if (!snap.moving) break
seen.push({ waypointX: snap.currentWaypoint!.x, waypointY: snap.currentWaypoint!.y, dir64: snap.dir64 })
}
expect(seen.length).toBeGreaterThan(10)
for (const step of seen) {
expect(step.dir64).toBe(step.waypointY === 100 ? 56 : 7) // world +Y is screen south-west
}
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 110, logicalY: 110, dir64: 7 })
})
it('keeps the facing while idle, on an empty path and on a 0x15 reposition', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
predictor.setPath([{ x: 90, y: 100 }], false)
expect(predictor.getSnapshot().dir64).toBe(23) // world −X is screen north-west
predictor.update(5000)
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23 })
predictor.update(1000)
predictor.setPath([], true)
predictor.setPath([{ x: 90, y: 100 }], true) // only the player's own cell
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 90, dir64: 23 })
predictor.setPosition(300, 400)
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 300, logicalY: 400, moving: false, dir64: 23 })
predictor.setDirection(40)
expect(predictor.getSnapshot().dir64).toBe(40)
expect(() => predictor.setDirection(64)).toThrow(RangeError)
expect(() => predictor.setDirection(-1)).toThrow(RangeError)
})
it('paths from the player cell in moveToWorldSubTile, like CommandMapper', () => {
const grid = makeGrid(4900, 4900, 200)
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
predictor.setPath([{ x: 5030, y: 5000 }], true)
const at = predictor.update((4.7 / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000)
expect(at.logicalX).toBeCloseTo(5004.7, 6)
// findPathWorld floors its start (cell 5004); the predictor must path from cell 5005.
const remaining = predictor.moveToWorldSubTile(grid, 5030, 5000)
expect(remaining).toEqual([{ x: 5030, y: 5000 }])
const next = predictor.update(FRAME_MS)
expect(next.logicalX).toBeGreaterThan(at.logicalX)
expect(next.dir64).toBe(56)
})
})
describe('remote unit facing (RemoteEntityInterpolator)', () => {
it('faces the 16 screen headings when a move starts and keeps the facing to the end', () => {
const interp = new RemoteEntityInterpolator()
for (let k = 0; k < 16; k++) {
interp.spawnOrSnap({ unitType: 1, unitId: k, x: 5000, y: 5000 })
const [dx, dy] = headingVector(k)
const started = interp.startMove({
unitType: 1,
unitId: k,
currentX: 5000,
currentY: 5000,
targetX: 5000 + 10 * dx,
targetY: 5000 + 10 * dy,
running: true,
})
expect(started.dir64, `heading ${k}`).toBe(HEADING_DIR64[k])
}
for (let frame = 0; frame < 120; frame++) {
interp.update(FRAME_MS)
for (let k = 0; k < 16; k++) {
expect(interp.getEntityState(1, k)!.dir64, `heading ${k} frame ${frame}`).toBe(HEADING_DIR64[k])
}
}
expect(interp.getAllStates().every(s => !s.moving)).toBe(true)
})
it('keeps the facing on a reassign without a direction and starts new units facing dir64 0', () => {
const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 100, y: 100, dir64: 40 })
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130 }).dir64).toBe(40)
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130, dir64: 12 }).dir64).toBe(12)
expect(interp.spawnOrSnap({ unitType: 0, unitId: 8, x: 100, y: 100 }).dir64).toBe(0)
expect(() => interp.spawnOrSnap({ unitType: 0, unitId: 9, x: 1, y: 1, dir64: 64 })).toThrow(RangeError)
})
it('faces the stop point while gliding to it and keeps the facing on a hard stop', () => {
const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 1, unitId: 1, x: 5000, y: 5000 })
expect(interp.startMove({ unitType: 1, unitId: 1, targetX: 5010, targetY: 5000 }).dir64).toBe(56)
interp.update(200)
const mid = interp.getEntityState(1, 1)!
expect(mid.dir64).toBe(56)
const glide = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003, y: 5002 })
expect(glide.moving).toBe(true)
expect(glide.dir64).toBe(dir64FromPoints(mid.x, mid.y, 5003, 5002, 56))
interp.update(1000)
expect(interp.getEntityState(1, 1)).toMatchObject({ x: 5003, y: 5002, moving: false, dir64: glide.dir64 })
// Within 0.15 sub-tiles the unit snaps and keeps its facing.
const snapped = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003.1, y: 5002 })
expect(snapped).toMatchObject({ x: 5003.1, moving: false, dir64: glide.dir64 })
})
})

View File

@ -1252,16 +1252,31 @@ describe('Tier 1 — F13: Client Movement Prediction, Server Reconciliation & Pa
expect(snap.x).toBeLessThanOrEqual(120) expect(snap.x).toBeLessThanOrEqual(120)
}) })
it('F13.3: LocalMovementPredictor reconciles small server drift (<=5 sub-tiles) via rubber-band', () => { it('F13.3: LocalMovementPredictor keeps a server position within the 1.13c tolerance (gate 0x6FADA1B0)', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 }) const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
const res = predictor.reconcileServerPosition({ x: 103, y: 100 }) const res = predictor.verifyServerPosition({
expect(res.mode).toBe('rubber-band') x: 103,
y: 100,
source: { kind: 'life-mana', vx: 0, vy: 0 },
rttMs: 0,
inTown: false,
})
expect(res).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 3 })
expect(predictor.getSnapshot().x).toBe(100)
}) })
it('F13.4: LocalMovementPredictor reconciles large server drift (>5 sub-tiles) or 0x15 snap via hard-snap', () => { it('F13.4: LocalMovementPredictor asks for a 0x5F resync on large drift and 0x15 repositions', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 }) const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
const res = predictor.reconcileServerPosition({ x: 200, y: 200 }, true) const res = predictor.verifyServerPosition({
expect(res.mode).toBe('hard-snap') x: 200,
y: 200,
source: { kind: 'life-mana', vx: 0, vy: 0 },
rttMs: 0,
inTown: false,
})
expect(res).toMatchObject({ action: 'request-resync', reason: 'exceeded', clientX: 100, clientY: 100 })
expect(predictor.stepTicks(0).x).toBe(100)
predictor.setPosition(200, 200)
expect(predictor.stepTicks(0).x).toBe(200) expect(predictor.stepTicks(0).x).toBe(200)
}) })

View File

@ -692,9 +692,11 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
w15Small.u16LE(5050) w15Small.u16LE(5050)
w15Small.u8(0) w15Small.u8(0)
const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables }) const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables })
expect(evSmall.type).toBe('UnitReassign')
if (evSmall.type === 'UnitReassign') { if (evSmall.type === 'UnitReassign') {
const rec = predictor.reconcileServerPosition(evSmall.at, evSmall.snap) // 1.13c `0x15` repositions the player directly, however small the drift (D2Client 0x6FB57E20).
expect(rec.mode).toBe('rubber-band') predictor.setPosition(evSmall.at.x, evSmall.at.y)
expect(predictor.getSnapshot()).toMatchObject({ x: 5103, y: 5050, moving: false })
} }
const w0f = new ByteWriter() const w0f = new ByteWriter()
@ -721,7 +723,10 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
moveType: 0x17, moveType: 0x17,
}) })
interp.update(200) interp.update(200)
expect(interp.getEntityState(0, 2)!.x).toBeGreaterThan(5200) const mover = interp.getEntityState(0, 2)!
expect(mover.x).toBeGreaterThan(5200)
// World +X is screen south-east: dir64 56 (D2Common 0x6FD5CF40).
expect(mover.dir64).toBe(56)
} }
}) })

View File

@ -244,10 +244,17 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
} }
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThan(0) expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThan(0)
// Reconcile predictor and play Act 1 Town BGM // Check a server position with the 1.13c gate (D2Client 0x6FADA1B0) and play Act 1 Town BGM
const predictor = new LocalMovementPredictor({ initialX: world.self.x, initialY: world.self.y }) const predictor = new LocalMovementPredictor({ initialX: world.self.x, initialY: world.self.y })
const rec = predictor.reconcileServerPosition({ x: world.self.x + 2, y: world.self.y }) const verdict = predictor.verifyServerPosition({
expect(rec.mode).toBe('rubber-band') x: world.self.x + 2,
y: world.self.y,
source: { kind: 'life-mana', vx: 0, vy: 0 },
rttMs: 0,
inTown: true,
})
// Standing in town is TN, whose tolerance base is 5.
expect(verdict).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 5 })
const playedBgm: number[] = [] const playedBgm: number[] = []
const playedSfx: string[] = [] const playedSfx: string[] = []

View File

@ -132,8 +132,8 @@ import {
D2_BASE_RUN_SUBTILES_PER_SEC, D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC, D2_BASE_WALK_SUBTILES_PER_SEC,
LocalMovementPredictor, LocalMovementPredictor,
deltaToDir16,
} from '../../src/client/motion/predictor.ts' } from '../../src/client/motion/predictor.ts'
import { dir64FromPoints } from '../../src/common/units/direction.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts' import { OnlineSession } from '../../src/client/session/online-session.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { import {
@ -1585,15 +1585,11 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
expect(worldRes.path.length).toBeGreaterThanOrEqual(3) expect(worldRes.path.length).toBeGreaterThanOrEqual(3)
}) })
it('adv_18_predictor_rubber_band_vs_hard_snap_boundary_and_frw_diminishing_returns', () => { it('adv_18_predictor_server_position_gate_boundaries_and_frw_diminishing_returns', () => {
const predictor = new LocalMovementPredictor({ const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
initialX: 5000,
initialY: 5000,
rubberBandThreshold: 5.0,
rubberBandCorrectionRate: 12.0,
})
// 1. Start a multi-waypoint path // 1. Start a multi-waypoint path; the leading point in the player's own cell is dropped and
// the player faces world +X (screen south-east, dir64 56).
predictor.setPath( predictor.setPath(
[ [
{ x: 5000, y: 5000 }, { x: 5000, y: 5000 },
@ -1604,41 +1600,51 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
) )
expect(predictor.getSnapshot().moving).toBe(true) expect(predictor.getSnapshot().moving).toBe(true)
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2) expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
expect(predictor.getSnapshot().dir64).toBe(56)
// 2. Server WalkVerify at drift = 4.99 sub-tiles (<= 5.0 threshold) -> rubber-band mode, path preserved! const lifeMana = (x: number, y: number, vx = 0, vy = 0, rttMs = 0) =>
const rBand = predictor.reconcileServerPosition({ x: 5004.99, y: 5000, teleport: false }) predictor.verifyServerPosition({ x, y, source: { kind: 'life-mana', vx, vy }, rttMs, inTown: false })
expect(rBand.mode).toBe('rubber-band')
// 2. Running (RN base 7), RTT 0: 7 sub-tiles is within, 8 asks for a 0x5F resync, and the client
// neither moves nor drops its path.
expect(lifeMana(5007, 5000)).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 7 })
expect(lifeMana(5008, 5000)).toMatchObject({
action: 'request-resync',
reason: 'exceeded',
clientX: 5000,
clientY: 5000,
})
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 5000, logicalY: 5000, moving: true })
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2) expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
const snapBeforeDecay = predictor.getSnapshot()
expect(Math.abs(snapBeforeDecay.errorOffsetX)).toBeGreaterThan(4.0)
// Step 500ms -> rubber-band offset decays exponentially toward 0 // 3. RTT term `(rtt + 50) >>> 7`: 77 ms adds nothing, 78 ms adds 1.
predictor.update(500) expect(lifeMana(5008, 5000, 0, 0, 77).action).toBe('request-resync')
const snapAfterDecay = predictor.getSnapshot() expect(lifeMana(5008, 5000, 0, 0, 78)).toMatchObject({ action: 'none', threshold: 8 })
expect(Math.abs(snapAfterDecay.errorOffsetX)).toBeLessThan(0.1)
// 3. Server ReassignPlayer at drift = 5.01 sub-tiles (> 5.0 threshold) -> hard-snap mode, clears path! // 4. Client ahead along the server unit's motion: exempt while d²(client, server) < 100.
const curLogical = predictor.getSnapshot() expect(lifeMana(4992, 5000, 4, 0).reason).toBe('leading-server')
const rSnap = predictor.reconcileServerPosition({ expect(lifeMana(4990, 5000, 4, 0).reason).toBe('exceeded')
x: curLogical.logicalX + 5.01,
y: curLogical.logicalY,
teleport: false,
})
expect(rSnap.mode).toBe('hard-snap')
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
expect(predictor.getSnapshot().errorOffsetX).toBe(0)
// 4. Teleport flag (`teleport: true`) forces hard-snap even when drift is only 1.0 sub-tile // 5. X or Y of 0 carries no position.
predictor.setPath([{ x: 5050, y: 5000 }], true) expect(lifeMana(0, 5000).reason).toBe('no-position')
const rTele = predictor.reconcileServerPosition({
x: predictor.getSnapshot().logicalX + 1.0, // 6. `0x0D` for the local player passes gate flag 1: tolerance 10 whatever the mode and RTT.
y: predictor.getSnapshot().logicalY, const stop = (x: number) =>
teleport: true, predictor.verifyServerPosition({ x, y: 5000, source: { kind: 'player-stop' }, rttMs: 500, inTown: false })
}) expect(stop(5010)).toMatchObject({ action: 'none', threshold: 10 })
expect(rTele.mode).toBe('hard-snap') expect(stop(5011).action).toBe('request-resync')
// 7. Malformed input fails fast.
expect(() => lifeMana(65536, 5000)).toThrow(RangeError)
expect(() => lifeMana(5000, 5000, 0.5, 0)).toThrow(RangeError)
expect(() => lifeMana(5000, 5000, 0, 0, -1)).toThrow(RangeError)
// 8. `0x15 ReassignPlayer` repositions directly, clears the path and keeps the facing.
predictor.setPosition(5005, 5000)
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 5005, logicalY: 5000, moving: false, dir64: 56 })
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0) expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
// 5. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw)) // 9. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw))
// At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec // At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec
predictor.setRunning(true) predictor.setRunning(true)
predictor.setFasterRunWalkPercent(150) predictor.setFasterRunWalkPercent(150)
@ -1650,8 +1656,8 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
predictor.setFasterRunWalkPercent(-200) predictor.setFasterRunWalkPercent(-200)
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(1.5) expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(1.5)
// 6. 16-direction facing helper handles zero vector fallback cleanly // 10. The dir64 routine keeps the current facing for a zero vector
expect(deltaToDir16(0, 0, 7)).toBe(7) expect(dir64FromPoints(5000, 5000, 5000, 5000, 7)).toBe(7)
}) })
it('adv_19_remote_entity_interpolator_knockback_stop_and_remove', () => { it('adv_19_remote_entity_interpolator_knockback_stop_and_remove', () => {

View File

@ -44,6 +44,7 @@ import { OutboundRateLimiter } from '../src/client/session/rate-limiter.ts'
import { ClientWorld } from '../src/client/world/client-world.ts' import { ClientWorld } from '../src/client/world/client-world.ts'
import { getCanonicalItemDataTables } from '../src/client/world/item-tables-provider.ts' import { getCanonicalItemDataTables } from '../src/client/world/item-tables-provider.ts'
import { COLLIDE_MASK_INVALID } from '../src/common/world/d2map.ts' import { COLLIDE_MASK_INVALID } from '../src/common/world/d2map.ts'
import { isTownLevel } from '../src/common/world/scene-population.ts'
import { import {
InMemoryPacketTap, InMemoryPacketTap,
createD2OnlineFlow, createD2OnlineFlow,
@ -380,61 +381,70 @@ function createSyntheticBotD2sBuffer(charName: string): Uint8Array {
return buf return buf
} }
/**
* Walk-bot pass criterion, not a game rule: a non-teleport `0x15` that moves the bot further
* than this (sub-tiles) means its walk and the server disagreed.
*/
const BOT_MAX_REASSIGN_DRIFT = 5
async function runWalkSubcommand( async function runWalkSubcommand(
world: ClientWorld, world: ClientWorld,
adapter: GameServerAdapter, adapter: GameServerAdapter,
mapService: MapService, mapService: MapService,
): Promise<void> { ): Promise<void> {
const rateLimiter = new OutboundRateLimiter({ refillPerSec: 25, burstCapacity: 8 }) const rateLimiter = new OutboundRateLimiter({ refillPerSec: 25, burstCapacity: 8 })
const areaId = world.areaId || 1
const view =
mapService.getActiveLevelView() ?? mapService.getLevelView(areaId, 'all', true)
const inTown = isTownLevel(view.id)
console.log(
`[d2-bot:walk] Loaded LevelView area=${view.id} act=${view.act} seed=0x${view.seed.toString(16).padStart(8, '0')} origin=(${view.subOriginX},${view.subOriginY}) subSize=${view.subWidth}x${view.subHeight} walkableCells=${view.walkableCellCount} rooms=${view.rooms.length}`,
)
const predictor = new LocalMovementPredictor({ const predictor = new LocalMovementPredictor({
initialX: world.self.x, initialX: world.self.x,
initialY: world.self.y, initialY: world.self.y,
rubberBandThreshold: 5.0,
}) })
let hardSnaps = 0 let hardSnaps = 0
let rubberBands = 0 let resyncRequests = 0
let serverCorrections = 0 let serverCorrections = 0
adapter.onEvent((ev) => { adapter.onEvent((ev) => {
if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) { if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) {
serverCorrections++ serverCorrections++
const res = predictor.reconcileServerPosition({ const before = predictor.getSnapshot()
x: ev.at.x, const drift = Math.hypot(ev.at.x - before.logicalX, ev.at.y - before.logicalY)
y: ev.at.y, // 1.13c `0x15` repositions the player directly (D2Client 0x6FB5F640 → 0x6FB57E20).
teleport: ev.snap, predictor.setPosition(ev.at.x, ev.at.y)
}) if (!ev.snap && drift > BOT_MAX_REASSIGN_DRIFT) {
if (!ev.snap && res.mode === 'hard-snap') {
hardSnaps++ hardSnaps++
console.warn( console.warn(
`[d2-bot:walk] Unexpected UnitReassign hard-snap: drift=${res.driftDistance.toFixed(2)} to (${ev.at.x},${ev.at.y})`, `[d2-bot:walk] Unexpected UnitReassign hard-snap: drift=${drift.toFixed(2)} to (${ev.at.x},${ev.at.y})`,
) )
} else if (res.mode === 'rubber-band') {
rubberBands++
} }
} else if (ev.type === 'LifeMana' && ev.at && ev.at.x > 0 && ev.at.y > 0) { } else if (ev.type === 'LifeMana' && ev.at && ev.at.x > 0 && ev.at.y > 0) {
serverCorrections++ serverCorrections++
const res = predictor.reconcileServerPosition({ if (ev.vx === undefined || ev.vy === undefined) {
throw new Error('[d2-bot:walk] LifeMana has a position but no vx/vy motion vector')
}
// D2Client 0x6FADA1B0; the RTT is 0 until the first 0x8F pong, like D2Client's
// zero-initialised [0x6FBC9804].
const verdict = predictor.verifyServerPosition({
x: ev.at.x, x: ev.at.x,
y: ev.at.y, y: ev.at.y,
teleport: false, source: { kind: 'life-mana', vx: ev.vx, vy: ev.vy },
rttMs: adapter.stats.rttMs ?? 0,
inTown,
}) })
if (res.mode === 'hard-snap') { if (verdict.action === 'request-resync') {
predictor.setPosition(ev.at.x, ev.at.y) resyncRequests++
} else if (res.mode === 'rubber-band') { adapter.send({ type: 'UpdatePlayerPos', x: verdict.clientX, y: verdict.clientY })
rubberBands++
} }
} }
}) })
const areaId = world.areaId || 1
const view =
mapService.getActiveLevelView() ?? mapService.getLevelView(areaId, 'all', true)
console.log(
`[d2-bot:walk] Loaded LevelView area=${view.id} act=${view.act} seed=0x${view.seed.toString(16).padStart(8, '0')} origin=(${view.subOriginX},${view.subOriginY}) subSize=${view.subWidth}x${view.subHeight} walkableCells=${view.walkableCellCount} rooms=${view.rooms.length}`,
)
interface LandmarkTarget { interface LandmarkTarget {
readonly label: string readonly label: string
readonly x: number readonly x: number
@ -560,12 +570,12 @@ async function runWalkSubcommand(
landmarksVisited++ landmarksVisited++
const snap = predictor.getSnapshot() const snap = predictor.getSnapshot()
console.log( console.log(
`[d2-bot:walk] Reached landmark ${landmarksVisited}/${selectedLandmarks.length} (${landmark.label}): predictor=(${snap.x.toFixed(1)},${snap.y.toFixed(1)}) worldSelf=(${world.self.x},${world.self.y}) hardSnaps=${hardSnaps} rubberBands=${rubberBands}`, `[d2-bot:walk] Reached landmark ${landmarksVisited}/${selectedLandmarks.length} (${landmark.label}): predictor=(${snap.x.toFixed(1)},${snap.y.toFixed(1)}) worldSelf=(${world.self.x},${world.self.y}) hardSnaps=${hardSnaps} resyncRequests=${resyncRequests}`,
) )
} }
console.log( console.log(
`[d2-bot:walk:summary] landmarksVisited=${landmarksVisited} totalHops=${totalHops} subTilesWalked=${totalSubTilesWalked.toFixed(1)} serverCorrections=${serverCorrections} rubberBands=${rubberBands} hardSnaps=${hardSnaps}`, `[d2-bot:walk:summary] landmarksVisited=${landmarksVisited} totalHops=${totalHops} subTilesWalked=${totalSubTilesWalked.toFixed(1)} serverCorrections=${serverCorrections} resyncRequests=${resyncRequests} hardSnaps=${hardSnaps}`,
) )
if (landmarksVisited < 3) { if (landmarksVisited < 3) {