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>
39 KiB
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) — setsPhysicsObj/WeenieObj, eagerly constructs BOTHMotionInterpreter = new MotionInterp(obj, wobj)andMoveToManager = 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-guardsMoveToManager, forwards toMoveToManager.CancelMoveTo(error).static Create(PhysicsObj obj, WeenieObject wobj)(L33-36) — factory,return new MovementManager(obj, wobj).EnterDefaultState()(L38-46) — early-returns ifPhysicsObj == null. Lazy-initsMotionInterpreterviaMotionInterp.Create(PhysicsObj, WeenieObj)if null, then callsMotionInterpreter.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 toMotionInterpreter.HandleExitWorld(). Does NOT touchMoveToManager(noMoveToManager.HandleExitWorldcall exists in this class at all).HandleUpdateTarget(TargetInfo targetInfo)(L60-64) — null-guardsMoveToManager, forwards toMoveToManager.HandleUpdateTarget(targetInfo). Only route into MoveToManager for target updates — MotionInterpreter is not involved.HitGround()(L66-73) — forwards to BOTHMotionInterpreter.HitGround()ANDMoveToManager.HitGround(), each separately null-guarded. Order: MotionInterpreter first, then MoveToManager.InqInterpretedMotionState()(L75-84) — lazy-init pattern (create +enter_default_state()ifPhysicsObj != null), returnsMotionInterpreter.InterpretedState.InqRawMotionState()(L86-95) — identical lazy-init pattern, returnsMotionInterpreter.RawState.IsMovingTo()(L97-102) —MoveToManager == null→false, elseMoveToManager.is_moving_to().LeaveGround()(L104-110) — null-guarded forward toMotionInterpreter.LeaveGround()only. Comment// NoticeHandler::RecvNotice_PrevSpellSection— dead/no-op reference, no MoveToManager call (asymmetric vsHitGround).MakeMoveToManager()(L112-116) — ifMoveToManager == null,MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj).MotionDone(uint motion, bool success)(L118-122) — null-guarded forward toMotionInterpreter.MotionDone(success). Note: themotionparameter is accepted but never used/passed through — onlysuccessreachesMotionInterp.MotionDone.PerformMovement(MovementStruct mvs)(L124-157) — dispatch hub:PhysicsObj.set_active(true)unconditionally first.switch (mvs.Type):RawCommand,InterpretedCommand,StopRawCommand,StopInterpretedCommand,StopCompletely→ lazy-initMotionInterpreter(create + enter_default_state), thenreturn MotionInterpreter.PerformMovement(mvs).MoveToObject,MoveToPosition,TurnToObject,TurnToHeading→ lazy-initMoveToManager(create only, no default-state analog), thenreturn 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 toMotionInterpreter.ReportExhaustion(). Comment// NoticeHandler::RecvNotice_PrevSpellSelectionagain (dead).SetWeenieObject(WeenieObject wobj)(L167-174) — setsWeenieObj = wobj, then propagates to both children if non-null:MotionInterpreter.SetWeenieObject(wobj),MoveToManager.SetWeenieObject(wobj).UseTime()(L176-179) — only forwards toMoveToManager.UseTime(). Does NOT call anything onMotionInterpreter. 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 toInqInterpretedMotionState, returnsMotionInterpreteritself (not a sub-state).motions_pending()(L195-200) —MotionInterpreter == null→false, elseMotionInterpreter.motions_pending(). Doc comment recommendsPhysicsObj.IsAnimatinginstead for perf (L192-194).move_to_interpreted_state(InterpretedMotionState state)(L202-211) — lazy-init pattern, thenMotionInterpreter.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
unpack_movementis 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."HandleEnterWorldis 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.unusedmotionparameter inMotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side.- Uses
ACE.Entity.Enum.WeenieError(ACE server type) as the return type forPerformMovement/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). - 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) — callsSetPhysicsObject(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:InterpolationManager.adjust_offset(frame, quantum)StickyManager.adjust_offset(frame, quantum)ConstraintManager.adjust_offset(frame, quantum)
All three mutate the SAME
frame(anAFrame, passed by reference since it's a class/struct with mutableOrigin) sequentially — each sub-adjustment composes on top of whatever the previous one wrote intoframe.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 becauseConstraintManager.adjust_offsetscales/clampsoffset.Originbased on what's already accumulated (it readsoffset.Origin.Length()at the end to updateConstraintPosOffset— 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-initsConstraintManagerviaConstraintManager.Create(PhysicsObj)if null, forwards args toConstraintManager.ConstrainTo(...). -
static Create(PhysicsObj physicsObj)(L38-41) — factory. -
GetStickyObjectID()(L43-47) —StickyManager == null→0, elseStickyManager.TargetID. -
HandleUpdateTarget(TargetInfo targetInfo)(L49-53) — null-guarded forward toStickyManager.HandleUpdateTarget(targetInfo)only (Constraint/Interpolation don't participate in target updates). -
InterpolateTo(Position position, bool keepHeading)(L55-61) — lazy-initsInterpolationManagerviaInterpolationManager.Create(PhysicsObj), forwards toInterpolationManager.InterpolateTo(position, keepHeading). -
IsFullyConstrained()(L63-69) —ConstraintManager == null→false, elseConstraintManager.IsFullyConstrained(). -
IsInterpolating()(L71-74) —InterpolationManager != null && InterpolationManager.IsInterpolating(). -
MakeStickyManager()(L76-80) — ifStickyManager == null,StickyManager = StickyManager.Create(PhysicsObj). -
SetPhysicsObject(PhysicsObj obj)(L82-91) — setsPhysicsObj = obj, propagates to all three sub-managers if non-null (each gets its ownSetPhysicsObject(obj)call). -
StickTo(uint objectID, float radius, float height)(L93-99) — ifStickyManager == null, callsMakeStickyManager()(note: goes through the helper, unlikeConstrainTo/InterpolateTowhich inline their own lazy-init), thenStickyManager.StickTo(objectID, radius, height). -
StopInterpolating()(L101-105) — null-guarded forward. -
Unconstrain()(L107-111) — null-guarded forward toConstraintManager.Unconstrain(). -
Unstick()(L113-117) — null-guarded forward, but callsStickyManager.HandleExitWorld()(NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally callsClearTarget()). -
UseTime()(L119-129) — per-tick pump: forwards to all three sub-managers'UseTime()if non-null, in order Interpolation → Sticky → Constraint (same order asAdjustOffset).
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
StickTomethod routing throughMakeStickyManager()rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence. HandleUpdateTargetonly routing toStickyManager(notConstraintManagerorInterpolationManager) matches the design: constraint following is by static target position/radius set once viaConstrainTo, 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) — callsSetPhysicsObject(obj).
Methods
-
ClearTarget()(L33-42) — early-return ifTargetID == 0. Else:TargetID = 0,Initialized = false, thenPhysicsObj.clear_target()andPhysicsObj.cancel_moveto(). -
static Create(PhysicsObj obj)(L44-47) — factory. -
HandleExitWorld()(L49-52) — callsClearTarget(). -
HandleUpdateTarget(TargetInfo targetInfo)(L54-66) — guardstargetInfo.ObjectID != TargetID→ return (ignore stale/foreign updates). IftargetInfo.Status == TargetStatus.OK→Initialized = true,TargetPosition = targetInfo.TargetPosition. Else ifTargetID != 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), firstClearTarget(). Then:TargetID = objectIDInitialized = falseTargetRadius = targetRadiusStickyTimeoutTime = 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 thetargetRadius/targetHeightparams passed in.targetHeightparameter is accepted but never used anywhere in this method or class.
-
UseTime()(L85-89) — ifPhysicsTimer.CurrentTime > StickyTimeoutTime→ClearTarget(). This is the sticky-target watchdog: if no target update refreshesInitialized/position before the 1-second timeout, drop the stick. (Note: nothing in this file resetsStickyTimeoutTimeonHandleUpdateTarget— it's set once inStickToand never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a freshStickTocall. This looks intentional given no other write-site exists.) -
adjust_offset(AFrame offset, double quantum)(L91-133) — the requested sticky-position math.- Guard:
PhysicsObj == null || TargetID == 0 || !Initialized→ return (no-op if not ready). target = PhysicsObj.GetObjectA(TargetID)— resolve live object if in scope;targetPosition = target == null ? TargetPosition : target.Position(falls back to last-known cachedTargetPositionif target object isn't locally resolvable — e.g., out of landblock range).- Offset vector (world → local, flattened to XY):
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.offset.Origin = PhysicsObj.Position.GetOffset(targetPosition); offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin); offset.Origin.Z = 0.0f; - 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 vsTargetRadius), 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. - Normalize direction (with small-vector guard):
if (Vec.NormalizeCheckSmall(ref offset.Origin)) offset.Origin = Vector3.Zero;NormalizeCheckSmallnormalizes 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). - Speed selection:
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.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; - Delta-clamp to distance:
Standard "don't overshoot" clamp: proposed per-quantum step isvar delta = speed * (float)quantum; if (delta >= Math.Abs(dist)) delta = dist; offset.Origin *= delta;speed * quantum; if that step would travel farther than the remainingdist(in absolute value), snap the step to exactlydistinstead (this can produce a negative delta scaling — meaning the offset direction gets inverted/scaled backward — whendistis negative, i.e., when already past the 0.3m sticky radius and needing to back off). - Heading alignment:
Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrappingvar 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);+360(note: this wrap only triggers for deltas below-EPSILON, not a full[-180,180]normalize — deltas in e.g.(-360, -epsilon)all get+360added once, which is only a correct wrap ifheadingis already constrained to(-360, 360)by the subtraction of two[0,360)headings, which it is). Result is written intooffset.set_heading(heading)— i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent withoffsetbeing a per-tick delta-frame consumed elsewhere, likely integrated by the caller).
- Dead commented-out diagnostic:
//Console.WriteLine($"StickyManager.AdjustOffset(...)")(L131).
- Guard:
Divergences / ACE adaptations
targetHeightparameter ofStickTois 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).set_target(0, objectID, 0.5f, 0.5f)hard-codes context=0, radius=0.5f, quantum=0.5f — thetargetRadiusargument passed intoStickTois stored inTargetRadiusfor use inadjust_offset's distance math, but is NOT what's passed toset_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.- 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) — callsSetPhysicsObject(obj).
Methods
-
static Create(PhysicsObj obj)(L22-25) — factory. -
ConstrainTo(Position position, float startDistance, float maxDistance)(L27-35) — setsIsConstrained = 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) — ifPhysicsObj != null(i.e., there was a previous object), resetIsConstrained = falseandConstraintPosOffset = 0.0fBEFORE reassigningPhysicsObj = 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 wherePhysicsObjstarts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway). -
Unconstrain()(L52-55) —IsConstrained = falseonly (does NOT resetConstraintPosOffset,ConstraintPos,ConstraintDistanceStart/Max— those linger stale until the nextConstrainTocall). -
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.-
Guard:
PhysicsObj == null || !IsConstrained→ return. -
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 andoffset.Originpasses through completely unmodified from whateverStickyManager/InterpolationManageralready 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:
This ranges from ~1.0 (when(ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart)ConstraintPosOffsetis just pastConstraintDistanceStart) down toward 0.0 (asConstraintPosOffsetapproachesConstraintDistanceMax) — 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.Originpasses through unchanged.
- If offset is also > ConstraintDistanceStart (i.e., in the "braking zone" between start and max) → scale the incoming
- If current offset (
-
State update (runs unconditionally, both when in contact and when airborne):
ConstraintPosOffset = offset.Origin.Length();This line is outside the
if (Contact)block — meaningConstraintPosOffsetis recomputed EVERY call from the magnitude of the (possibly just-scaled)offset.Origin, not from the actual distance toConstraintPos/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. GivenAdjustOffset'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 inConstrainTo— it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere usingConstraintPos, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read ofPositionManager/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) — setsPhysicsObj = physObjdirectly (noSetPhysicsObjecthelper here, unlike the other four classes).
Methods
-
SetTarget(uint contextID, uint objectID, float radius, double quantum)(L23-42) — null-guardPhysicsObj. CallsClearTarget()first (always clears any existing target before setting a new one — no early-return-if-same-target check). IfobjectID == 0(clear/cancel request): builds aTargetInfowithStatus = TargetStatus.TimedOutand callsPhysicsObj.HandleUpdateTarget(failedTargetInfo)directly, then returns (does NOT setTargetInfofield — leaves it null, i.e., "targeting nothing" is represented byTargetInfo == null). Otherwise:TargetInfo = new TargetInfo(contextID, objectID, radius, quantum); resolvestarget = PhysicsObj.GetObjectA(objectID); if resolvable, callstarget.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-guardsPhysicsObj/TargetInfo. UpdatesTargetInfo.Quantum = quantum, resolves the target object, and if found, re-registers viatargetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)(refreshes the voyeur registration on the target with the new quantum, keeping the storedRadius). -
HandleTargetting()(L56-79) — the requested quantum-scheduling dispatcher (note British-double-t spelling matches the ACE source literally). Guards:PhysicsObj == null→ return.- 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). - 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). - Timeout check: if
TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime→ markTargetInfo.Status = TargetStatus.TimedOutand callPhysicsObj.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 fromStickyManager's 1-second timeout — these are two independently-tuned quanta for two different subsystems. - Voyeur sweep: if
VoyeurTable != null, iterateVoyeurTable.Values.ToList()(materializes a copy of the values to iterate safely against in-loop mutation) and callCheckAndUpdateVoyeur(voyeur)for each. - 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 andnewPos.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 registeredRadiusthreshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast. -
GetInterpolatedPosition(double quantum)(L91-98) — null-guardsPhysicsObj.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) — ifTargetInfo == null→ return. Else resolves the CURRENT target object and callstargetObj.remove_voyeur(PhysicsObj.ID)(un-registers self as a voyeur on the old target) — note this call is unconditional ontargetObj != nullcheck (guarded) but NOT unconditional on whetherTargetInfo.ObjectIDwas actually valid; then setsTargetInfo = null(redundant inner null-checkif (TargetInfo != null)immediately after already having checkedTargetInfo == nullat entry — harmless dead conditional, definitely true at that point). -
NotifyVoyeurOfEvent(TargetStatus status)(L113-119) — null-guardsPhysicsObj/VoyeurTable. BroadcastsSendVoyeurUpdate(voyeur, PhysicsObj.Position, status)to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlikeCheckAndUpdateVoyeur) — 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 inHandleTargetting).- Heading computation:
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 toTargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition); if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading)) TargetInfo.InterpolatedHeading = Vector3.UnitZ;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-updatedTargetInfoto the owningPhysicsObj(which presumably routes it onward toPositionManager.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 whateverHandleUpdateTargetpropagation already did).
-
AddVoyeur(uint objectID, float radius, double quantum)(L138-157) — ifVoyeurTable != null, try to find an existing entry forobjectID; if found, just update itsRadius/Quantumin 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 newTargettedVoyeurInfo(objectID, radius, quantum), adds it to the table, and immediately callsSendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)— new voyeurs get an immediate initial position push (not gated by the distance threshold thatCheckAndUpdateVoyeurapplies for routine updates). -
SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)(L159-172) — setsvoyeur.LastSentPosition = new Position(pos)(deep copy, records what was just sent for future delta-threshold comparisons — this is whatCheckAndUpdateVoyeurcomparesnewPos.Distance(...)against). Builds an outboundTargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)(contextID hard-coded to 0), setsTargetPosition = 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, callsvoyeurObj.receive_target_update(info)— this is the reverse-direction hop that presumably lands back in the voyeur's ownTargetManager.ReceiveUpdate. -
RemoveVoyeur(uint objectID)(L174-179) —VoyeurTable == null→false, elseVoyeurTable.Remove(objectID)(dictionary's own bool-return removal).
Divergences / ACE adaptations
- Two independent "// ref?" comments (L71, L125) mark spots where the ACE porter was unsure whether retail passes
TargetInfoby 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. - The voyeur pattern is inherently server-authoritative plumbing —
SendVoyeurUpdate/receive_target_update/bidirectionaladd_voyeur/remove_voyeurregistration between twoPhysicsObjs 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). 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 againstdocs/research/named-retail/before treating as ground truth given the file's own admitted uncertainty elsewhere.- Namespace: this class lives under
ACE.Server.Physics.Combat, while all four other classes here live underACE.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.PerformMovementbranches byMovementStruct.Typeinto eitherMotionInterp.PerformMovement(raw/interpreted commands) orMoveToManager.PerformMovement(MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file. MovementManager.UseTime()only pumpsMoveToManager—MotionInterppresumably ticks via a different call site (not shown in these 5 files) — do not assumeMovementManager.UseTimeis the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained toMoveToManager-adjacent code, and this file confirmsMoveToManager.UseTime()is exactly one call, unconditioned, fromMovementManager.UseTime().PositionManager.AdjustOffsetandPositionManager.UseTimeshare the identical fixed pipeline order: Interpolation → Sticky → Constraint. Any acdream port ofPositionManagerMUST preserve this order —ConstraintManager.adjust_offset's scaling operates on the ALREADY-composedoffset.Originwritten by the two prior stages (confirmed by readingConstraintManager.adjust_offsetitself: it treats incomingoffset.Originas 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:
AdjustOffset(AFrame frame, double quantum)— a per-tick time delta (seconds since last adjustment), used identically by all three sub-adjusters as aspeed * quantum = distanceintegration step.TargetInfo.Quantum/TargettedVoyeurInfo.Quantum/SetTarget(..., double quantum)— a lookahead/extrapolation horizon fed intoGetInterpolatedPosition(quantum)for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta).- Implicit "throttle interval" constants (
0.5finHandleTargetting,1.0fStickyTime,10.0fstaleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table.