diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 32be95d..614d085 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -8,16 +8,21 @@ * - `0x67 NPCMove` (`{ unitId, moveType, x, y, targetX, targetY, ... }`) * - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`) * - `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 { D2_BASE_RUN_SUBTILES_PER_SEC, D2_BASE_WALK_SUBTILES_PER_SEC, D2_TICK_MS, - DEFAULT_RUBBER_BAND_THRESHOLD, - deltaToDir16, } 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 { readonly unitType: number readonly unitId: number @@ -28,7 +33,8 @@ export interface RemoteEntityMotionState { readonly speedSubTilesPerSec: number readonly moving: boolean readonly running: boolean - readonly dir16: number + /** Facing in the engine's 64-direction space. */ + readonly dir64: number } export interface RemoteMoveCommand { @@ -61,7 +67,7 @@ interface MutableRemoteEntityMotion { speedSubTilesPerSec: number moving: boolean running: boolean - dir16: number + dir64: number } function entityKey(unitType: number, unitId: number): string { @@ -98,20 +104,23 @@ export class RemoteEntityInterpolator { private 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: { readonly unitType: number readonly unitId: number readonly x: number readonly y: number - readonly dir16?: number + readonly dir64?: number }): RemoteEntityMotionState { + if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64') const key = entityKey(params.unitType, params.unitId) + const previous = this.entities.get(key) const state: MutableRemoteEntityMotion = { unitType: params.unitType, unitId: params.unitId >>> 0, @@ -122,7 +131,7 @@ export class RemoteEntityInterpolator { speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC, moving: false, running: false, - dir16: (params.dir16 ?? 0) & 15, + dir64: params.dir64 ?? previous?.dir64 ?? 0, } this.entities.set(key, state) return { ...state } @@ -150,7 +159,7 @@ export class RemoteEntityInterpolator { speedSubTilesPerSec, moving: false, running, - dir16: 0, + dir64: 0, } this.entities.set(key, state) } else if (cmd.currentX !== undefined && cmd.currentY !== undefined) { @@ -171,7 +180,7 @@ export class RemoteEntityInterpolator { const dist = Math.hypot(dx, dy) if (dist > 1e-4) { 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 (cmd.currentX !== undefined && cmd.currentY !== undefined) { const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY) @@ -216,11 +225,12 @@ export class RemoteEntityInterpolator { state.targetY = cmd.y state.moving = false } 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.targetY = cmd.y 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 } @@ -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 { if (dtMs <= 0) return @@ -255,7 +265,6 @@ export class RemoteEntityInterpolator { const ratio = step / dist state.x += dx * ratio state.y += dy * ratio - state.dir16 = deltaToDir16(dx, dy, state.dir16) } } } diff --git a/src/client/motion/predictor.ts b/src/client/motion/predictor.ts index 9152176..8f7c2d5 100644 --- a/src/client/motion/predictor.ts +++ b/src/client/motion/predictor.ts @@ -1,22 +1,24 @@ /** - * Local Player Movement Predictor & Server Reconciliation - * (`src/client/motion/predictor.ts`, Feature F12). + * Local Player Movement Predictor (`src/client/motion/predictor.ts`, Feature F12). * * 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 - * server packets: - * - `0x15 ReassignPlayer` (`{ unitType, unitId, x, y, flags }`) - * - `0x95 PlayerLifeManaChange` (`{ x, y, ... }`) - * - `0x96 WalkVerify` (`{ stamina, x, y, dX, dY }`) + * or arbitrary `dtMs` frame deltas, and applies the D2Client 1.13c rules for authoritative + * server positions: + * - `0x15 ReassignPlayer` repositions the player and drops its path + * ({@link LocalMovementPredictor.setPosition}; D2Client 0x6FB5F640 → 0x6FB57E20, no + * 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: - * - When Euclidean drift `<= rubberBandThreshold` (default: `5` sub-tiles) and - * `teleport !== true`, applies a smooth exponential rubber-band correction offset - * 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. + * The facing is a dir64 (`common/units/direction.ts`), set only when a segment starts — on + * {@link LocalMovementPredictor.setPath} and on each waypoint advance — like the engine's + * D2Common 0x6FD5D240, never per frame. */ +import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts' import { findPathWorld, type PathfindOptions, @@ -27,7 +29,19 @@ import { 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_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 { readonly initialX?: number @@ -35,71 +49,99 @@ export interface PredictorConfig { readonly walkSpeedSubTilesPerSec?: number readonly runSpeedSubTilesPerSec?: 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 + /** Server sub-tile Y (u16). */ readonly y: number - readonly teleport?: boolean - readonly clearPathOnHardSnap?: boolean + readonly source: ServerPositionSource + /** 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 { - readonly driftDistance: number - readonly mode: 'none' | 'rubber-band' | 'hard-snap' - readonly x: number - readonly y: number +export type ServerPositionVerdictReason = + /** X or Y is 0: the gate returns before comparing anything. */ + | 'no-position' + /** Both axes within the tolerance. */ + | '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 { - /** Authoritative + predicted logical sub-tile X. */ + /** Predicted logical sub-tile X. */ readonly logicalX: number - /** Authoritative + predicted logical sub-tile Y. */ + /** Predicted logical sub-tile Y. */ 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 - /** Visual sub-tile Y (includes decaying rubber-band offset). */ + /** Drawn sub-tile Y; always `logicalY`. */ 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. */ readonly moving: boolean /** True if running (false if walking). */ readonly running: boolean - /** 16-direction isometric facing index (0..15). */ - readonly dir16: number + /** Facing in the engine's 64-direction space (`common/units/direction.ts`). */ + readonly dir64: number /** Current active target waypoint, or null if idle. */ readonly currentWaypoint: SubTilePoint | null /** Remaining waypoints including current target. */ readonly remainingWaypoints: readonly SubTilePoint[] } -/** - * Convert a sub-tile delta `(dx, dy)` into a 16-direction D2 facing index (`0..15`). - */ -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) - return Math.round(normalized * 16) & 15 +function assertSubTile(value: number, what: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + throw new RangeError(`${what} must be a u16 sub-tile coordinate, got ${String(value)}`) + } +} + +/** D2Common #10769 (0x6FD84580): squared distance, no square root. */ +function distanceSquared(x1: number, y1: number, x2: number, y2: number): number { + return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1) } export class LocalMovementPredictor { private logicalX: number private logicalY: number - private errorOffsetX = 0 - private errorOffsetY = 0 private walkSpeed: number private runSpeed: number private fasterRunWalkPercent: number - private readonly rubberBandThreshold: number - private readonly rubberBandCorrectionRate: number private running = true - private dir16 = 0 + private dir64 = 0 private waypoints: SubTilePoint[] = [] private waypointIndex = 0 @@ -109,8 +151,6 @@ export class LocalMovementPredictor { this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC 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 } + /** + * 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 { this.logicalX = x this.logicalY = y - this.errorOffsetX = 0 - this.errorOffsetY = 0 this.waypoints = [] this.waypointIndex = 0 } /** - * Assign a pre-computed waypoint list (first element may be the current position or - * the first target waypoint). + * Set the facing (dir64), e.g. when the server assigns one. + */ + 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 { this.running = running - if (path.length === 0) { - this.waypoints = [] - this.waypointIndex = 0 - return + const cellX = Math.round(this.logicalX) + const cellY = Math.round(this.logicalY) + let start = 0 + 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 - 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.waypoints = path.slice(start).map(p => ({ x: p.x, y: p.y })) this.waypointIndex = 0 - if (this.waypoints.length > 0) { - const target = this.waypoints[0]! - this.dir16 = deltaToDir16(target.x - this.logicalX, target.y - this.logicalY, this.dir16) - } + this.faceCurrentWaypoint() } /** * Compute an A* + LOS string-pulled path on `grid` (in world sub-tiles) and start * 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( grid: SubTileWalkabilityGrid, @@ -178,7 +236,14 @@ export class LocalMovementPredictor { if (options.running !== undefined) { 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) 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 { if (dtMs <= 0) return this.getSnapshot() - const dtSec = dtMs / 1000 - // Decay rubber-band error offset smoothly toward 0 - 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 + let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * (dtMs / 1000) while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) { const target = this.waypoints[this.waypointIndex]! const dx = target.x - this.logicalX const dy = target.y - this.logicalY 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) { this.logicalX = target.x this.logicalY = target.y remainingDist -= segDist this.waypointIndex++ + this.faceCurrentWaypoint() } else { const ratio = remainingDist / segDist this.logicalX += dx * ratio @@ -255,62 +303,68 @@ export class LocalMovementPredictor { } /** - * Reconcile predicted player state against an authoritative server packet - * (`0x15 ReassignPlayer`, `0x95 PlayerLifeManaChange`, `0x96 WalkVerify`). + * D2Client 1.13c position tolerance gate 0x6FADA1B0, for the local player. * - * - If `update.teleport === true` or `drift > rubberBandThreshold` (`5` sub-tiles), - * performs a hard snap to `(update.x, update.y)`. - * - If `0 < drift <= rubberBandThreshold`, shifts `logicalX/Y` to `(update.x, update.y)` - * while preserving visual continuity via `errorOffsetX/Y`, which smoothly decays to 0. + * 1. X or Y of 0 → no check. + * 2. Client position = the player's integer sub-tile (D2Client 0x6FAB1630 / 0x6FAB1660; + * logical N.0 is the centre of sub-tile N, so `Math.round`). + * 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 { - const visualX = this.logicalX + this.errorOffsetX - const visualY = this.logicalY + this.errorOffsetY - const dx = update.x - this.logicalX - const dy = update.y - this.logicalY - const driftDistance = Math.hypot(dx, dy) - const isTeleport = teleport ?? update.teleport === true + verifyServerPosition(check: ServerPositionCheck): ServerPositionVerdict { + assertSubTile(check.x, 'server x') + assertSubTile(check.y, 'server y') + if (!Number.isFinite(check.rttMs) || check.rttMs < 0) { + throw new RangeError(`rttMs must be a finite non-negative number, got ${String(check.rttMs)}`) + } + 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) { - this.logicalX = update.x - this.logicalY = update.y - this.errorOffsetX = 0 - this.errorOffsetY = 0 - if (update.clearPathOnHardSnap !== false) { - this.waypoints = [] - this.waypointIndex = 0 - } - return { - driftDistance, - mode: 'hard-snap', - x: this.logicalX, - y: this.logicalY, + const clientX = Math.round(this.logicalX) + const clientY = Math.round(this.logicalY) + if (check.x === 0 || check.y === 0) { + return { action: 'none', reason: 'no-position', clientX, clientY, threshold: 0 } + } + + const threshold = + source.kind === 'player-stop' + ? GATE_FLAG1_THRESHOLD + : this.gateModeBase(check.inTown) + ((Math.trunc(check.rttMs) + 50) >>> 7) + const exceeded = + Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold + if (!exceeded) { + 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 { - 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, - } + return { action: 'request-resync', reason: 'exceeded', clientX, clientY, threshold } } getSnapshot(): PredictorSnapshot { @@ -319,15 +373,32 @@ export class LocalMovementPredictor { return { logicalX: this.logicalX, logicalY: this.logicalY, - x: this.logicalX + this.errorOffsetX, - y: this.logicalY + this.errorOffsetY, - errorOffsetX: this.errorOffsetX, - errorOffsetY: this.errorOffsetY, + x: this.logicalX, + y: this.logicalY, moving: currentWaypoint !== null, running: this.running, - dir16: this.dir16, + dir64: this.dir64, currentWaypoint, 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 + } } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 42c6329..e7680d2 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -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 { npcDisplayName } from '../../common/units/npc-table.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' @@ -346,7 +346,6 @@ export interface SceneSourceRenderContext { readonly hudManager?: HudManager | null | undefined readonly nowMs?: number | undefined readonly playerMoving?: boolean | undefined - readonly playerFacingRadians?: number | undefined readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined } @@ -420,10 +419,7 @@ function drawSceneUnitDrawable( charArt.clips?.[requestedClipKey] ?? (isMoving ? charArt.clips?.wl : charArt.clips?.nu) const dirCount = Math.max(1, clip?.directions ?? charArt.directions) - const direction = - ctx.playerFacingRadians !== undefined - ? facingToDirection(ctx.playerFacingRadians, dirCount) - : ((unit.direction % dirCount) + dirCount) % dirCount + const direction = dir64ToClipDirection(unit.direction, dirCount) const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand const groupIndex = baseGroup + direction const group = @@ -490,7 +486,7 @@ function drawSceneUnitDrawable( (isMoving ? art.clips?.wl : undefined) ?? art.clips?.nu const dirCount = Math.max(1, clip?.directions ?? art.directions) - const direction = ((unit.direction % dirCount) + dirCount) % dirCount + const direction = dir64ToClipDirection(unit.direction, dirCount) const baseGroup = clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset const groupIndex = baseGroup + direction diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index e28e26d..6b702ca 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -58,7 +58,7 @@ import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-in import type { LevelView } from '../map/level-view.ts' import type { MapService } from '../map/map-service.ts' import { RemoteEntityInterpolator } from '../motion/interpolate.ts' -import { LocalMovementPredictor } from '../motion/predictor.ts' +import { LocalMovementPredictor, type ServerPositionSource } from '../motion/predictor.ts' import { getGlobalSettingsStore, type SettingsStore, @@ -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 { isTownLevel } from '../../common/world/scene-population.ts' export type OnlineSessionPhase = | 'idle' @@ -883,6 +884,59 @@ export class OnlineSession { 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 ───────────────────────── /** @@ -1080,7 +1134,7 @@ export class OnlineSession { unitId: u.id, x: u.x, y: u.y, - ...(u.direction !== undefined ? { dir16: u.direction } : {}), + ...(u.direction !== undefined ? { dir64: u.direction } : {}), }) } break @@ -1091,20 +1145,10 @@ export class OnlineSession { const isLocalPlayer = evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0) if (isLocalPlayer) { - const prevSnap = this.predictor.getSnapshot() - if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) { - this.predictor.setPosition(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) - } - } + // 1.13c D2Client 0x6FB5F640 → 0x6FB57E20: `0x15` repositions the player directly, with + // no tolerance check whatever its flag byte, and drops the path. + this.predictor.setPosition(ev.at.x, ev.at.y) + this.camera.snapTo(ev.at.x, ev.at.y) } else { this.interpolator.spawnOrSnap({ unitType: evTypeId, @@ -1123,10 +1167,15 @@ export class OnlineSession { this.predictor.setPosition(ev.at.x, ev.at.y) this.camera.snapTo(ev.at.x, ev.at.y) } else { - this.predictor.reconcileServerPosition({ - x: ev.at.x, - y: ev.at.y, - teleport: false, + if (ev.vx === undefined || ev.vy === undefined) { + throw new Error( + 'LifeMana has a position but no vx/vy motion vector (0x18/0x95/0x96 always carry both)', + ) + } + 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 if (isLocalPlayer) { if (ev.mode === 'stop') { - this.predictor.reconcileServerPosition({ - x: toX, - y: toY, - teleport: false, - }) + this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) } } else if (ev.mode === 'stop') { this.interpolator.stopEntity({ @@ -1257,7 +1302,7 @@ export class OnlineSession { if (unit) { unit.x = state.x unit.y = state.y - unit.direction = state.dir16 + unit.direction = state.dir64 } } } @@ -1289,7 +1334,7 @@ export class OnlineSession { ? selfUnit.mode : 1 const selfDirection = - selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir16 + selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64 out.push({ id: this.world.selfId, kind: 'player', @@ -1383,7 +1428,7 @@ export class OnlineSession { x: ux, y: uy, mode: u.mode, - direction: interp ? interp.dir16 : u.direction, + direction: interp ? interp.dir64 : u.direction, radiusX: 28, radiusY: 20, }) @@ -1422,7 +1467,7 @@ export class OnlineSession { x: ux, y: uy, mode, - direction: interp ? interp.dir16 : u.direction, + direction: interp ? interp.dir64 : u.direction, hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), isCorpse: false, states: Array.from(u.states.list()), @@ -1464,7 +1509,7 @@ export class OnlineSession { x: ux, y: uy, mode, - direction: interp ? interp.dir16 : u.direction, + direction: interp ? interp.dir64 : u.direction, hpRatio: 1, isCorpse: false, ...(goldAmount !== undefined ? { goldAmount } : {}), @@ -1490,7 +1535,7 @@ export class OnlineSession { ...(targetX !== undefined ? { targetX } : {}), ...(targetY !== undefined ? { targetY } : {}), mode, - direction: interp ? interp.dir16 : u.direction, + direction: interp ? interp.dir64 : u.direction, hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), isCorpse, isChampion: u.isChampion, diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index f56b21d..0650ee8 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -1,5 +1,6 @@ import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.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 { PointLightInput } from '../scene/lighting-presets.ts' import type { UnitCastState } from '../world/client-unit.ts' @@ -35,6 +36,11 @@ export interface RenderableUnitInput { readonly targetX?: number | undefined readonly targetY?: 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 frame?: number | undefined readonly lifePct?: number | undefined @@ -79,6 +85,7 @@ export interface SceneUnitDrawable { readonly itemQuality?: number | undefined readonly goldAmount?: number | undefined readonly mode: number + /** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */ readonly direction: number readonly frame: number readonly subX: number @@ -262,6 +269,23 @@ function computeDirection8Or16( 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 { readonly unitDrawables: readonly SceneUnitDrawable[] readonly missileDrawables: readonly SceneMissileDrawable[] @@ -484,14 +508,7 @@ export function buildSceneDrawables( }) } - const dir = computeDirection8Or16( - unit.x, - unit.y, - effectiveTargetX, - effectiveTargetY, - unit.direction ?? 0, - 8, - ) + const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY) const isWarp = unit.kind === 'warp' || unit.kind === 'tile' const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId diff --git a/tests/client/drlg-map-motion.test.ts b/tests/client/drlg-map-motion.test.ts index c8860db..0178668 100644 --- a/tests/client/drlg-map-motion.test.ts +++ b/tests/client/drlg-map-motion.test.ts @@ -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({ initialX: 100, initialY: 200, @@ -425,29 +425,36 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion & expect(after1s.logicalY).toBeCloseTo(200, 4) expect(after1s.moving).toBe(true) - // Reconcile small drift (2.0 sub-tiles <= 5 threshold): rubber-band - const rb = predictor.reconcileServerPosition({ x: 111.5, y: 200 }) - expect(rb.mode).toBe('rubber-band') - expect(rb.driftDistance).toBeCloseTo(2.0, 4) - // Visual position remains 113.5 immediately after reconcile (no pop), logical is 111.5 - const snapAfterRb = predictor.getSnapshot() - expect(snapAfterRb.logicalX).toBeCloseTo(111.5, 4) - expect(snapAfterRb.x).toBeCloseTo(113.5, 4) - expect(snapAfterRb.errorOffsetX).toBeCloseTo(2.0, 4) + // Server 2 sub-tiles behind a running player (RN base 7, RTT 0): within tolerance, no move. + const within = predictor.verifyServerPosition({ + x: 112, + y: 200, + source: { kind: 'life-mana', vx: 0, vy: 0 }, + rttMs: 0, + inTown: false, + }) + expect(within).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 7 }) + expect(predictor.getSnapshot().logicalX).toBeCloseTo(113.5, 4) - // Stepping ticks decays errorOffsetX toward 0 - const decayed = predictor.stepTicks(15) - expect(Math.abs(decayed.errorOffsetX)).toBeLessThan(0.05) + // 10 sub-tiles behind: the client asks for a resync with its own sub-tile and keeps walking. + const far = predictor.verifyServerPosition({ + 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 - const currentLogicalX = predictor.getSnapshot().logicalX - const hs = predictor.reconcileServerPosition({ x: currentLogicalX + 8.0, y: 210 }) - expect(hs.mode).toBe('hard-snap') - const snapAfterHs = predictor.getSnapshot() - expect(snapAfterHs.logicalX).toBeCloseTo(currentLogicalX + 8.0, 4) - expect(snapAfterHs.x).toBeCloseTo(currentLogicalX + 8.0, 4) - expect(snapAfterHs.errorOffsetX).toBe(0) - expect(snapAfterHs.moving).toBe(false) + // 0x15 ReassignPlayer repositions directly and drops the path. + predictor.setPosition(104, 200) + const afterReassign = predictor.getSnapshot() + expect(afterReassign.logicalX).toBe(104) + expect(afterReassign.x).toBe(104) + expect(afterReassign.moving).toBe(false) // Walk speed verification predictor.setRunning(false) diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 465b597..5e51ae5 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -717,6 +717,8 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { mana: 100, stamina: 100, at: { x: 5100, y: 5100 }, + vx: 0, + vy: 0, }) session.handleServerEvent({ type: 'Merc', @@ -924,15 +926,20 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { life: 320, mana: 410, 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.hudManager.hp).toBe(320) expect(session.hudManager.mana).toBe(410) - expect(session.predictor.getSnapshot().logicalX).toBe(502) - expect(session.predictor.getSnapshot().logicalY).toBe(501) + // 1.13c never moves the local player toward a 0x95 position (D2Client gate 0x6FADA1B0); a + // 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 session.tick(40, 1000) diff --git a/tests/client/local-position-gate-session.test.ts b/tests/client/local-position-gate-session.test.ts new file mode 100644 index 0000000..d72bab1 --- /dev/null +++ b/tests/client/local-position-gate-session.test.ts @@ -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) => + 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([]) + }) +}) diff --git a/tests/client/spawn-position-539.test.ts b/tests/client/spawn-position-539.test.ts index 2950a84..b4eadac 100644 --- a/tests/client/spawn-position-539.test.ts +++ b/tests/client/spawn-position-539.test.ts @@ -149,7 +149,8 @@ describe('Issue #539: player spawn is rendered at the server position inside the const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService }) session.loadD2CapReplay(text) 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) expect(session.disconnectReason).toBeNull() diff --git a/tests/client/unit-facing-motion.test.ts b/tests/client/unit-facing-motion.test.ts new file mode 100644 index 0000000..0c9f482 --- /dev/null +++ b/tests/client/unit-facing-motion.test.ts @@ -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 }) + }) +}) diff --git a/tests/e2e-netproto/tier1-feature-coverage.test.ts b/tests/e2e-netproto/tier1-feature-coverage.test.ts index fed8698..b07a9c2 100644 --- a/tests/e2e-netproto/tier1-feature-coverage.test.ts +++ b/tests/e2e-netproto/tier1-feature-coverage.test.ts @@ -1252,16 +1252,31 @@ describe('Tier 1 — F13: Client Movement Prediction, Server Reconciliation & Pa 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 res = predictor.reconcileServerPosition({ x: 103, y: 100 }) - expect(res.mode).toBe('rubber-band') + const res = predictor.verifyServerPosition({ + 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 res = predictor.reconcileServerPosition({ x: 200, y: 200 }, true) - expect(res.mode).toBe('hard-snap') + const res = predictor.verifyServerPosition({ + 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) }) diff --git a/tests/e2e-netproto/tier3-cross-feature.test.ts b/tests/e2e-netproto/tier3-cross-feature.test.ts index 919d49f..25872d0 100644 --- a/tests/e2e-netproto/tier3-cross-feature.test.ts +++ b/tests/e2e-netproto/tier3-cross-feature.test.ts @@ -692,9 +692,11 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => { w15Small.u16LE(5050) w15Small.u8(0) const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables }) + expect(evSmall.type).toBe('UnitReassign') if (evSmall.type === 'UnitReassign') { - const rec = predictor.reconcileServerPosition(evSmall.at, evSmall.snap) - expect(rec.mode).toBe('rubber-band') + // 1.13c `0x15` repositions the player directly, however small the drift (D2Client 0x6FB57E20). + predictor.setPosition(evSmall.at.x, evSmall.at.y) + expect(predictor.getSnapshot()).toMatchObject({ x: 5103, y: 5050, moving: false }) } const w0f = new ByteWriter() @@ -721,7 +723,10 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => { moveType: 0x17, }) 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) } }) diff --git a/tests/e2e-netproto/tier4-real-world-scenarios.test.ts b/tests/e2e-netproto/tier4-real-world-scenarios.test.ts index ff093e6..754bb59 100644 --- a/tests/e2e-netproto/tier4-real-world-scenarios.test.ts +++ b/tests/e2e-netproto/tier4-real-world-scenarios.test.ts @@ -244,10 +244,17 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', () } 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 rec = predictor.reconcileServerPosition({ x: world.self.x + 2, y: world.self.y }) - expect(rec.mode).toBe('rubber-band') + const verdict = predictor.verifyServerPosition({ + 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 playedSfx: string[] = [] diff --git a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts index 7bdc5d5..7c48424 100644 --- a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts +++ b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts @@ -132,8 +132,8 @@ import { D2_BASE_RUN_SUBTILES_PER_SEC, D2_BASE_WALK_SUBTILES_PER_SEC, LocalMovementPredictor, - deltaToDir16, } from '../../src/client/motion/predictor.ts' +import { dir64FromPoints } from '../../src/common/units/direction.ts' import { OnlineSession } from '../../src/client/session/online-session.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { @@ -1585,15 +1585,11 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path expect(worldRes.path.length).toBeGreaterThanOrEqual(3) }) - it('adv_18_predictor_rubber_band_vs_hard_snap_boundary_and_frw_diminishing_returns', () => { - const predictor = new LocalMovementPredictor({ - initialX: 5000, - initialY: 5000, - rubberBandThreshold: 5.0, - rubberBandCorrectionRate: 12.0, - }) + it('adv_18_predictor_server_position_gate_boundaries_and_frw_diminishing_returns', () => { + const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 }) - // 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( [ { 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().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 rBand = predictor.reconcileServerPosition({ x: 5004.99, y: 5000, teleport: false }) - expect(rBand.mode).toBe('rubber-band') + const lifeMana = (x: number, y: number, vx = 0, vy = 0, rttMs = 0) => + predictor.verifyServerPosition({ x, y, source: { kind: 'life-mana', vx, vy }, rttMs, inTown: false }) + + // 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) - const snapBeforeDecay = predictor.getSnapshot() - expect(Math.abs(snapBeforeDecay.errorOffsetX)).toBeGreaterThan(4.0) - // Step 500ms -> rubber-band offset decays exponentially toward 0 - predictor.update(500) - const snapAfterDecay = predictor.getSnapshot() - expect(Math.abs(snapAfterDecay.errorOffsetX)).toBeLessThan(0.1) + // 3. RTT term `(rtt + 50) >>> 7`: 77 ms adds nothing, 78 ms adds 1. + expect(lifeMana(5008, 5000, 0, 0, 77).action).toBe('request-resync') + expect(lifeMana(5008, 5000, 0, 0, 78)).toMatchObject({ action: 'none', threshold: 8 }) - // 3. Server ReassignPlayer at drift = 5.01 sub-tiles (> 5.0 threshold) -> hard-snap mode, clears path! - const curLogical = predictor.getSnapshot() - const rSnap = predictor.reconcileServerPosition({ - 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. Client ahead along the server unit's motion: exempt while d²(client, server) < 100. + expect(lifeMana(4992, 5000, 4, 0).reason).toBe('leading-server') + expect(lifeMana(4990, 5000, 4, 0).reason).toBe('exceeded') - // 4. Teleport flag (`teleport: true`) forces hard-snap even when drift is only 1.0 sub-tile - predictor.setPath([{ x: 5050, y: 5000 }], true) - const rTele = predictor.reconcileServerPosition({ - x: predictor.getSnapshot().logicalX + 1.0, - y: predictor.getSnapshot().logicalY, - teleport: true, - }) - expect(rTele.mode).toBe('hard-snap') + // 5. X or Y of 0 carries no position. + expect(lifeMana(0, 5000).reason).toBe('no-position') + + // 6. `0x0D` for the local player passes gate flag 1: tolerance 10 whatever the mode and RTT. + const stop = (x: number) => + predictor.verifyServerPosition({ x, y: 5000, source: { kind: 'player-stop' }, rttMs: 500, inTown: false }) + expect(stop(5010)).toMatchObject({ action: 'none', threshold: 10 }) + 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) - // 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 predictor.setRunning(true) predictor.setFasterRunWalkPercent(150) @@ -1650,8 +1656,8 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path predictor.setFasterRunWalkPercent(-200) expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(1.5) - // 6. 16-direction facing helper handles zero vector fallback cleanly - expect(deltaToDir16(0, 0, 7)).toBe(7) + // 10. The dir64 routine keeps the current facing for a zero vector + expect(dir64FromPoints(5000, 5000, 5000, 5000, 7)).toBe(7) }) it('adv_19_remote_entity_interpolator_knockback_stop_and_remove', () => { diff --git a/tools/d2-bot.ts b/tools/d2-bot.ts index 513bda4..3a4433d 100644 --- a/tools/d2-bot.ts +++ b/tools/d2-bot.ts @@ -44,6 +44,7 @@ import { OutboundRateLimiter } from '../src/client/session/rate-limiter.ts' import { ClientWorld } from '../src/client/world/client-world.ts' import { getCanonicalItemDataTables } from '../src/client/world/item-tables-provider.ts' import { COLLIDE_MASK_INVALID } from '../src/common/world/d2map.ts' +import { isTownLevel } from '../src/common/world/scene-population.ts' import { InMemoryPacketTap, createD2OnlineFlow, @@ -380,61 +381,70 @@ function createSyntheticBotD2sBuffer(charName: string): Uint8Array { 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( world: ClientWorld, adapter: GameServerAdapter, mapService: MapService, ): Promise { 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({ initialX: world.self.x, initialY: world.self.y, - rubberBandThreshold: 5.0, }) let hardSnaps = 0 - let rubberBands = 0 + let resyncRequests = 0 let serverCorrections = 0 adapter.onEvent((ev) => { if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) { serverCorrections++ - const res = predictor.reconcileServerPosition({ - x: ev.at.x, - y: ev.at.y, - teleport: ev.snap, - }) - if (!ev.snap && res.mode === 'hard-snap') { + const before = predictor.getSnapshot() + const drift = Math.hypot(ev.at.x - before.logicalX, ev.at.y - before.logicalY) + // 1.13c `0x15` repositions the player directly (D2Client 0x6FB5F640 → 0x6FB57E20). + predictor.setPosition(ev.at.x, ev.at.y) + if (!ev.snap && drift > BOT_MAX_REASSIGN_DRIFT) { hardSnaps++ 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) { 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, 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') { - predictor.setPosition(ev.at.x, ev.at.y) - } else if (res.mode === 'rubber-band') { - rubberBands++ + if (verdict.action === 'request-resync') { + resyncRequests++ + adapter.send({ type: 'UpdatePlayerPos', x: verdict.clientX, y: verdict.clientY }) } } }) - 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 { readonly label: string readonly x: number @@ -560,12 +570,12 @@ async function runWalkSubcommand( landmarksVisited++ const snap = predictor.getSnapshot() 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( - `[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) {