acdream/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md
Erik 3d89446d98 feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)
The retail movement-manager family the R4 MoveToManager port left as
do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's
PositionManager facade + StickyManager + ConstraintManager + the
TargetManager voyeur system, with full conformance tests. NO wiring yet
— purely additive, no behavior change. Wiring (retiring TS-39 sticky +
AP-79 target adapter) is R5-V2/V3.

New Core classes (src/AcDream.Core/Physics/Motion/):
- StickyManager (0x00555400): follow-a-target steering. adjust_offset's
  dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0,
  follow speed ×5 / fallback 15) — speed-clamped signed-distance steer +
  bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown.
- ConstraintManager (0x00556090): the server-position rubber-band leash.
  90% IsFullyConstrained jump gate + grounded linear brake taper.
  Structural only — acdream never ARMS it (retail arms from
  SmartBox::HandleReceivedPosition, which acdream lacks, with two x87
  constants BN elided). IsFullyConstrained stays false = TS-35 behavior;
  leash-arming + the unknown constants are a deferred issue.
- PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out.
- TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer
  voyeur subscription system (0.5 s throttle, 10 s staleness,
  send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A
  faithful superset of the AP-79 adapter — SetTarget subscribes ON the
  target; the target's HandleTargetting pushes updates back.
- IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/
  radius/contact/GetObjectA + target-tracking fan-out) the App wires per
  entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator.

Supporting:
- TargetInfo extended to the full retail 10-field struct (additive
  defaults keep the R4 4-arg call sites compiling).
- MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall,
  GlobalToLocalVec.
- Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote
  anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion
  Combiner, freeing the name and removing the ambiguity that breaks every
  file importing both Physics + Physics.Motion (GameWindow will in V2/V3).

Tests: 42 new conformance cases (Sticky/Constraint/Position facade +
TargetManager incl. the full cross-entity voyeur round-trip). Full suite
4006 green (+2 skipped), no regressions.

Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:34:49 +02:00

332 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<uint, TargettedVoyeurInfo>` | 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.