# ACE cross-reference: MovementManager / PositionManager / StickyManager / ConstraintManager / TargetManager Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics/Managers/` All five files read in full (MovementManager.cs 216 lines, PositionManager.cs 132 lines, StickyManager.cs 136 lines, ConstraintManager.cs 80 lines, TargetManager.cs 182 lines). --- ## 1. MovementManager.cs (namespace `ACE.Server.Physics.Animation`) ### Fields | Field | Type | Initial value | |---|---|---| | `MotionInterpreter` | `MotionInterp` | null (set in ctor or lazily) | | `MoveToManager` | `MoveToManager` | null (set in ctor or lazily) | | `PhysicsObj` | `PhysicsObj` | null (set in ctor) | | `WeenieObj` | `WeenieObject` | null (set in ctor) | No timers, no quanta — this class is a pure facade/dispatcher with no owned state beyond the two sub-managers + back-refs. Confirms it's a **facade**, not a state machine itself. ### Constructors - `MovementManager()` — parameterless, all-null (default-constructed / deserialization shape). - `MovementManager(PhysicsObj obj, WeenieObject wobj)` (L18-25) — sets `PhysicsObj`/`WeenieObj`, eagerly constructs BOTH `MotionInterpreter = new MotionInterp(obj, wobj)` and `MoveToManager = new MoveToManager(obj, wobj)`. Note: this differs from the lazy-init pattern used everywhere else in the file (see below) — the 2-arg ctor is the only path that eagerly builds both children. ### Methods (signature + mechanical summary) - `CancelMoveTo(WeenieError error)` (L27-31) — null-guards `MoveToManager`, forwards to `MoveToManager.CancelMoveTo(error)`. - `static Create(PhysicsObj obj, WeenieObject wobj)` (L33-36) — factory, `return new MovementManager(obj, wobj)`. - `EnterDefaultState()` (L38-46) — early-returns if `PhysicsObj == null`. **Lazy-inits** `MotionInterpreter` via `MotionInterp.Create(PhysicsObj, WeenieObj)` if null, then calls `MotionInterpreter.enter_default_state()`. This is the canonical lazy-init pattern reused in 5 other methods below. - `HandleEnterWorld()` (L48-52) — **entirely commented out** (`//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter)`). Dead stub in ACE — no-op. - `HandleExitWorld()` (L54-58) — null-guards, forwards to `MotionInterpreter.HandleExitWorld()`. Does NOT touch `MoveToManager` (no `MoveToManager.HandleExitWorld` call exists in this class at all). - `HandleUpdateTarget(TargetInfo targetInfo)` (L60-64) — null-guards `MoveToManager`, forwards to `MoveToManager.HandleUpdateTarget(targetInfo)`. **Only route into MoveToManager for target updates** — MotionInterpreter is not involved. - `HitGround()` (L66-73) — forwards to BOTH `MotionInterpreter.HitGround()` AND `MoveToManager.HitGround()`, each separately null-guarded. Order: MotionInterpreter first, then MoveToManager. - `InqInterpretedMotionState()` (L75-84) — lazy-init pattern (create + `enter_default_state()` if `PhysicsObj != null`), returns `MotionInterpreter.InterpretedState`. - `InqRawMotionState()` (L86-95) — identical lazy-init pattern, returns `MotionInterpreter.RawState`. - `IsMovingTo()` (L97-102) — `MoveToManager == null` → `false`, else `MoveToManager.is_moving_to()`. - `LeaveGround()` (L104-110) — null-guarded forward to `MotionInterpreter.LeaveGround()` only. Comment `// NoticeHandler::RecvNotice_PrevSpellSection` — dead/no-op reference, no MoveToManager call (asymmetric vs `HitGround`). - `MakeMoveToManager()` (L112-116) — if `MoveToManager == null`, `MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj)`. - `MotionDone(uint motion, bool success)` (L118-122) — null-guarded forward to `MotionInterpreter.MotionDone(success)`. **Note: the `motion` parameter is accepted but never used/passed through** — only `success` reaches `MotionInterp.MotionDone`. - `PerformMovement(MovementStruct mvs)` (L124-157) — dispatch hub: - `PhysicsObj.set_active(true)` unconditionally first. - `switch (mvs.Type)`: - `RawCommand`, `InterpretedCommand`, `StopRawCommand`, `StopInterpretedCommand`, `StopCompletely` → lazy-init `MotionInterpreter` (create + enter_default_state), then `return MotionInterpreter.PerformMovement(mvs)`. - `MoveToObject`, `MoveToPosition`, `TurnToObject`, `TurnToHeading` → lazy-init `MoveToManager` (create only, no default-state analog), then `return MoveToManager.PerformMovement(mvs)`. - `default` → `return WeenieError.GeneralMovementFailure`. - This is the **dispatch order** requested: motion-type enum branches to exactly one of the two subsystems, never both. - `ReportExhaustion()` (L159-165) — null-guarded forward to `MotionInterpreter.ReportExhaustion()`. Comment `// NoticeHandler::RecvNotice_PrevSpellSelection` again (dead). - `SetWeenieObject(WeenieObject wobj)` (L167-174) — sets `WeenieObj = wobj`, then propagates to both children if non-null: `MotionInterpreter.SetWeenieObject(wobj)`, `MoveToManager.SetWeenieObject(wobj)`. - `UseTime()` (L176-179) — **only forwards to `MoveToManager.UseTime()`**. Does NOT call anything on `MotionInterpreter`. This is the per-tick pump entry for movement — MotionInterp presumably gets its ticks from elsewhere (not in this file). - `get_minterp()` (L181-190) — lazy-init identical to `InqInterpretedMotionState`, returns `MotionInterpreter` itself (not a sub-state). - `motions_pending()` (L195-200) — `MotionInterpreter == null` → `false`, else `MotionInterpreter.motions_pending()`. Doc comment recommends `PhysicsObj.IsAnimating` instead for perf (L192-194). - `move_to_interpreted_state(InterpretedMotionState state)` (L202-211) — lazy-init pattern, then `MotionInterpreter.move_to_interpreted_state(state)`. - `unpack_movement(object addr, uint size)` (L213) — **empty body**. Client-only wire-unpack stub; server doesn't need to unpack a movement buffer since it authors movement itself. This is the clearest ACE-adaptation marker in the file (see divergences). ### Divergences / ACE adaptations 1. **`unpack_movement` is a no-op** (L213) — retail client presumably deserializes a raw movement buffer here (used for network movement replay/interpolation); ACE server doesn't need this path since it's the authoritative source, not a consumer, of movement state. No comment explaining it, just an empty `{ }` body — a clear "removed client-only branch." 2. **`HandleEnterWorld` is fully commented out** (L48-52) — the notice-handler call for previous spell selection was presumably meaningful in the retail client's UI/notice pipeline; ACE has no client-side notice UI so it's dead. 3. `unused `motion` parameter in `MotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side. 4. Uses `ACE.Entity.Enum.WeenieError` (ACE server type) as the return type for `PerformMovement`/`CancelMoveTo` — this is ACE's message-to-client error enum, not something retail's internal MovementManager would return (server-specific plumbing for GameActionFailure-style responses). 5. No `Dispose`/serialization glue for save-to-DB; the two nested managers are always live objects, not lazily persisted — normal for a live-object emulator. --- ## 2. PositionManager.cs (namespace `ACE.Server.Physics.Animation`) ### Fields | Field | Type | Initial value | |---|---|---| | `InterpolationManager` | `InterpolationManager` | null | | `StickyManager` | `StickyManager` | null | | `ConstraintManager` | `ConstraintManager` | null | | `PhysicsObj` | `PhysicsObj` | null (set via ctor/`SetPhysicsObject`) | All three sub-managers are lazily created on first real use (`MakeStickyManager`, `ConstrainTo`, `InterpolateTo`) — none are eagerly constructed in the ctor, unlike `MovementManager`'s eager 2-arg ctor. ### Constructors - `PositionManager()` — parameterless/default. - `PositionManager(PhysicsObj obj)` (L15-18) — calls `SetPhysicsObject(obj)`. ### Methods - **`AdjustOffset(AFrame frame, double quantum)`** (L20-28) — **the requested composition method.** Null-guards each of the three sub-managers independently and calls, **in this exact order**: 1. `InterpolationManager.adjust_offset(frame, quantum)` 2. `StickyManager.adjust_offset(frame, quantum)` 3. `ConstraintManager.adjust_offset(frame, quantum)` All three mutate the SAME `frame` (an `AFrame`, passed by reference since it's a class/struct with mutable `Origin`) sequentially — each sub-adjustment composes on top of whatever the previous one wrote into `frame.Origin`/heading. This is a **strict, hard-coded pipeline order**: interpolation offset first, then sticky-follow offset, then constraint clamp — not a generic list of "adjusters." Constraint is explicitly LAST because `ConstraintManager.adjust_offset` scales/clamps `offset.Origin` based on what's already accumulated (it reads `offset.Origin.Length()` at the end to update `ConstraintPosOffset` — see ConstraintManager notes), i.e., it operates on the composed net displacement from both prior systems, not an independent contribution. - `ConstrainTo(Position position, float startDistance, float maxDistance)` (L30-36) — lazy-inits `ConstraintManager` via `ConstraintManager.Create(PhysicsObj)` if null, forwards args to `ConstraintManager.ConstrainTo(...)`. - `static Create(PhysicsObj physicsObj)` (L38-41) — factory. - `GetStickyObjectID()` (L43-47) — `StickyManager == null` → `0`, else `StickyManager.TargetID`. - `HandleUpdateTarget(TargetInfo targetInfo)` (L49-53) — null-guarded forward to `StickyManager.HandleUpdateTarget(targetInfo)` only (Constraint/Interpolation don't participate in target updates). - `InterpolateTo(Position position, bool keepHeading)` (L55-61) — lazy-inits `InterpolationManager` via `InterpolationManager.Create(PhysicsObj)`, forwards to `InterpolationManager.InterpolateTo(position, keepHeading)`. - `IsFullyConstrained()` (L63-69) — `ConstraintManager == null` → `false`, else `ConstraintManager.IsFullyConstrained()`. - `IsInterpolating()` (L71-74) — `InterpolationManager != null && InterpolationManager.IsInterpolating()`. - `MakeStickyManager()` (L76-80) — if `StickyManager == null`, `StickyManager = StickyManager.Create(PhysicsObj)`. - `SetPhysicsObject(PhysicsObj obj)` (L82-91) — sets `PhysicsObj = obj`, propagates to all three sub-managers if non-null (each gets its own `SetPhysicsObject(obj)` call). - `StickTo(uint objectID, float radius, float height)` (L93-99) — if `StickyManager == null`, calls `MakeStickyManager()` (note: goes through the helper, unlike `ConstrainTo`/`InterpolateTo` which inline their own lazy-init), then `StickyManager.StickTo(objectID, radius, height)`. - `StopInterpolating()` (L101-105) — null-guarded forward. - `Unconstrain()` (L107-111) — null-guarded forward to `ConstraintManager.Unconstrain()`. - `Unstick()` (L113-117) — null-guarded forward, but calls `StickyManager.HandleExitWorld()` (NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally calls `ClearTarget()`). - `UseTime()` (L119-129) — per-tick pump: forwards to all three sub-managers' `UseTime()` if non-null, in order Interpolation → Sticky → Constraint (same order as `AdjustOffset`). ### Divergences / ACE adaptations - None visually flagged with comments — this class is pure composition/delegation, symmetric with how a client would implement it. No server-only branches visible. The `StickTo` method routing through `MakeStickyManager()` rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence. - `HandleUpdateTarget` only routing to `StickyManager` (not `ConstraintManager` or `InterpolationManager`) matches the design: constraint following is by static target position/radius set once via `ConstrainTo`, not from live target updates; only sticky-follow needs live target position updates. --- ## 3. StickyManager.cs (namespace `ACE.Server.Physics.Animation`) ### Fields | Field | Type | Initial value | |---|---|---| | `TargetID` | `uint` | 0 | | `TargetRadius` | `float` | 0 | | `TargetPosition` | `Position` | null | | `PhysicsObj` | `PhysicsObj` | null | | `Initialized` | `bool` | false | | `StickyTimeoutTime` | `double` | 0 | | `StickyRadius` | `const float` | **0.3f** (L20) | | `StickyTime` | `const float` | **1.0f** (L22) | ### Constructors - `StickyManager()` — default. - `StickyManager(PhysicsObj obj)` (L28-31) — calls `SetPhysicsObject(obj)`. ### Methods - `ClearTarget()` (L33-42) — early-return if `TargetID == 0`. Else: `TargetID = 0`, `Initialized = false`, then `PhysicsObj.clear_target()` and `PhysicsObj.cancel_moveto()`. - `static Create(PhysicsObj obj)` (L44-47) — factory. - `HandleExitWorld()` (L49-52) — calls `ClearTarget()`. - `HandleUpdateTarget(TargetInfo targetInfo)` (L54-66) — guards `targetInfo.ObjectID != TargetID` → return (ignore stale/foreign updates). If `targetInfo.Status == TargetStatus.OK` → `Initialized = true`, `TargetPosition = targetInfo.TargetPosition`. Else if `TargetID != 0` → `ClearTarget()` (i.e., any non-OK status for our current target clears it). - `SetPhysicsObject(PhysicsObj obj)` (L68-71) — trivial setter. - **`StickTo(uint objectID, float targetRadius, float targetHeight)`** (L73-83) — if already targeting something (`TargetID != 0`), first `ClearTarget()`. Then: - `TargetID = objectID` - `Initialized = false` - `TargetRadius = targetRadius` - `StickyTimeoutTime = PhysicsTimer.CurrentTime + StickyTime` (i.e., `now + 1.0f`) - `PhysicsObj.set_target(0, objectID, 0.5f, 0.5f)` — registers with the target-tracking system using **hard-coded radius=0.5f, quantum=0.5f** regardless of the `targetRadius`/`targetHeight` params passed in. **`targetHeight` parameter is accepted but never used anywhere in this method or class.** - `UseTime()` (L85-89) — if `PhysicsTimer.CurrentTime > StickyTimeoutTime` → `ClearTarget()`. This is the sticky-target watchdog: if no target update refreshes `Initialized`/position before the 1-second timeout, drop the stick. (Note: nothing in this file resets `StickyTimeoutTime` on `HandleUpdateTarget` — it's set once in `StickTo` and never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a fresh `StickTo` call. This looks intentional given no other write-site exists.) - **`adjust_offset(AFrame offset, double quantum)`** (L91-133) — **the requested sticky-position math.** 1. Guard: `PhysicsObj == null || TargetID == 0 || !Initialized` → return (no-op if not ready). 2. `target = PhysicsObj.GetObjectA(TargetID)` — resolve live object if in scope; `targetPosition = target == null ? TargetPosition : target.Position` (falls back to last-known cached `TargetPosition` if target object isn't locally resolvable — e.g., out of landblock range). 3. **Offset vector (world → local, flattened to XY):** ``` offset.Origin = PhysicsObj.Position.GetOffset(targetPosition); offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin); offset.Origin.Z = 0.0f; ``` i.e., compute the world-space vector from self to target, rotate it into the object's own local frame, then zero the vertical component — sticky-follow only steers horizontally. 4. **Distance computation:** ``` var radius = PhysicsObj.GetRadius(); var dist = Position.CylinderDistanceNoZ(radius, PhysicsObj.Position, TargetRadius, targetPosition) - StickyRadius; ``` `CylinderDistanceNoZ` = surface-to-surface horizontal distance between two cylinders (self radius vs `TargetRadius`), then subtract the **0.3f StickyRadius** constant — this yields how far past the "stick zone" (0.3m gap) the follower currently is; can be negative if already inside the desired gap. 5. **Normalize direction** (with small-vector guard): ``` if (Vec.NormalizeCheckSmall(ref offset.Origin)) offset.Origin = Vector3.Zero; ``` `NormalizeCheckSmall` normalizes in place and returns true if the vector was too small to normalize meaningfully (near-zero) — in that case zero it out entirely (don't chase jitter at near-zero range). 6. **Speed selection:** ``` var speed = 0.0f; var minterp = PhysicsObj.get_minterp(); if (minterp != null) speed = minterp.get_max_speed() * 5.0f; if (speed < PhysicsGlobals.EPSILON) speed = 15.0f; ``` Sticky-follow speed is **5× the object's own max movement speed** (so the follow catches up faster than normal walk/run speed would allow), falling back to a **hard-coded 15.0f** if no motion interpreter is available or computed speed is ~0. 7. **Delta-clamp to distance:** ``` var delta = speed * (float)quantum; if (delta >= Math.Abs(dist)) delta = dist; offset.Origin *= delta; ``` Standard "don't overshoot" clamp: proposed per-quantum step is `speed * quantum`; if that step would travel farther than the remaining `dist` (in absolute value), snap the step to exactly `dist` instead (this can produce a *negative* delta scaling — meaning the offset direction gets inverted/scaled backward — when `dist` is negative, i.e., when already past the 0.3m sticky radius and needing to back off). 8. **Heading alignment:** ``` var curHeading = PhysicsObj.Position.Frame.get_heading(); var targetHeading = PhysicsObj.Position.heading(targetPosition); var heading = targetHeading - curHeading; if (Math.Abs(heading) < PhysicsGlobals.EPSILON) heading = 0.0f; if (heading < -PhysicsGlobals.EPSILON) heading += 360.0f; offset.set_heading(heading); ``` Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrapping `+360` (note: this wrap only triggers for deltas below `-EPSILON`, not a full `[-180,180]` normalize — deltas in e.g. `(-360, -epsilon)` all get `+360` added once, which is only a correct wrap if `heading` is already constrained to `(-360, 360)` by the subtraction of two `[0,360)` headings, which it is). Result is written into `offset.set_heading(heading)` — i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent with `offset` being a per-tick delta-frame consumed elsewhere, likely integrated by the caller). - Dead commented-out diagnostic: `//Console.WriteLine($"StickyManager.AdjustOffset(...)")` (L131). ### Divergences / ACE adaptations 1. **`targetHeight` parameter of `StickTo` is entirely unused** — accepted into the signature (matches the client API surface presumably) but never read. Could be a client-only positional-height computation retail uses that ACE's server-authoritative model doesn't need (server just re-derives Z from the physics/terrain resolve, not from a fixed offset height). 2. **`set_target(0, objectID, 0.5f, 0.5f)` hard-codes context=0, radius=0.5f, quantum=0.5f** — the `targetRadius` argument passed into `StickTo` is stored in `TargetRadius` for use in `adjust_offset`'s distance math, but is NOT what's passed to `set_target`'s tracking-radius parameter; that's a separate fixed 0.5f. This looks like two distinct radii serving different purposes (voyeur/update-triggering radius vs. desired-follow-gap radius) rather than a bug, but it's worth flagging as a spot to verify against the retail decomp — ACE naming makes them look conflatable. 3. No explicit "ACE custom" comments in this file — the divergence is purely inferred from unused-parameter/hardcoded-constant patterns, not documented in-line. --- ## 4. ConstraintManager.cs (namespace `ACE.Server.Physics.Animation`) ### Fields | Field | Type | Initial value | |---|---|---| | `PhysicsObj` | `PhysicsObj` | null | | `IsConstrained` | `bool` | false | | `ConstraintPosOffset` | `float` | 0 | | `ConstraintPos` | `Position` | null | | `ConstraintDistanceStart` | `float` | 0 | | `ConstraintDistanceMax` | `float` | 0 | ### Constructors - `ConstraintManager()` — default. - `ConstraintManager(PhysicsObj obj)` (L17-20) — calls `SetPhysicsObject(obj)`. ### Methods - `static Create(PhysicsObj obj)` (L22-25) — factory. - `ConstrainTo(Position position, float startDistance, float maxDistance)` (L27-35) — sets `IsConstrained = true`; `ConstraintPos = new Position(position)` (deep copy); `ConstraintDistanceStart = startDistance`; `ConstraintDistanceMax = maxDistance`; **`ConstraintPosOffset = position.Distance(PhysicsObj.Position)`** — i.e., initializes the tracked offset to the CURRENT straight-line distance between the constraint anchor and the object's live position at the moment constraining begins (not zero). - `IsFullyConstrained()` (L37-40) — `return ConstraintDistanceMax * 0.9f < ConstraintPosOffset;` — **"fully constrained" means the object's tracked offset has exceeded 90% of the max allowed distance.** This is a soft/early trigger, not requiring the offset to hit 100% of max. - `SetPhysicsObject(PhysicsObj obj)` (L42-50) — if `PhysicsObj != null` (i.e., there was a previous object), reset `IsConstrained = false` and `ConstraintPosOffset = 0.0f` BEFORE reassigning `PhysicsObj = obj`. Net effect: constraint state is cleared whenever the manager is rebound to a (possibly different, possibly the same) physics object — except on the very first bind where `PhysicsObj` starts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway). - `Unconstrain()` (L52-55) — `IsConstrained = false` only (does NOT reset `ConstraintPosOffset`, `ConstraintPos`, `ConstraintDistanceStart/Max` — those linger stale until the next `ConstrainTo` call). - `UseTime()` (L57-60) — **empty body**, comment `// empty`. No time-based ticking logic at all in this manager (unlike Sticky's timeout-watchdog). - **`adjust_offset(AFrame offset, double quantum)`** (L62-77) — **the requested constraint spring math.** 1. Guard: `PhysicsObj == null || !IsConstrained` → return. 2. **Contact-gated branch:** ``` if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)) { if (ConstraintPosOffset < ConstraintDistanceMax) { if (ConstraintPosOffset > ConstraintDistanceStart) offset.Origin *= (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart); } else offset.Origin = Vector3.Zero; } ``` The scaling logic **only runs when the object is in ground/surface contact** (`TransientStateFlags.Contact`) — while airborne, this whole inner block is skipped and `offset.Origin` passes through completely unmodified from whatever `StickyManager`/`InterpolationManager` already wrote into it (constraint has no effect while not in contact). When in contact: - If current offset (`ConstraintPosOffset`, tracked from the PREVIOUS tick's final value — see step 3) is **≥ ConstraintDistanceMax** → hard-clamp: `offset.Origin = Vector3.Zero` (object cannot move further this tick at all — fully pinned). - Else if offset is **< ConstraintDistanceMax**: - If offset is **also > ConstraintDistanceStart** (i.e., in the "braking zone" between start and max) → scale the incoming `offset.Origin` (the proposed displacement already computed by prior pipeline stages) by the **linear falloff factor**: ``` (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart) ``` This ranges from ~1.0 (when `ConstraintPosOffset` is just past `ConstraintDistanceStart`) down toward 0.0 (as `ConstraintPosOffset` approaches `ConstraintDistanceMax`) — a **linear spring/brake taper** that progressively resists outward motion as the object nears its max leash distance. - If offset is **≤ ConstraintDistanceStart** (still well within the free-movement zone) → no scaling at all, `offset.Origin` passes through unchanged. 3. **State update (runs unconditionally, both when in contact and when airborne):** ``` ConstraintPosOffset = offset.Origin.Length(); ``` **This line is outside the `if (Contact)` block** — meaning `ConstraintPosOffset` is recomputed EVERY call from the magnitude of the (possibly just-scaled) `offset.Origin`, not from the actual distance to `ConstraintPos`/anchor. This is notable: the tracked "offset" is a proxy — the length of the per-tick displacement vector — not a running total distance from the constraint anchor; it's being used as a same-tick feedback value that the NEXT call's contact-branch will compare against Start/Max. Given `AdjustOffset`'s pipeline order (Interpolation → Sticky → Constraint), this length is measuring the magnitude of the net per-tick offset produced by all prior systems, right before constraint clamps it — an odd quantity to call "ConstraintPosOffset" (it looks more like "last tick's step distance" than "distance from anchor"), but that's exactly what the code does — flag this if the acdream port needs to match it precisely; it's easy to misread as "distance from ConstraintPos." ### Divergences / ACE adaptations - No explicit ACE-only comments; the whole file reads as a fairly literal, compact port. The main subtlety (not a divergence, but a correctness trap) is the `ConstraintPosOffset = offset.Origin.Length()` semantics noted above — this should be triple-checked against retail decomp before the acdream port trusts the "distance from constraint anchor" mental model that the field name suggests. `ConstraintPos` (the actual anchor position, L11) is stored but **never read anywhere in this file** after being set in `ConstrainTo` — it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere using `ConstraintPos`, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read of `PositionManager`/`ConstraintManager.ConstraintPos` — none found in this file. --- ## 5. TargetManager.cs (namespace `ACE.Server.Physics.Combat`) Note: this one lives in the `Combat` namespace, not `Animation`, unlike the other four. ### Fields | Field | Type | Initial value | |---|---|---| | `PhysicsObj` | `PhysicsObj` | null | | `TargetInfo` | `TargetInfo` | null | | `VoyeurTable` | `Dictionary` | null (lazily created in `AddVoyeur`) | | `LastUpdateTime` | `double` | 0 | ### Constructors - `TargetManager()` — default. - `TargetManager(PhysicsObj physObj)` (L18-21) — sets `PhysicsObj = physObj` directly (no `SetPhysicsObject` helper here, unlike the other four classes). ### Methods - `SetTarget(uint contextID, uint objectID, float radius, double quantum)` (L23-42) — null-guard `PhysicsObj`. Calls `ClearTarget()` first (always clears any existing target before setting a new one — no early-return-if-same-target check). If `objectID == 0` (clear/cancel request): builds a `TargetInfo` with `Status = TargetStatus.TimedOut` and calls `PhysicsObj.HandleUpdateTarget(failedTargetInfo)` directly, then returns (does NOT set `TargetInfo` field — leaves it null, i.e., "targeting nothing" is represented by `TargetInfo == null`). Otherwise: `TargetInfo = new TargetInfo(contextID, objectID, radius, quantum)`; resolves `target = PhysicsObj.GetObjectA(objectID)`; if resolvable, calls `target.add_voyeur(PhysicsObj.ID, radius, quantum)` — i.e., registers itself as a voyeur ON the target object (bidirectional relationship: I track it, and it tracks me watching it). - `SetTargetQuantum(double quantum)` (L44-54) — null-guards `PhysicsObj`/`TargetInfo`. Updates `TargetInfo.Quantum = quantum`, resolves the target object, and if found, re-registers via `targetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)` (refreshes the voyeur registration on the target with the new quantum, keeping the stored `Radius`). - **`HandleTargetting()`** (L56-79) — **the requested quantum-scheduling dispatcher** (note British-double-t spelling matches the ACE source literally). Guards: 1. `PhysicsObj == null` → return. 2. **Throttle: `PhysicsTimer.CurrentTime - LastUpdateTime < 0.5f` → return.** This whole method only actually runs its body once every **0.5 seconds of wall clock**, regardless of how often it's called (looks like it's called every physics tick from elsewhere, and self-throttles). 3. If `TargetInfo != null && TargetInfo.TargetPosition == null` → return (target info exists but has no resolved position yet — commented-out diagnostic `//Console.WriteLine("...null position")` at L64). 4. **Timeout check:** if `TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime` → mark `TargetInfo.Status = TargetStatus.TimedOut` and call `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` (passes a COPY, not the live reference — comment `// ref?` at L71 suggests the ACE porter was unsure whether retail passes by value or reference here). **10-second target-info staleness timeout**, separate from `StickyManager`'s 1-second timeout — these are two independently-tuned quanta for two different subsystems. 5. **Voyeur sweep:** if `VoyeurTable != null`, iterate `VoyeurTable.Values.ToList()` (materializes a copy of the values to iterate safely against in-loop mutation) and call `CheckAndUpdateVoyeur(voyeur)` for each. 6. Finally, `LastUpdateTime = PhysicsTimer.CurrentTime` — resets the 0.5s throttle window. - `CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)` (L81-89) — **the requested per-voyeur quantum check.** `newPos = GetInterpolatedPosition(voyeur.Quantum)`; if non-null and `newPos.Distance(voyeur.LastSentPosition) > voyeur.Radius` → `SendVoyeurUpdate(voyeur, newPos, TargetStatus.OK)`. I.e., only pushes an update to a voyeur if the tracked object has moved farther than that voyeur's registered `Radius` threshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast. - `GetInterpolatedPosition(double quantum)` (L91-98) — null-guards `PhysicsObj`. `pos = new Position(PhysicsObj.Position)` (deep copy); `pos.Frame.Origin += PhysicsObj.get_velocity() * (float)quantum` — **dead-reckoning extrapolation**: current position plus velocity times the requested lookahead quantum. This is the same "quantum" concept used throughout — it's a forward-prediction time horizon, not a physics tick delta. - `ClearTarget()` (L100-111) — if `TargetInfo == null` → return. Else resolves the CURRENT target object and calls `targetObj.remove_voyeur(PhysicsObj.ID)` (un-registers self as a voyeur on the old target) — note this call is unconditional on `targetObj != null` check (guarded) but NOT unconditional on whether `TargetInfo.ObjectID` was actually valid; then sets `TargetInfo = null` (redundant inner null-check `if (TargetInfo != null)` immediately after already having checked `TargetInfo == null` at entry — harmless dead conditional, definitely true at that point). - `NotifyVoyeurOfEvent(TargetStatus status)` (L113-119) — null-guards `PhysicsObj`/`VoyeurTable`. Broadcasts `SendVoyeurUpdate(voyeur, PhysicsObj.Position, status)` to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlike `CheckAndUpdateVoyeur`) — used for discrete events (e.g., death, teleport) rather than routine movement polling. - **`ReceiveUpdate(TargetInfo update)`** (L121-136) — **the requested inbound-update handler** (the other half of the "ReceiveUpdate/CheckAndUpdateVoyeur" pair requested). Guards: `PhysicsObj == null || TargetInfo == null || TargetInfo.ObjectID != update.ObjectID` → return (ignore updates for a target we're not currently tracking, or if we have no target at all). - `TargetInfo = new TargetInfo(update)` (copy-construct from the incoming update — comment `// ref?` again at L125, same porter uncertainty). - `TargetInfo.LastUpdateTime = PhysicsTimer.CurrentTime` — stamps receipt time (used later by the 10-second-timeout check in `HandleTargetting`). - **Heading computation:** ``` TargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition); if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading)) TargetInfo.InterpolatedHeading = Vector3.UnitZ; ``` Computes the direction vector from self to the target's (already-interpolated-by-sender) position, normalizes it in place, and if the vector was too small to normalize (near-coincident positions), falls back to `Vector3.UnitZ` (a fixed "up" vector as a degenerate-case default — presumably any non-zero placeholder direction is acceptable when the target is essentially on top of the observer). - `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` — forwards a COPY of the now-updated `TargetInfo` to the owning `PhysicsObj` (which presumably routes it onward to `PositionManager.HandleUpdateTarget` → `StickyManager.HandleUpdateTarget`, wiring back to file #3 above). - If `update.Status == TargetStatus.ExitWorld` → `ClearTarget()` (target left the world; stop tracking it entirely, in addition to whatever `HandleUpdateTarget` propagation already did). - `AddVoyeur(uint objectID, float radius, double quantum)` (L138-157) — if `VoyeurTable != null`, try to find an existing entry for `objectID`; if found, just update its `Radius`/`Quantum` in place and return early (no duplicate entries, no re-send of an initial update on refresh). Else (table doesn't exist yet), `VoyeurTable = new Dictionary<...>()`. Creates a new `TargettedVoyeurInfo(objectID, radius, quantum)`, adds it to the table, and **immediately** calls `SendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)` — new voyeurs get an immediate initial position push (not gated by the distance threshold that `CheckAndUpdateVoyeur` applies for routine updates). - `SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)` (L159-172) — sets `voyeur.LastSentPosition = new Position(pos)` (deep copy, records what was just sent for future delta-threshold comparisons — this is what `CheckAndUpdateVoyeur` compares `newPos.Distance(...)` against). Builds an outbound `TargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)` (contextID hard-coded to 0), sets `TargetPosition = PhysicsObj.Position` (current authoritative position), `InterpolatedPosition = new Position(pos)` (the possibly-extrapolated position that triggered/represents this update), `Velocity = PhysicsObj.get_velocity()`, `Status = status`. Resolves the voyeur's own physics object (`GetObjectA(voyeur.ObjectID)`) and if found, calls `voyeurObj.receive_target_update(info)` — this is the reverse-direction hop that presumably lands back in the voyeur's own `TargetManager.ReceiveUpdate`. - `RemoveVoyeur(uint objectID)` (L174-179) — `VoyeurTable == null` → `false`, else `VoyeurTable.Remove(objectID)` (dictionary's own bool-return removal). ### Divergences / ACE adaptations 1. **Two independent "// ref?" comments** (L71, L125) mark spots where the ACE porter was unsure whether retail passes `TargetInfo` by reference or performs a defensive copy — ACE chose copy-construct (`new TargetInfo(...)`) in both cases as the conservative/safe choice for a multi-threaded server. This is the clearest **explicit uncertainty marker** in the whole set of five files — flag as a spot acdream should verify against the retail decomp directly rather than trust ACE's guess, since ACE itself flagged its own uncertainty. 2. **The voyeur pattern is inherently server-authoritative plumbing** — `SendVoyeurUpdate`/`receive_target_update`/bidirectional `add_voyeur`/`remove_voyeur` registration between two `PhysicsObj`s is exactly the kind of "who-is-watching-whom" bookkeeping a server needs to decide what to push to which client-controlled object, whereas a retail single-player-perspective client would only need to track ITS OWN target, not maintain a reverse table of watchers. This entire voyeur/broadcast half of the class is very plausibly ACE-server infrastructure sitting alongside a more literally-ported "my own target" half (`SetTarget`/`ReceiveUpdate`/`HandleTargetting`'s timeout logic). No comment marks this explicitly, but structurally it's the load-bearing "adaptation" in this file — acdream (a client) likely needs ONLY the target-tracking half (`SetTarget`, `ReceiveUpdate`, the 10s timeout, `GetInterpolatedPosition`), not the voyeur/broadcast half, UNLESS acdream's server-facing code also needs to originate voyeur registrations (unlikely for a pure client). 3. `HandleTargetting`'s two independently-tuned timing constants — **0.5f throttle** (L60) and **10.0f staleness timeout** (L68) — are worth citing precisely if porting; these are likely real retail-tuned quanta, not ACE inventions, but should be cross-checked against `docs/research/named-retail/` before treating as ground truth given the file's own admitted uncertainty elsewhere. 4. Namespace: this class lives under `ACE.Server.Physics.Combat`, while all four other classes here live under `ACE.Server.Physics.Animation` — matches retail's likely split (targeting/voyeur being combat/perception machinery vs. movement/position being animation machinery), not an ACE-specific reorganization, but worth preserving that namespace distinction in acdream's own port structure. --- ## Cross-class composition notes (for the R5 MovementManager/PositionManager facade work) - **Dispatch chain confirmed:** `MovementManager.PerformMovement` branches by `MovementStruct.Type` into either `MotionInterp.PerformMovement` (raw/interpreted commands) or `MoveToManager.PerformMovement` (MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file. - **`MovementManager.UseTime()` only pumps `MoveToManager`** — `MotionInterp` presumably ticks via a different call site (not shown in these 5 files) — do not assume `MovementManager.UseTime` is the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained to `MoveToManager`-adjacent code, and this file confirms `MoveToManager.UseTime()` is exactly one call, unconditioned, from `MovementManager.UseTime()`. - **`PositionManager.AdjustOffset` and `PositionManager.UseTime` share the identical fixed pipeline order**: Interpolation → Sticky → Constraint. Any acdream port of `PositionManager` MUST preserve this order — `ConstraintManager.adjust_offset`'s scaling operates on the ALREADY-composed `offset.Origin` written by the two prior stages (confirmed by reading `ConstraintManager.adjust_offset` itself: it treats incoming `offset.Origin` as pre-populated and only clamps/scales it, never zeroes-then-rebuilds it). - **Quantum is used with (at least) three distinct meanings across these files** — worth flagging for the R5 doc's "quantum model" section: 1. `AdjustOffset(AFrame frame, double quantum)` — a per-tick **time delta** (seconds since last adjustment), used identically by all three sub-adjusters as a `speed * quantum = distance` integration step. 2. `TargetInfo.Quantum` / `TargettedVoyeurInfo.Quantum` / `SetTarget(..., double quantum)` — a **lookahead/extrapolation horizon** fed into `GetInterpolatedPosition(quantum)` for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta). 3. Implicit "throttle interval" constants (`0.5f` in `HandleTargetting`, `1.0f` `StickyTime`, `10.0f` staleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table.