feat(anim): Phase L.1c port MoveTo path data + per-tick steer

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-28 21:49:22 +02:00
parent 882a07cfde
commit 186a584404
7 changed files with 917 additions and 19 deletions

View file

@ -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.

View file

@ -226,11 +226,50 @@ public sealed class GameWindow : IDisposable
/// <summary> /// <summary>
/// True while a server MoveToObject/MoveToPosition packet is the /// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager /// active locomotion source. Retail runs these through MoveToManager
/// and CMotionInterp using the packet's runRate; until we port the /// and CMotionInterp; the per-tick remote driver consults this to
/// full target solver, use this only to protect packet-derived /// decide whether to feed body steering through
/// animation speed from velocity-cycle clobbering. /// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
/// the InterpretedMotionState path.
/// </summary> /// </summary>
public bool ServerMoveToActive; public bool ServerMoveToActive;
/// <summary>
/// True once a MoveTo packet's full path payload (Origin + thresholds)
/// has been parsed and the world-converted destination is stored on
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
/// Phase L.1c (2026-04-28).
/// </summary>
public bool HasMoveToDestination;
/// <summary>
/// World-space destination from the most recent MoveTo packet's
/// <c>Origin</c> field, converted via the same landblock-grid
/// arithmetic <c>OnLivePositionUpdated</c> uses.
/// </summary>
public System.Numerics.Vector3 MoveToDestinationWorld;
/// <summary>
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
/// the chase-arrival threshold per retail
/// <c>MoveToManager::HandleMoveToPosition</c>.
/// </summary>
public float MoveToMinDistance;
/// <summary>
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
/// the flee branch (<c>move_away</c>); chase uses
/// <see cref="MoveToMinDistance"/>.
/// </summary>
public float MoveToDistanceToObject;
/// <summary>
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
/// <c>0x200</c>) is set on the active packet — i.e. this is a
/// chase. False = flee (<c>move_away</c>) or static target.
/// </summary>
public bool MoveToMoveTowards;
/// <summary> /// <summary>
/// Legacy field — no longer used for slerp (retail hard-snaps /// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn. /// per FUN_00514b90 set_frame). Kept to avoid churn.
@ -2454,6 +2493,37 @@ public sealed class GameWindow : IDisposable
{ {
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; 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). // Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion( remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true); fullMotion, speedMod, modifyInterpretedState: true);
@ -5042,13 +5112,53 @@ public sealed class GameWindow : IDisposable
rm.Body.Velocity = rm.ServerVelocity; 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) else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
{ {
// We only parse enough of MoveTo to recover retail // MoveTo flag set but we haven't seen a path payload
// animation speed. Do not let apply_current_movement // yet (e.g. truncated packet, or a brand-new entity
// extrapolate position from an incomplete target // whose first cycle UM is still in flight). Hold
// solver; hold until the next UpdatePosition-derived // velocity at zero — same conservative stance as the
// velocity arrives. // 882a07c stabilizer for incomplete state.
rm.Body.Velocity = System.Numerics.Vector3.Zero; rm.Body.Velocity = System.Numerics.Vector3.Zero;
} }
else else

View file

@ -143,7 +143,8 @@ public static class CreateObject
byte MovementType = 0, byte MovementType = 0,
uint? MoveToParameters = null, uint? MoveToParameters = null,
float? MoveToSpeed = null, float? MoveToSpeed = null,
float? MoveToRunRate = null) float? MoveToRunRate = null,
MoveToPathData? MoveToPath = null)
{ {
/// <summary> /// <summary>
/// ACE/retail movement types 6 and 7 are server-controlled /// ACE/retail movement types 6 and 7 are server-controlled
@ -155,8 +156,43 @@ public static class CreateObject
public bool MoveToCanRun => !MoveToParameters.HasValue public bool MoveToCanRun => !MoveToParameters.HasValue
|| (MoveToParameters.Value & 0x2u) != 0; || (MoveToParameters.Value & 0x2u) != 0;
/// <summary>
/// MovementParameters bit 9 (mask 0x200) — set when the creature is
/// chasing its target. Cross-checked against acclient.h:31423-31443
/// (named retail) + ACE <c>MovementParamFlags.MoveTowards</c>.
/// </summary>
public bool MoveTowards => MoveToParameters.HasValue
&& (MoveToParameters.Value & 0x200u) != 0;
} }
/// <summary>
/// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
/// Wire layout per <c>MovementParameters::UnPackNet</c> @ <c>0x0052ac50</c>
/// + the leading <c>Origin</c> + optional target guid for type 6:
/// <list type="bullet">
/// <item>type 6 (MoveToObject) only: u32 <c>TargetGuid</c></item>
/// <item>Origin: u32 <c>cellId</c>, then 3 floats (local x/y/z within the landblock)</item>
/// <item>MovementParameters (28 bytes, exact retail order):
/// u32 flags, f32 <c>distance_to_object</c>, f32 <c>min_distance</c>,
/// f32 <c>fail_distance</c>, f32 <c>speed</c>, f32 <c>walk_run_threshhold</c>,
/// f32 <c>desired_heading</c></item>
/// </list>
/// (The trailing <c>runRate</c> float is captured separately on
/// <see cref="ServerMotionState.MoveToRunRate"/>.)
/// </summary>
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);
/// <summary> /// <summary>
/// One entry in the InterpretedMotionState's Commands list (MotionItem). /// One entry in the InterpretedMotionState's Commands list (MotionItem).
/// The server packs 0..many of these per broadcast: emotes, attacks, /// The server packs 0..many of these per broadcast: emotes, attacks,

View file

@ -130,6 +130,7 @@ public static class UpdateMotion
uint? moveToParameters = null; uint? moveToParameters = null;
float? moveToSpeed = null; float? moveToSpeed = null;
float? moveToRunRate = null; float? moveToRunRate = null;
CreateObject.MoveToPathData? moveToPath = null;
List<CreateObject.MotionItem>? commands = null; List<CreateObject.MotionItem>? commands = null;
if (movementType == 0) if (movementType == 0)
@ -232,7 +233,8 @@ public static class UpdateMotion
movementType, movementType,
out moveToParameters, out moveToParameters,
out moveToSpeed, out moveToSpeed,
out moveToRunRate); out moveToRunRate,
out moveToPath);
} }
return new Parsed(guid, new CreateObject.ServerMotionState( return new Parsed(guid, new CreateObject.ServerMotionState(
@ -241,7 +243,8 @@ public static class UpdateMotion
movementType, movementType,
moveToParameters, moveToParameters,
moveToSpeed, moveToSpeed,
moveToRunRate)); moveToRunRate,
moveToPath));
} }
catch catch
{ {
@ -255,11 +258,13 @@ public static class UpdateMotion
byte movementType, byte movementType,
out uint? movementParameters, out uint? movementParameters,
out float? speed, out float? speed,
out float? runRate) out float? runRate,
out CreateObject.MoveToPathData? path)
{ {
movementParameters = null; movementParameters = null;
speed = null; speed = null;
runRate = null; runRate = null;
path = null;
// Retail MovementManager::PerformMovement (0x00524440) consumes // Retail MovementManager::PerformMovement (0x00524440) consumes
// MoveToObject/MoveToPosition as: // MoveToObject/MoveToPosition as:
@ -268,25 +273,60 @@ public static class UpdateMotion
// MovementParameters::UnPackNet (0x0052AC50): flags, distance, // MovementParameters::UnPackNet (0x0052AC50): flags, distance,
// min, fail, speed, walk/run threshold, desired heading // min, fail, speed, walk/run threshold, desired heading
// f32 runRate copied into CMotionInterp::my_run_rate. // f32 runRate copied into CMotionInterp::my_run_rate.
//
// Phase L.1c (2026-04-28): the full path payload is now retained on
// <see cref="CreateObject.MoveToPathData"/> 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 (movementType == 6)
{ {
if (body.Length - pos < 4) return false; 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; 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)); movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4; pos += 4;
pos += 4; // distanceToObject float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4; // minDistance pos += 4;
pos += 4; // failDistance float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4; pos += 4;
pos += 4; // walkRunThreshold float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4; // desiredHeading pos += 4;
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
path = new CreateObject.MoveToPathData(
targetGuid,
originCellId,
originX,
originY,
originZ,
distanceToObject,
minDistance,
failDistance,
walkRunThreshold,
desiredHeading);
return true; return true;
} }
} }

View file

@ -0,0 +1,204 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-tick steering for server-controlled remote creatures while a
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
/// is the active locomotion source.
///
/// <para>
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
/// stabilizer. With the full MoveTo path payload now captured on
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
/// 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.
/// </para>
///
/// <para>
/// Retail references:
/// <list type="bullet">
/// <item><description>
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
/// per-tick driver. Computes heading-to-target, fires an aux
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| &gt; 20°, snaps
/// orientation when within tolerance, and tests arrival via
/// <c>dist &lt;= min_distance</c> (chase) or
/// <c>dist &gt;= distance_to_object</c> (flee).
/// </description></item>
/// <item><description>
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
/// MoveToManager itself does NOT touch the body. The body's actual
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
/// the body's orientation.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// 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 <c>set_heading(true)</c> snap-on-aligned fudge), and
/// arrival detection via <c>min_distance</c>.
/// </para>
///
/// <para>
/// ACE divergence: ACE swaps the chase/flee arrival predicates
/// (<c>dist &lt;= DistanceToObject</c> vs retail's <c>dist &lt;= MinDistance</c>).
/// We follow retail.
/// </para>
/// </summary>
public static class RemoteMoveToDriver
{
/// <summary>
/// Heading tolerance below which we snap orientation directly to the
/// target heading (ACE's <c>set_heading(target, true)</c>
/// server-tic-rate fudge). Above tolerance we rotate at
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
/// </summary>
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
/// <summary>
/// Default angular rate for in-motion heading correction when delta
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
/// when the per-creature value differs, the future port can wire it
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
/// </summary>
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Float-comparison slack for the arrival predicate. With
/// <c>min_distance == 0</c> 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.
/// </summary>
public const float ArrivalEpsilon = 0.05f;
public enum DriveResult
{
/// <summary>Within arrival window — caller should zero velocity.</summary>
Arrived,
/// <summary>Steering active — caller should let
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
Steering,
}
/// <summary>
/// Steer body orientation toward <paramref name="destinationWorld"/>
/// and report whether the body has arrived (within
/// <paramref name="minDistance"/>) or should keep running. Pure
/// function — emits the updated orientation via
/// <paramref name="newOrientation"/> (the input is not mutated; the
/// caller assigns the new value back to its body).
/// </summary>
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;
}
/// <summary>
/// Convert a landblock-local Origin from a MoveTo packet
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
/// into acdream's render world space using the same arithmetic as
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
/// from the live-mode center.
/// </summary>
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);
}
/// <summary>Wrap an angle in radians to [-π, π].</summary>
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;
}
}

View file

@ -251,5 +251,69 @@ public class UpdateMotionTests
Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed); Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate); Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
Assert.True(result.Value.MotionState.MoveToCanRun); 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);
} }
} }

View file

@ -0,0 +1,159 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
/// per-tick steering port of retail
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
/// creatures.
/// </summary>
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);
}
}