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); + } +}