From 186a5844045db44f2a9aa6c66b43c5cc7e19896c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 21:49:22 +0200 Subject: [PATCH] feat(anim): Phase L.1c port MoveTo path data + per-tick steer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-causing the user-reported "monsters disappearing some time + laggy/jittery locomotion" via systematic-debugging Phase 1: our UpdateMotion parser kept only speed/runRate/flags from a movementType 6/7 packet and discarded Origin (destination), targetGuid, and the distance/walkRunThreshold/desiredHeading half of MovementParameters. The integrator consequently held Body.Velocity at zero during MoveTo ("incomplete state" stabilizer 882a07c), so the body froze with legs animating until UpdatePosition snap-teleported it — sometimes outside the visible window (disappearing) — and constant-velocity drift along the old heading between snaps produced jitter on every UP correction. The 882a07c stabilizer was deliberately conservative because the state WAS incomplete. Completing the data plumbing makes its restriction moot: with the full MoveTo payload captured, the body solver has every field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads. Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204 all show different cell/xyz floats). Those are heading updates we'd been throwing away. With the full payload retained, the per-tick driver steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s turn rate above tolerance) and lets apply_current_movement fill in Velocity from the existing RunForward cycle — no new motion path, just the right heading. Scope is the minimum viable subset: target re-tracking, sticky/StickTo, fail-distance progress detector, and sphere-cylinder distance are server-side concerns we don't need (server's emit cadence handles all of them). MoveToObject_Internal target-guid resolution is also skipped — Origin is refreshed each packet, so the effective target tracks the real entity even without a guid lookup. Cross-references: - docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command (0x00527be0). 18,366 named PDB symbols make this the primary oracle. - references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs — port aid; flagged divergences (WalkRunThreshold default, set_heading snap, inRange one-shot) called out in the new pseudocode doc. - docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode + ACE divergence flags + out-of-scope list per CLAUDE.md mandatory workflow (decompile → cross-reference → pseudocode → port). Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid retention; driver arrival, in-tolerance snap, beyond-tolerance step, behind-target shortest-path turn, arrival preserves orientation, Origin→world landblock-grid arithmetic). Pending visual sign-off — handoff stabilizer 882a07c was the last commit the user tested. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-28-remote-moveto-pseudocode.md | 285 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 126 +++++++- src/AcDream.Core.Net/Messages/CreateObject.cs | 38 ++- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 60 +++- .../Physics/RemoteMoveToDriver.cs | 204 +++++++++++++ .../Messages/UpdateMotionTests.cs | 64 ++++ .../Physics/RemoteMoveToDriverTests.cs | 159 ++++++++++ 7 files changed, 917 insertions(+), 19 deletions(-) create mode 100644 docs/research/2026-04-28-remote-moveto-pseudocode.md create mode 100644 src/AcDream.Core/Physics/RemoteMoveToDriver.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs diff --git a/docs/research/2026-04-28-remote-moveto-pseudocode.md b/docs/research/2026-04-28-remote-moveto-pseudocode.md new file mode 100644 index 0000000..19ec7c9 --- /dev/null +++ b/docs/research/2026-04-28-remote-moveto-pseudocode.md @@ -0,0 +1,285 @@ +# Phase L.1c — Remote MoveTo body-driver pseudocode + +**Date**: 2026-04-28 +**Goal**: Port the minimum viable subset of retail `MoveToManager` so the body +position of server-controlled chasing creatures (movementType 6/7) tracks the +server-supplied destination smoothly, instead of freezing at zero velocity +between sparse `UpdatePosition` snaps. + +## Problem (root cause from systematic-debugging Phase 1) + +The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive` +is true, on the principle "do not let `apply_current_movement` free-run with +incomplete MoveTo state." The state IS incomplete: our parser at +[`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs) +keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters` +block and the `runRate` trailer, **discarding** `Origin (destination)`, +`targetGuid` (type 6 only), `distance_to_object`, `min_distance`, +`fail_distance`, `walk_run_threshhold`, and `desired_heading`. + +Symptoms (live log + user observation 2026-04-28): +- **Disappearing**: body frozen at `Velocity=0` while RunForward animation + plays; next UpdatePosition teleports body to actual server pose. If the + teleport target is outside the visible window, observer sees disappear/reappear. +- **Jitter**: when a stale UP-derived velocity exists, body extrapolates along + the OLD heading; meanwhile the server is steering the creature on a curve. + Each new UP snap-corrects → visible stutter. + +The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS +sending fresh target positions and headings each tick — we're throwing them +away. + +## Retail behavior (named decomp + ACE port) + +Sources: +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below +- `docs/research/named-retail/acclient.h` — struct definitions +- `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs` +- `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs` + +### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7) + +``` +[uint targetGuid] // type 6 only (MoveToObject) +Origin: uint cellId // then 3 floats local x/y/z + float x, y, z // destination position +MovementParameters (28 bytes, exact retail order): + uint flags // bitfield (see below) + float distance_to_object // arrival far-bound (ACE default 0.6) + float min_distance // arrival near-bound + float fail_distance // abort when starting→current >= this + float speed // base speed multiplier + float walk_run_threshhold // (sic, two h's) — wire default 15.0 + float desired_heading // final orientation (radians or degrees) +float runRate // CMotionInterp::my_run_rate copy +``` + +### MovementParameters bit-flags (declaration order, acclient.h:31423-31443) + +| Bit | Mask | Name | Meaning | +|----:|---------|------|---------| +| 0 | 0x00001 | can_walk | gait permission | +| 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) | +| 2 | 0x00004 | can_sidestep | enables strafe path | +| 3 | 0x00008 | can_walk_backwards | gait permission | +| 4 | 0x00010 | can_charge | force HoldKey_Run | +| 5 | 0x00020 | fail_walk | fail if only walk possible | +| 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival | +| 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion | +| 8 | 0x00100 | move_away | flee target | +| 9 | 0x00200 | move_towards | chase target (chase creatures set this) | +| 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line | +| 11 | 0x00800 | set_hold_key | apply HoldKeyToApply | +| ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) | + +### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440) + +``` +if physics.motions_pending: + cancel any aux turn cmd (let the queued motion complete) +else: + targetWorld = currentTargetPosition // last server-supplied destination + desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd) + headingDelta = normalize(desiredHeading - body.heading) + if |headingDelta| <= 20°: // retail tolerance + // ACE adds set_heading(target, true) here (server-tic-rate fudge) + cancel any aux turn cmd + else: + edi = (headingDelta < 180°) ? TurnLeft : TurnRight + if edi != auxCommand: + _DoMotion(edi) // -> CMotionInterp + auxCommand = edi +dist = GetCurrentDistance() +if CheckProgressMade(dist): + if !movingAway and dist <= min_distance: // arrived + popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode() + if movingAway and dist >= distance_to_object: + popHeadNode(); ... + if !movingAway and Position.distance(starting, current) >= fail_distance: + CancelMoveTo(0x3d) // YouChargedTooFar +``` + +### Key insight: MoveToManager does NOT touch the body directly + +Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion` +(via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the +ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is +purely a *planner* sitting above CMotionInterp, deciding *which command* (and +which auxiliary turn) the body should be running at any given tick. + +## Acdream port — minimum viable subset + +The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can +skip: +- `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it +- Sticky / `PositionManager::StickTo` +- `CheckProgressMade` stall detection — server cancels the move +- `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern +- `WeenieObj::OnMoveComplete` callback +- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as + a fresh single-step plan) + +We DO need: +1. **Parser**: extract the discarded fields into `ServerMotionState`. +2. **Per-tick steer**: compute heading-to-destination, turn body orientation + toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow* + `apply_current_movement` to run — which sets `Body.Velocity` from the + active RunForward cycle, oriented along the now-correct heading. +3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready + and clear `ServerMoveToActive`. Server's next MoveTo packet will resume. + +## Pseudocode — acdream port + +### Parser change (`UpdateMotion.TryParseMoveToPayload`) + +``` +TryParseMoveToPayload(body, pos, mt, out parsed): + if mt == 6: + if rem < 4: return false + parsed.TargetGuid = ReadU32; pos += 4 + + if rem < 16: return false + parsed.OriginCellId = ReadU32; pos += 4 + parsed.OriginX = ReadF32; pos += 4 + parsed.OriginY = ReadF32; pos += 4 + parsed.OriginZ = ReadF32; pos += 4 + + if rem < 28: return false + parsed.Flags = ReadU32; pos += 4 + parsed.DistanceToObject = ReadF32; pos += 4 + parsed.MinDistance = ReadF32; pos += 4 + parsed.FailDistance = ReadF32; pos += 4 + parsed.Speed = ReadF32; pos += 4 + parsed.WalkRunThreshold = ReadF32; pos += 4 + parsed.DesiredHeading = ReadF32; pos += 4 + + if rem < 4: return false + parsed.RunRate = ReadF32 + return true +``` + +### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`) + +``` +DriveOneTick(rm, dt): + if not rm.HasMoveToDestination: return ApplyDefault + + targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time + bodyPos = rm.Body.Position + + // Distance check first — arrival short-circuits before any heading work + dist = horizontalDistance(targetWorld, bodyPos) + if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble): + rm.HasMoveToDestination = false + // animation cycle moves to Ready via the existing + // ApplyServerControlledVelocityCycle path on next zero-velocity sample + rm.Body.Velocity = Vector3.Zero + return Arrived + + // Heading compute (XY plane; Z untouched — server owns Z) + deltaXY = (targetWorld.XY - bodyPos.XY).Normalized + desiredHeading = atan2(deltaXY) // radians + currentHeading = QuaternionToYaw(rm.Body.Orientation) + headingDelta = wrapPi(desiredHeading - currentHeading) + + // Snap orientation toward target — match ACE's set_heading(target, true) + // when within tolerance, otherwise rotate at retail-faithful turn rate. + const float tolerance = 20° (in radians) + if |headingDelta| <= tolerance: + rm.Body.Orientation = QuaternionFromYaw(desiredHeading) + else: + // retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt + float maxStep = TurnRateRadPerSec * dt + float step = clamp(headingDelta, -maxStep, +maxStep) + rm.Body.Orientation = QuaternionFromYaw(currentHeading + step) + + // Allow apply_current_movement to set Velocity from RunForward cycle. + // The cycle was already seeded by PlanMoveToStart at packet receipt + // and is being played by the AnimationSequencer. CMotionInterp's + // apply_current_movement reads InterpretedState.ForwardCommand and + // sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod. + return DriveActive // caller now invokes apply_current_movement +``` + +### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch) + +``` +on receipt of MoveTo packet: + // existing code already seeds the animation cycle via PlanMoveToStart + // NEW: store world-converted destination + thresholds on rmState + lbX = (originCellId >> 24) & 0xFF + lbY = (originCellId >> 16) & 0xFF + origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0) + rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin + rmState.MoveToMinDistance = parsed.MinDistance + rmState.MoveToDistanceToObject = parsed.DistanceToObject + rmState.HasMoveToDestination = true + // ServerMoveToActive remains set; existing +``` + +### Integration in per-tick remote update (`GameWindow.cs` ~line 5045) + +``` +// Replace the current Velocity = Zero hold with: +else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) +{ + var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt); + if driveResult == Arrived: + // signal cycle update to Ready via existing path + ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero); + else: + rm.Body.TransientState |= Contact | OnWalkable | Active + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); +} +else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) +{ + // No destination yet (very early frame, packet hasn't fully landed) + rm.Body.Velocity = Vector3.Zero; +} +else +{ + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); +} +``` + +## Conformance test cases + +1. **Parser round-trip — type 7 (MoveToPosition)** + - Synthesize a 68-byte body with known origin + 7 params + runRate. + - Assert all 9 new fields decode correctly. + +2. **Parser round-trip — type 6 (MoveToObject)** + - Synthesize a 72-byte body with target guid + origin + params + runRate. + - Assert TargetGuid populated and shifts subsequent fields by 4 bytes. + +3. **DriveOneTick — heading snap within tolerance** + - body at (0,0,0) facing east, destination (10,0,0). + - DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap. + - assert orientation unchanged (already correct). + +4. **DriveOneTick — heading turn beyond tolerance** + - body at (0,0,0) facing east, destination (0,10,0). + - desiredHeading=π/2; current=0; |delta|=π/2 > 20°. + - dt=0.1s, TurnRate=π/2 → step = π/4 toward target. + - assert orientation rotated by π/4 (not full snap). + +5. **DriveOneTick — arrival** + - body at (0,0,0), destination (0.4,0,0), MinDistance=0.6. + - assert HasMoveToDestination cleared and Velocity zeroed. + +6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`) + - assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`. + +## Out of scope (future Phase L.1d if needed) + +- Sticky / StickTo for MoveToObject completion +- `use_final_heading` (post-arrival turn-to-heading) +- `fail_distance` early-cancel (server already does this; we just don't flag it) +- `CheckProgressMade` stall detector +- Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper) +- Sphere-cylinder distance (`use_spheres` bit) +- `MoveToObject` target-guid resolution — currently we only honor the Origin, + which works because the server re-emits with refreshed Origin each tick. + If the target is moving fast and the server's emit cadence falls behind, + we'd see lag; a future enhancement is to look up the target entity by + guid and use its current world position when fresher than Origin. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 353f8b4..aae7478 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -226,11 +226,50 @@ public sealed class GameWindow : IDisposable /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager - /// and CMotionInterp using the packet's runRate; until we port the - /// full target solver, use this only to protect packet-derived - /// animation speed from velocity-cycle clobbering. + /// and CMotionInterp; the per-tick remote driver consults this to + /// decide whether to feed body steering through + /// instead of + /// the InterpretedMotionState path. /// public bool ServerMoveToActive; + + /// + /// True once a MoveTo packet's full path payload (Origin + thresholds) + /// has been parsed and the world-converted destination is stored on + /// . Cleared on arrival or when + /// the next non-MoveTo UpdateMotion replaces the locomotion source. + /// Phase L.1c (2026-04-28). + /// + public bool HasMoveToDestination; + + /// + /// World-space destination from the most recent MoveTo packet's + /// Origin field, converted via the same landblock-grid + /// arithmetic OnLivePositionUpdated uses. + /// + public System.Numerics.Vector3 MoveToDestinationWorld; + + /// + /// min_distance from the MoveTo packet's MovementParameters. + /// Used by as + /// the chase-arrival threshold per retail + /// MoveToManager::HandleMoveToPosition. + /// + public float MoveToMinDistance; + + /// + /// distance_to_object from the MoveTo packet. Reserved for + /// the flee branch (move_away); chase uses + /// . + /// + public float MoveToDistanceToObject; + + /// + /// True if MovementParameters bit 9 (move_towards, mask + /// 0x200) is set on the active packet — i.e. this is a + /// chase. False = flee (move_away) or static target. + /// + public bool MoveToMoveTowards; /// /// Legacy field — no longer used for slerp (retail hard-snaps /// per FUN_00514b90 set_frame). Kept to avoid churn. @@ -2454,6 +2493,37 @@ public sealed class GameWindow : IDisposable { remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; + // Phase L.1c (2026-04-28): capture the full MoveTo path + // payload so the per-tick remote driver can steer the + // body toward Origin instead of holding velocity at zero + // between sparse UpdatePosition snaps. Retail + // MoveToManager::MoveToPosition stores the same fields + // (acclient_2013_pseudo_c.txt:307521-307593). + if (update.MotionState.IsServerControlledMoveTo + && update.MotionState.MoveToPath is { } path) + { + remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver + .OriginToWorld( + path.OriginCellId, + path.OriginX, + path.OriginY, + path.OriginZ, + _liveCenterX, + _liveCenterY); + remoteMot.MoveToMinDistance = path.MinDistance; + remoteMot.MoveToDistanceToObject = path.DistanceToObject; + remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards; + remoteMot.HasMoveToDestination = true; + } + else if (!update.MotionState.IsServerControlledMoveTo) + { + // Cycle changed off MoveTo — clear stale destination + // so the per-tick driver doesn't keep steering after + // the server has switched us back to interpreted + // motion. + remoteMot.HasMoveToDestination = false; + } + // Forward axis (Ready / WalkForward / RunForward / WalkBackward). remoteMot.Motion.DoInterpretedMotion( fullMotion, speedMod, modifyInterpretedState: true); @@ -5042,13 +5112,53 @@ public sealed class GameWindow : IDisposable rm.Body.Velocity = rm.ServerVelocity; } } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive + && rm.HasMoveToDestination) + { + // Phase L.1c port of retail MoveToManager per-tick + // steering (HandleMoveToPosition @ 0x00529d80). + // Steer body orientation toward the latest + // server-supplied destination, then let + // apply_current_movement set Velocity from the + // RunForward cycle through the now-correct heading. + var driveResult = AcDream.Core.Physics.RemoteMoveToDriver + .Drive( + rm.Body.Position, + rm.Body.Orientation, + rm.MoveToDestinationWorld, + rm.MoveToMinDistance, + (float)dt, + rm.MoveToMoveTowards, + out var steeredOrientation); + rm.Body.Orientation = steeredOrientation; + + if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver + .DriveResult.Arrived) + { + // Within arrival window — zero velocity until the + // next MoveTo packet refreshes the destination + // (or the server explicitly stops us with an + // interpreted-motion UM cmd=Ready). + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + // Steering active — apply_current_movement reads + // InterpretedState.ForwardCommand=RunForward (set + // when the MoveTo packet arrived) and emits + // velocity along +Y in body local space. Our + // updated orientation rotates that into the right + // world direction toward the target. + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } + } else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) { - // We only parse enough of MoveTo to recover retail - // animation speed. Do not let apply_current_movement - // extrapolate position from an incomplete target - // solver; hold until the next UpdatePosition-derived - // velocity arrives. + // MoveTo flag set but we haven't seen a path payload + // yet (e.g. truncated packet, or a brand-new entity + // whose first cycle UM is still in flight). Hold + // velocity at zero — same conservative stance as the + // 882a07c stabilizer for incomplete state. rm.Body.Velocity = System.Numerics.Vector3.Zero; } else diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 847f1c2..39b30cd 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -143,7 +143,8 @@ public static class CreateObject byte MovementType = 0, uint? MoveToParameters = null, float? MoveToSpeed = null, - float? MoveToRunRate = null) + float? MoveToRunRate = null, + MoveToPathData? MoveToPath = null) { /// /// ACE/retail movement types 6 and 7 are server-controlled @@ -155,8 +156,43 @@ public static class CreateObject public bool MoveToCanRun => !MoveToParameters.HasValue || (MoveToParameters.Value & 0x2u) != 0; + + /// + /// MovementParameters bit 9 (mask 0x200) — set when the creature is + /// chasing its target. Cross-checked against acclient.h:31423-31443 + /// (named retail) + ACE MovementParamFlags.MoveTowards. + /// + public bool MoveTowards => MoveToParameters.HasValue + && (MoveToParameters.Value & 0x200u) != 0; } + /// + /// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7). + /// Wire layout per MovementParameters::UnPackNet @ 0x0052ac50 + /// + the leading Origin + optional target guid for type 6: + /// + /// type 6 (MoveToObject) only: u32 TargetGuid + /// Origin: u32 cellId, then 3 floats (local x/y/z within the landblock) + /// MovementParameters (28 bytes, exact retail order): + /// u32 flags, f32 distance_to_object, f32 min_distance, + /// f32 fail_distance, f32 speed, f32 walk_run_threshhold, + /// f32 desired_heading + /// + /// (The trailing runRate float is captured separately on + /// .) + /// + public readonly record struct MoveToPathData( + uint? TargetGuid, + uint OriginCellId, + float OriginX, + float OriginY, + float OriginZ, + float DistanceToObject, + float MinDistance, + float FailDistance, + float WalkRunThreshold, + float DesiredHeading); + /// /// One entry in the InterpretedMotionState's Commands list (MotionItem). /// The server packs 0..many of these per broadcast: emotes, attacks, diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 6cc76cc..8756281 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -130,6 +130,7 @@ public static class UpdateMotion uint? moveToParameters = null; float? moveToSpeed = null; float? moveToRunRate = null; + CreateObject.MoveToPathData? moveToPath = null; List? commands = null; if (movementType == 0) @@ -232,7 +233,8 @@ public static class UpdateMotion movementType, out moveToParameters, out moveToSpeed, - out moveToRunRate); + out moveToRunRate, + out moveToPath); } return new Parsed(guid, new CreateObject.ServerMotionState( @@ -241,7 +243,8 @@ public static class UpdateMotion movementType, moveToParameters, moveToSpeed, - moveToRunRate)); + moveToRunRate, + moveToPath)); } catch { @@ -255,11 +258,13 @@ public static class UpdateMotion byte movementType, out uint? movementParameters, out float? speed, - out float? runRate) + out float? runRate, + out CreateObject.MoveToPathData? path) { movementParameters = null; speed = null; runRate = null; + path = null; // Retail MovementManager::PerformMovement (0x00524440) consumes // MoveToObject/MoveToPosition as: @@ -268,25 +273,60 @@ public static class UpdateMotion // MovementParameters::UnPackNet (0x0052AC50): flags, distance, // min, fail, speed, walk/run threshold, desired heading // f32 runRate copied into CMotionInterp::my_run_rate. + // + // Phase L.1c (2026-04-28): the full path payload is now retained on + // so the per-tick remote + // body driver can steer toward Origin instead of holding velocity at + // zero between sparse UpdatePosition snaps. The 882a07c stabilizer + // was deliberately conservative because we only had speed+runRate; + // with the rest of the packet captured, the body solver has full + // path data and can run faithfully. + uint? targetGuid = null; if (movementType == 6) { if (body.Length - pos < 4) return false; - pos += 4; // target guid + targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; } if (body.Length - pos < 16 + 28 + 4) return false; - pos += 16; // Origin + + uint originCellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + float originX = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + float originY = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + float originZ = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; - pos += 4; // distanceToObject - pos += 4; // minDistance - pos += 4; // failDistance + float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; - pos += 4; // walkRunThreshold - pos += 4; // desiredHeading + float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + + path = new CreateObject.MoveToPathData( + targetGuid, + originCellId, + originX, + originY, + originZ, + distanceToObject, + minDistance, + failDistance, + walkRunThreshold, + desiredHeading); return true; } } diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs new file mode 100644 index 0000000..0b6a675 --- /dev/null +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -0,0 +1,204 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Per-tick steering for server-controlled remote creatures while a +/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet +/// is the active locomotion source. +/// +/// +/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo" +/// stabilizer. With the full MoveTo path payload now captured on +/// , +/// the body solver has the destination + heading + thresholds it needs to +/// run the retail per-tick loop instead of waiting for sparse +/// UpdatePosition snap corrections. +/// +/// +/// +/// Retail references: +/// +/// +/// MoveToManager::HandleMoveToPosition (0x00529d80) — the +/// per-tick driver. Computes heading-to-target, fires an aux +/// TurnLeft/TurnRight command when |delta| > 20°, snaps +/// orientation when within tolerance, and tests arrival via +/// dist <= min_distance (chase) or +/// dist >= distance_to_object (flee). +/// +/// +/// MoveToManager::_DoMotion / _StopMotion route turn +/// commands through CMotionInterp::DoInterpretedMotion — i.e. +/// MoveToManager itself does NOT touch the body. The body's actual +/// velocity comes from CMotionInterp::apply_current_movement +/// reading InterpretedState.ForwardCommand = RunForward and +/// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by +/// the body's orientation. +/// +/// +/// +/// +/// +/// Acdream port scope: minimum viable subset. We skip target re-tracking +/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/ +/// StickTo, fail-distance progress detector, and the sphere-cylinder +/// distance variant — all server-side concerns the local body doesn't need +/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance +/// (with ACE's set_heading(true) snap-on-aligned fudge), and +/// arrival detection via min_distance. +/// +/// +/// +/// ACE divergence: ACE swaps the chase/flee arrival predicates +/// (dist <= DistanceToObject vs retail's dist <= MinDistance). +/// We follow retail. +/// +/// +public static class RemoteMoveToDriver +{ + /// + /// Heading tolerance below which we snap orientation directly to the + /// target heading (ACE's set_heading(target, true) + /// server-tic-rate fudge). Above tolerance we rotate at + /// . Retail value (line 307251 of + /// acclient_2013_pseudo_c.txt) is 20°. + /// + public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f; + + /// + /// Default angular rate for in-motion heading correction when delta + /// exceeds . Picked to match + /// ACE's TurnSpeed default of π/2 rad/s for monsters; + /// when the per-creature value differs, the future port can wire it + /// in via the TurnSpeed field on InterpretedMotionState. + /// + public const float TurnRateRadPerSec = MathF.PI / 2.0f; + + /// + /// Float-comparison slack for the arrival predicate. With + /// min_distance == 0 in a chase packet, exact equality is + /// unreachable due to integration wobble; this epsilon prevents the + /// driver from over-shooting by a sub-meter and snap-flipping back. + /// + public const float ArrivalEpsilon = 0.05f; + + public enum DriveResult + { + /// Within arrival window — caller should zero velocity. + Arrived, + /// Steering active — caller should let + /// apply_current_movement set body velocity from the cycle. + Steering, + } + + /// + /// Steer body orientation toward + /// and report whether the body has arrived (within + /// ) or should keep running. Pure + /// function — emits the updated orientation via + /// (the input is not mutated; the + /// caller assigns the new value back to its body). + /// + public static DriveResult Drive( + Vector3 bodyPosition, + Quaternion bodyOrientation, + Vector3 destinationWorld, + float minDistance, + float dt, + bool moveTowards, + out Quaternion newOrientation) + { + // Horizontal distance only — server owns Z, our body Z is + // hard-snapped to the latest UpdatePosition. + float dx = destinationWorld.X - bodyPosition.X; + float dy = destinationWorld.Y - bodyPosition.Y; + float dist = MathF.Sqrt(dx * dx + dy * dy); + + // Arrival predicate per retail MoveToManager::HandleMoveToPosition + // (chase: dist ≤ min_distance; flee branch is unused here, but + // we honor the moveTowards flag for symmetry). + if (moveTowards && dist <= minDistance + ArrivalEpsilon) + { + newOrientation = bodyOrientation; + return DriveResult.Arrived; + } + + // Degenerate — already on target horizontally; preserve heading. + if (dist < 1e-4f) + { + newOrientation = bodyOrientation; + return DriveResult.Steering; + } + + // Body's local-forward is +Y (see MotionInterpreter.get_state_velocity + // at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed). + // World forward = Transform((0,1,0), orientation). Yaw extracted + // via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity. + var localForward = new Vector3(0f, 1f, 0f); + var worldForward = Vector3.Transform(localForward, bodyOrientation); + float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y); + + // Desired heading: face the target. (dx, dy) is the world-space + // offset to the target. With local-forward=+Y we want yaw such + // that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves + // to yaw = atan2(-dx, dy). + float desiredYaw = MathF.Atan2(-dx, dy); + float delta = WrapPi(desiredYaw - currentYaw); + + if (MathF.Abs(delta) <= HeadingSnapToleranceRad) + { + // ACE's set_heading(target, true) — sync to server-tic-rate. + // We have the same sparse-UP problem ACE does, so the same + // fudge applies. + newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw); + } + else + { + // Retail BeginTurnToHeading / HandleMoveToPosition aux turn: + // rotate at TurnRate clamped to dt, in the shorter direction. + float maxStep = TurnRateRadPerSec * dt; + float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep); + // Apply incremental yaw around world +Z (preserving any + // server-supplied pitch/roll from the latest UpdatePosition). + var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step); + newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation); + } + + return DriveResult.Steering; + } + + /// + /// Convert a landblock-local Origin from a MoveTo packet + /// () + /// into acdream's render world space using the same arithmetic as + /// OnLivePositionUpdated: shift by the landblock-grid offset + /// from the live-mode center. + /// + public static Vector3 OriginToWorld( + uint originCellId, + float originX, + float originY, + float originZ, + int liveCenterLandblockX, + int liveCenterLandblockY) + { + int lbX = (int)((originCellId >> 24) & 0xFFu); + int lbY = (int)((originCellId >> 16) & 0xFFu); + return new Vector3( + originX + (lbX - liveCenterLandblockX) * 192f, + originY + (lbY - liveCenterLandblockY) * 192f, + originZ); + } + + /// Wrap an angle in radians to [-π, π]. + private static float WrapPi(float r) + { + const float TwoPi = MathF.PI * 2f; + r %= TwoPi; + if (r > MathF.PI) r -= TwoPi; + if (r < -MathF.PI) r += TwoPi; + return r; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index ad0f01a..da042f0 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -251,5 +251,69 @@ public class UpdateMotionTests Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed); Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate); Assert.True(result.Value.MotionState.MoveToCanRun); + Assert.True(result.Value.MotionState.MoveTowards); + + // Phase L.1c (2026-04-28): full path payload retained. + Assert.NotNull(result.Value.MotionState.MoveToPath); + var path = result.Value.MotionState.MoveToPath!.Value; + Assert.Null(path.TargetGuid); + Assert.Equal(0xA8B4000Eu, path.OriginCellId); + Assert.Equal(10f, path.OriginX); + Assert.Equal(20f, path.OriginY); + Assert.Equal(30f, path.OriginZ); + Assert.Equal(0.6f, path.DistanceToObject); + Assert.Equal(0.0f, path.MinDistance); + Assert.Equal(float.MaxValue, path.FailDistance); + Assert.Equal(15.0f, path.WalkRunThreshold); + Assert.Equal(90.0f, path.DesiredHeading); + } + + [Fact] + public void ParsesMoveToObjectTargetGuidAndOrigin() + { + // Type 6 (MoveToObject) prepends a u32 target guid before the + // standard Origin + MovementParameters + runRate payload. + // Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72. + var body = new byte[20 + 4 + 16 + 28 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // MovementData header padding + + body[p++] = 6; // MoveToObject + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; + + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid + + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z + + const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate + + var result = UpdateMotion.TryParse(body); + + Assert.NotNull(result); + Assert.Equal((byte)6, result!.Value.MotionState.MovementType); + Assert.True(result.Value.MotionState.IsServerControlledMoveTo); + Assert.NotNull(result.Value.MotionState.MoveToPath); + var path = result.Value.MotionState.MoveToPath!.Value; + Assert.Equal(0x80001234u, path.TargetGuid); + Assert.Equal(0xA8B4000Eu, path.OriginCellId); + Assert.Equal(5f, path.OriginX); + Assert.Equal(6f, path.OriginY); + Assert.Equal(7f, path.OriginZ); + Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate); } } diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs new file mode 100644 index 0000000..7624734 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Phase L.1c (2026-04-28). Covers — the +/// per-tick steering port of retail +/// MoveToManager::HandleMoveToPosition for server-controlled remote +/// creatures. +/// +public class RemoteMoveToDriverTests +{ + private const float Epsilon = 1e-3f; + + private static float Yaw(Quaternion q) + { + var fwd = Vector3.Transform(new Vector3(0, 1, 0), q); + return MathF.Atan2(-fwd.X, fwd.Y); + } + + [Fact] + public void Drive_AlreadyAtTarget_ReportsArrived() + { + var bodyPos = new Vector3(10f, 20f, 0f); + var bodyRot = Quaternion.Identity; + var dest = new Vector3(10f, 20.3f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0.5f, dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + Assert.Equal(bodyRot, newOrient); // orientation untouched + } + + [Fact] + public void Drive_ChasingButNotInRange_ReportsSteering() + { + var bodyPos = new Vector3(0f, 0f, 0f); + var bodyRot = Quaternion.Identity; // facing +Y + var dest = new Vector3(0f, 50f, 0f); // straight ahead + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + // Already facing target → snap branch keeps yaw at 0. + Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon); + } + + [Fact] + public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance() + { + // Body facing +Y; target at (1, 10, 0) — that's a small angle + // (about 5.7°), well within the 20° snap tolerance. + var bodyPos = Vector3.Zero; + var bodyRot = Quaternion.Identity; + var dest = new Vector3(1f, 10f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + // Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad. + float expectedYaw = MathF.Atan2(-1f, 10f); + Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon); + + // Verify orientation actually transforms +Y onto the (1,10) line. + var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient); + Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f); + } + + [Fact] + public void Drive_TargetBeyondTolerance_RotatesByLimitedStep() + { + // Body facing +Y; target at (-10, 0) — that's 90° to the left + // (well beyond the 20° snap tolerance), so we turn by at most + // TurnRateRadPerSec * dt this tick rather than snapping. + var bodyPos = Vector3.Zero; + var bodyRot = Quaternion.Identity; // yaw = 0 + var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left) + const float dt = 0.1f; + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, dt: dt, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt; + // We should turn LEFT (positive yaw) toward the target. + Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon); + } + + [Fact] + public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath() + { + // Body facing +Y; target directly behind at (0, -10, 0). + // |delta| = π, equally close either way; the implementation + // picks one (sign depends on float wobble) — just assert + // we made progress (yaw changed by exactly TurnRate * dt). + var bodyPos = Vector3.Zero; + var bodyRot = Quaternion.Identity; + var dest = new Vector3(0f, -10f, 0f); + const float dt = 0.1f; + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, dt: dt, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt; + Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon); + } + + [Fact] + public void Drive_PreservesOrientationAtArrival() + { + var bodyPos = new Vector3(5f, 5f, 0f); + var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f); + var dest = new Vector3(5.01f, 5.01f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0.5f, dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + // Caller would zero velocity; orientation should be untouched + // so the body settles facing whatever direction it was already. + Assert.Equal(bodyRot, newOrient); + } + + [Fact] + public void OriginToWorld_AppliesLandblockGridShift() + { + // Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center + // at (0xA9, 0xB4), that's one landblock west and zero north, + // so origin (10, 20, 0) inside that landblock should map to + // (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space. + var w = RemoteMoveToDriver.OriginToWorld( + originCellId: 0xA8B4000Eu, + originX: 10f, originY: 20f, originZ: 0f, + liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4); + + Assert.Equal(-182f, w.X); + Assert.Equal(20f, w.Y); + Assert.Equal(0f, w.Z); + } +}