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

39 KiB
Raw Blame History

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 == nullfalse, 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).
      • defaultreturn 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 == nullfalse, 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 motionparameter inMotionDone`** — 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 == null0, 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 == nullfalse, 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.OKInitialized = true, TargetPosition = targetInfo.TargetPosition. Else if TargetID != 0ClearTarget() (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 > StickyTimeoutTimeClearTarget(). 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.RadiusSendVoyeurUpdate(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)quantumdead-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.HandleUpdateTargetStickyManager.HandleUpdateTarget, wiring back to file #3 above).
    • If update.Status == TargetStatus.ExitWorldClearTarget() (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 == nullfalse, 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 plumbingSendVoyeurUpdate/receive_target_update/bidirectional add_voyeur/remove_voyeur registration between two PhysicsObjs 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 MoveToManagerMotionInterp 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.