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>
This commit is contained in:
Erik 2026-07-03 19:34:49 +02:00
parent 517bbfdae4
commit 3d89446d98
25 changed files with 7279 additions and 12 deletions

View file

@ -0,0 +1,272 @@
# R5 seam recon — current acdream integration points MovementManager/PositionManager/TargetManager will replace
Repo root: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
Read-only recon. All line numbers verified against files as of this session (2026-07-03).
---
## 1. MoveToManager seams — `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (1605 lines)
### Ctor-required seam delegates (lines 78-138, ctor 184-222)
All `Action`/`Func` fields, all REQUIRED (non-nullable, thrown in ctor if null):
- `_stopCompletely` (L78) — retail `CPhysicsObj::StopCompletely`
- `_getPosition` (L83) — retail `physics_obj->m_position`
- `_getHeading` (L87) — retail `CPhysicsObj::get_heading`
- `_setHeading` (L94) — retail `CPhysicsObj::set_heading(heading, send)` — the ONE heading snap, fired only from `HandleTurnToHeading` (L1168)
- `_getOwnRadius` (L99), `_getOwnHeight` (L103) — mover's own cylinder dims
- `_contact` (L107) — retail `transient_state & 1` (CONTACT bit) — `UseTime`'s tick gate
- `_isInterpolating` (L112) — retail `CPhysicsObj::IsInterpolating` → PositionManager (R5 body doesn't exist yet)
- `_getVelocity` (L116) — feeds Phase-3 TargetManager quantum retune
- `_getSelfId` (L120) — self-target detection
- `_setTarget` (L126) — retail `CPhysicsObj::set_target` → **TargetManager::SetTarget (R5, call-shape only)**
- `_clearTarget` (L130) — retail `CPhysicsObj::clear_target` → **TargetManager::ClearTarget (R5, call-shape only)**
- `_getTargetQuantum` (L134) / `_setTargetQuantum` (L138) — **TargetManager quantum (R5, call-shape only)**
- `_curTime` (L175, optional param, defaults to a per-call 1/30s stub)
### AP-79 target seams (setTarget/clearTarget)
- `_setTarget(0, TopLevelObjectId, 0.5f, 0.0)` called from `MoveToObject` (L471) and `TurnToObject` (L592) — always context 0, radius 0.5, quantum 0.0.
- `_clearTarget()` called from `CleanUp` (L1499) when `TopLevelObjectId != 0 && MovementTypeState != Invalid`.
- Consumer of the callback: `HandleUpdateTarget(TargetInfo info)` (L1229-1283) — the P4 TargetTracker feed point. Ignores updates for any `info.ObjectId != TopLevelObjectId` (L1237). Two paths: deferred-start (`Initialized==false`, first callback) vs retarget-while-running.
- **Today's producer of `TargetInfo` is NOT a real TargetManager** — it's `GameWindow.TickRemoteMoveTo` (App-layer, §2c below) polling `_entitiesByServerGuid` each tick and manually diffing against `TrackedTargetRadius`.
### TS-39 sticky seams (StickTo/Unstick)
- `public Action? Unstick { get; set; }` (L145) — retail `CPhysicsObj::unstick_from_object`**PositionManager::UnStick (R5 body)**. Called unconditionally at the head of `PerformMovement` (L414). Optional — null = silent no-op.
- `public Action<uint,float,float>? StickTo { get; set; }` (L151) — retail `PositionManager::StickTo(object_id, radius, height)` (**R5 StickyManager body**). Called from `BeginNextNode`'s sticky arrival handoff (L708) with pre-CleanUp values `(tlid, radius, height)`. Optional — null = silent no-op.
- **Today, NEITHER `Unstick` nor `StickTo` is bound to anything** in GameWindow.cs — grep of `EnsureRemoteMotionBindings` and `EnterPlayerModeNow` shows no `rm.MoveTo.StickTo = ...` or `.Unstick = ...` assignment anywhere. Both are permanently null (silent no-op) in production today. (Separately, `MotionInterpreter.UnstickFromObject` — a DIFFERENT, unrelated seam on the interp, not the MoveToManager — IS bound in some contexts; see §3.)
### setHeading seam
- `_setHeading: Action<float,bool>` — bound per-manager at construction (remote: `mtBody.Orientation = MoveToMath.SetHeading(...)`, GameWindow.cs:4277-4278; player: `pcMoveTo.Yaw = MoveToMath.YawFromHeading(h)`, GameWindow.cs:12996-12997). The `bool send` (network-echo flag) param is **UNCONSUMED** on both sides — register row TS-33 (GameWindow.cs:12983-12988 comment): a stationary heading snap never reaches the wire.
### Public surface (entry points + drivers)
- `PerformMovement(MovementStruct mvs)` (L411) — the type-6..9 dispatch entry, called from `RouteServerMoveTo` (GameWindow.cs, §5below)
- `MoveToObject` / `MoveToPosition` / `TurnToObject` / `TurnToHeading` (L450, 501, 560, 613)
- `UseTime()` (L953) — THE TICK GATE: `!_contact()` / no pending node / (object-move `Initialized` gate) → dispatches `HandleMoveToPosition` / `HandleTurnToHeading`. Called from `GameWindow.TickRemoteMoveTo` (L4504) for remotes; not shown called for the player in the same function (player pump is via `_playerController`/`OnUpdateFrame` — see §2c).
- `HandleUpdateTarget(TargetInfo info)` (L1229) — AP-79 feed point (see above)
- `HitGround()` (L1292) — no-op if `MovementTypeState==Invalid`, else `BeginNextNode()`. Called from GameWindow at L5426 (remote landing) and L10070 (another remote landing site — see §2d).
- `CancelMoveTo(WeenieError error)` (L1461) — reentrancy-guarded drain+cleanup+stop; the `error` param is NEVER read in retail's body (kept for parity/logging only)
- `IsMovingTo()` (L397), `Destroy()` (L387), `CleanUp()` (L1485), `CleanUpAndCallWeenie()` (L1516)
- `MoveToComplete` (L168) — **CLIENT ADDITION, not retail** — fires `WeenieError.None` on natural completion only (never on cancel). Bound in `EnterPlayerModeNow` (GameWindow.cs:13024-13030) to re-fire the deferred AD-27 Use/PickUp action.
---
## 2. GameWindow wiring — `src/AcDream.App/Rendering/GameWindow.cs` (13,587 lines)
### 2a. `EnsureRemoteMotionBindings` (GameWindow.cs:4234-4308)
Binds, once per `RemoteMotion` (guarded by `rm.Sink is not null` early-return, L4239):
- `rm.Sink = new MotionTableDispatchSink(ae.Sequencer)` with `TurnApplied`/`TurnStopped` callbacks (L4243-4255) feeding `rmForSink.ObservedOmega`
- `rm.Motion.DefaultSink = rm.Sink` (L4256)
- `rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations` (L4257)
- `rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState()` (L4258)
- `rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions` (L4261) — R3-W5 per-op zero-tick flush
- `rm.MoveTo = new MoveToManager(...)` (L4270-4300) — full seam wiring: `stopCompletely`, `getPosition` (world-space, `rmT.CellId` + `mtBody.Position/Orientation`), `getHeading`/`setHeading` via `MoveToMath`, `getOwnRadius`/`getOwnHeight` **both hardcoded `0f`** (comment: "pin P4 note — setup cylsphere radius lands with R5's TargetManager port"), `contact: () => mtBody.OnWalkable`, `isInterpolating: () => rmT.Interp.IsActive`, `getVelocity`, `getSelfId: () => serverGuid`, `setTarget`/`clearTarget` writing `rmT.TrackedTargetGuid/Radius/FedOnce` (L4285-4291), `getTargetQuantum`/`setTargetQuantum` on `rmT.TargetQuantum`, real-clock `curTime` (L4294-4300, R4-V5 fix — was per-call stub before)
- `rm.Motion.InterruptCurrentMovement = () => rmT.MoveTo?.CancelMoveTo(WeenieError.ActionCancelled)` (L4304-4306) — TS-36, retail's `interrupt_current_movement → MovementManager::CancelMoveTo(0x36)` chain
- **NOT bound here**: `rm.MoveTo.StickTo`, `rm.MoveTo.Unstick`, `rm.MoveTo.MoveToComplete` — all left null for remotes.
### 2b. `EnterPlayerModeNow` (GameWindow.cs:12960-13145+)
Player-side construction, mirrors 2a with player-specific deltas (documented at L12970-12988):
- (a) heading via `pcMoveTo.Yaw` bridge, not a quaternion write directly
- (b) `contact: () => pcMoveTo.BodyInContact` — real Contact transient bit, not OnWalkable proxy
- (c) `isInterpolating: () => false` — player has no InterpolationManager
- `playerMoveTo = new MoveToManager(...)` (L12990-13013)
- `playerMoveTo.MoveToComplete = err => { ...; if (err==None) OnAutoWalkArrivedSendDeferredAction(); }` (L13024-13030) — the ONLY MoveToComplete binding in the codebase (AD-27 reanchor)
- `_playerController.MoveTo = playerMoveTo` (L13031)
- `_playerController.Motion.InterruptCurrentMovement = () => playerMoveTo.CancelMoveTo(WeenieError.ActionCancelled)` (L13039-13045) — TS-36 player side
- `_playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions` (L13145-13146), inside the `if (_animatedEntities.TryGetValue(...))` sequencer-bind block (L13135+) — the R4-V5 "moveto-stall fix #2" comment (L13126-13134) documents this MUST run before the initial `SetPosition` or the sequencer's pending_motions node orphans.
- **NOT bound**: `playerMoveTo.StickTo`, `playerMoveTo.Unstick`.
- Player MoveToManager tick pump: **not inside `EnterPlayerModeNow`** — search shows no direct `playerMoveTo.UseTime()` call in that function; it's driven per-frame from `OnUpdateFrame` via `_playerMoveToTarget*` fields (see §2c) and the `RouteServerMoveTo` call at L4772 (§2e).
### 2c. AP-79 tracker block + player pre-Update feed
- **Remote-side tracker**: `TickRemoteMoveTo(RemoteMotion rm)` (GameWindow.cs:4469-4505). Feeds `HandleUpdateTarget(Ok)` from `_entitiesByServerGuid` lookup when the tracked target moved beyond `rm.TrackedTargetRadius` (voyeur-radius diff, L4479-4491), or `ExitWorld` if the guid vanished from the live table (L4493-4502); then unconditionally calls `mtm.UseTime()` (L4504).
- **Player-side fields**: `_playerMoveToTargetGuid` / `_playerMoveToTargetRadius` / `_playerMoveToTargetFedOnce` / `_playerMoveToTargetQuantum` are declared and seeded in `EnterPlayerModeNow` (L13006-13016) via the ctor's `setTarget`/`clearTarget`/`getTargetQuantum`/`setTargetQuantum` closures, but **the actual per-tick "feed HandleUpdateTarget + call UseTime()" loop for the player was NOT found inside `EnterPlayerModeNow`** — grep of `TickRemoteMoveTo`-equivalent player logic did not surface a distinct function; it likely lives in `OnUpdateFrame` (13,587-line file — not fully read this pass). **Flag for R5 authors: locate/confirm the player pump site before assuming parity with `TickRemoteMoveTo`.**
### 2d. HitGround / LeaveGround / MotionDone / UseTime / CheckForCompletedMotions call sites
- `rm.Motion.LeaveGround()` — GameWindow.cs:5100 (remote ground departure, retail `LeaveGround` 0x00528b00)
- `rmState.Motion.HitGround()` + `rmState.MoveTo?.HitGround()` — GameWindow.cs:5425-5426 (remote landing; comment cites #161, retail `MovementManager::HitGround`)
- `rm.Motion.HitGround()` + `rm.MoveTo?.HitGround()` — GameWindow.cs:10062, 10070 (a SECOND remote-landing site, same pattern — the file has two independent landing branches)
- `ae.Sequencer.MotionDoneTarget = (m, ok) => { ...; interp?.MotionDone(m, ok); }` — GameWindow.cs:10142-10160 (R3-W2, bound once per sequencer; retail `CPhysicsObj::MotionDone` 0x0050fdb0 chain)
- `ae.Sequencer.Manager.UseTime()` — GameWindow.cs:10194 (this is the ANIMATION sequencer's own UseTime, not MoveToManager's — do not conflate)
- `mtm.UseTime()` — GameWindow.cs:4504 inside `TickRemoteMoveTo` (the MoveToManager tick, per remote, per frame)
- `rm.Motion.CheckForCompletedMotions = ...` — GameWindow.cs:4261 (bind site, in `EnsureRemoteMotionBindings`)
### 2e. RouteServerMoveTo (the type-6..9 funnel entry — see §5 also)
`RouteServerMoveTo(MoveToManager mgr, MotionInterpreter interp, uint cellId, EntityMotionUpdate update)` — GameWindow.cs:4361-4457. Two call sites:
- Player: GameWindow.cs:4772 — `RouteServerMoveTo(playerMoveTo, _playerController.Motion, _playerController.CellId, update)`, inside the `update.Guid == _playerServerGuid` branch (L4716), gated behind `_playerController is not null` (L4753) and preceded by `_playerController.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4768-4769) — the retail unpack_movement head (@300566).
- Remote: GameWindow.cs:4835 — `RouteServerMoveTo(moveMgr, remoteMot.Motion, remoteMot.CellId, update)`, inside the `else` (non-player) branch (L4784), also preceded by `remoteMot.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4828-4829).
### 2f. RemoteMotion class — GameWindow.cs:411-510+ (nested `internal sealed class RemoteMotion`, NOT a separate file)
Manager-related fields:
- `PhysicsBody Body`, `MotionInterpreter Motion` (L413-414)
- `MotionTableDispatchSink? Sink` (L419)
- `MoveToManager? MoveTo` (L436) — R4-V4
- AP-79 tracker state: `TrackedTargetGuid`, `TrackedTargetRadius`, `TrackedTargetLastFedPos`, `TrackedTargetFedOnce`, `TargetQuantum` (L442-446)
- Legacy/parallel `RemoteMoveToDriver`-oriented fields still present: `ServerMoveToActive`, `HasMoveToDestination`, `MoveToDestinationWorld`, `MoveToMinDistance`, `MoveToDistanceToObject`, `MoveToMoveTowards`, `LastMoveToPacketTime` (L448-503) — comments reference `AcDream.Core.Physics.RemoteMoveToDriver`, which per the R4 handoff docs was **retired** in the R4 "three approximations" cleanup (per MEMORY.md: "RemoteMoveToDriver/PlanMoveToStart/B.6-auto-walk all deleted"). These fields may now be dead weight predating that retirement — worth confirming during R5 cleanup.
---
## 3. MotionInterpreter sticky/action seams — `src/AcDream.Core/Physics/MotionInterpreter.cs` (3,298 lines)
- `StickTo`/`Unstick`/`stick` grep: **NO `StickTo` seam exists on `MotionInterpreter`** — only `UnstickFromObject` (L779: `public Action? UnstickFromObject { get; set; }`, doc L771-807), the R3-W2 no-op seam standing in for retail `CPhysicsObj::unstick_from_object`. This is a DIFFERENT seam from `MoveToManager.Unstick`/`.StickTo` (§1) — MotionInterpreter's copy is called at L2329 and L2375 inside its own dispatch/cancel paths, unrelated to the MoveToManager sticky handoff.
- `StandingLongJump`: field `public bool StandingLongJump;` (L747). Arms ONLY at `ChargeJump` (L25 comment, L1970 `StandingLongJump = true;`). Cleared at L2023 and L2511 (`StandingLongJump = false;`). Consumed in `ApplyInterpretedMovement`'s three-way branch (L2947, quoted below) and in `DoInterpretedMotion`'s verbatim retail body pseudocode (L2995-2997, `label_528440` goto).
### The #164 action-dispatch loop — `ApplyInterpretedMovement` (MotionInterpreter.cs:2920-2981)
```csharp
public void ApplyInterpretedMovement(
uint currentStyle, IInterpretedMotionSink? sink,
bool cancelMoveTo = false, bool allowJump = false)
{
if (PhysicsObj is null) return;
// Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449):
// Speed is re-set per axis below, everything else rides the pass.
var p = new MovementParameters
{
SetHoldKey = false,
ModifyInterpretedState = false,
CancelMoveTo = cancelMoveTo,
DisableJumpDuringLink = !allowJump,
};
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
p.Speed = 1.0f;
DoInterpretedMotion(currentStyle, p, sink);
if (!contact_allows_move(InterpretedState.ForwardCommand))
{
p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000
DoInterpretedMotion(MotionCommand.Falling, p, sink);
}
else if (StandingLongJump)
{
p.Speed = 1.0f;
DoInterpretedMotion(MotionCommand.Ready, p, sink);
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
}
else
{
p.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink);
if (InterpretedState.SideStepCommand == 0)
{
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
}
else
{
p.Speed = InterpretedState.SideStepSpeed;
DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink);
}
}
if (InterpretedState.TurnCommand != 0)
{
p.Speed = InterpretedState.TurnSpeed;
DoInterpretedMotion(InterpretedState.TurnCommand, p, sink);
return; // retail early return — no idle-stop this call
}
// Tail: unconditional StopInterpretedMotion(TurnRight, params).
StopInterpretedMotion(MotionCommand.TurnRight, p, sink);
}
```
**Correction to the task framing**: the dispatched `MovementParameters p` is built ONCE at the top with `Autonomous` implicitly `false` (default, never set) and **explicitly** sets `SetHoldKey=false`, `ModifyInterpretedState=false`, `CancelMoveTo=cancelMoveTo`, `DisableJumpDuringLink=!allowJump` — only `p.Speed` is mutated per-branch (not re-constructed per dispatch as `new MovementParameters { Speed }`). The #161 doc comment (L2892-2918) explicitly discusses this exact bitfield and calls `ModifyInterpretedState=false` "load-bearing." The `Autonomous` bit is never read or written anywhere in this method — confirms the framing that the loop drops/never-carries the Autonomous bit through to per-dispatch `MovementParameters`.
This is called from `MoveToInterpretedState` (L2820-2866, the funnel entry) at L2843: `ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);` — always `cancelMoveTo: true` for inbound wire state.
---
## 4. UpdateMotion wire parsing — `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (479 lines)
### mt-0 header flag parsing
- **MotionFlags byte** (wire term "header" in decomp comments) parsed at L161: `byte motionFlags = body[pos]; pos += 1;` — comment (L148-149) documents `0x1 = StickToObject, 0x2 = StandingLongJump` (ACE `MotionFlags` enum names). **This is NOT the same as the "0x100"/"0x200" framing in the task** — those are decomp-doc shorthand for the SAME bits at a different byte-shift (`header & 0x100` in the decomp refers to bit 0 of this byte after the 8-bit `motionFlags` is logically at bit-position 8 of the larger unpack_movement header dword). The actual C# test is `(motionFlags & 0x1) != 0` for sticky (L290) — there is no separate literal `0x100`/`0x200` mask in this file; grep for those literals returns nothing in UpdateMotion.cs.
- **0x1 (StickToObject) parse site**: L279-295, comment block cites ACE `MovementInvalid.Write` + decomp §2f case 0 @0052455d. Code:
```csharp
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
{
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
```
Only fires for `movementType == 0` (InterpretedMotionState branch, L188). The trailing u32 is read AFTER the InterpretedMotionState body + Commands list (matches ACE's write order).
- **0x2 (StandingLongJump) — NOT consumed**: comment L153-160 explicitly states "DOCUMENTED here but NOT consumed — retail's `unpack_movement` case 0 writes it onto `motion_interpreter->standing_longjump` (§2f @0052458e), an R5 unpack_movement item (this parser has no MotionInterpreter reference to write it onto...)". No code reads `motionFlags & 0x2` anywhere in the file.
### Downstream consumers (grep of whole src tree)
- `StickyObjectGuid` (the parsed field, carried on `CreateObject.ServerMotionState`, L317-326 return + `CreateObject.cs:265`): grep across `src/` shows it is **assigned into the record and returned, but never read by any production consumer** — the only non-definition hits are in `tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs` (asserting the parsed value, L662/683/716). No site in `GameWindow.cs`, `MotionInterpreter.cs`, or `MoveToManager.cs` reads `.StickyObjectGuid` off `ServerMotionState`.
- `motionFlags & 0x2` / `MotionFlags.StandingLongJump`: **zero matches anywhere in the repo** outside the UpdateMotion.cs documentation comment itself.
- **Conclusion for R5**: both the sticky-guid trailer and the standing-longjump wire bit are parsed-and-shelved. R5's PositionManager::StickTo (per the UpdateMotion.cs comment, "R5's PositionManager::StickTo body is the eventual consumer") and MovementManager (for standing-longjump) are the intended consumers — neither exists yet.
---
## 5. UM dispatch path for movement types 6-9 — funnel + head-stance question
- **Funnel/router**: `RouteServerMoveTo` (GameWindow.cs:4361-4457, quoted in part in §2e) is the ONE function that inspects `update.MotionState.IsServerControlledMoveTo`/`IsServerControlledTurnTo` + `MovementType` (6/7 → MoveToObject/MoveToPosition via `path`; 8/9 → TurnToObject/TurnToHeading via `turnPath`) and calls `mgr.PerformMovement(ms)`. Both the player call site (GameWindow.cs:4772) and remote call site (GameWindow.cs:4835) funnel through this SAME function — confirmed shared body, not duplicated per the R4-V5 extraction comment (L4459-4468).
- **Does any site do a head stance-change dispatch (`InqStyle != wire style -> DoMotion(style)`) BEFORE the type-6..9 switch?** **NO separate dispatch exists.** For the remote path, the stance/style value folds directly into `InboundInterpretedState.CurrentStyle` (GameWindow.cs:4867: `CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du`), which is built and consumed only in the `else` branch AFTER `RouteServerMoveTo` returns `false` (i.e., only for mt-0 / non-moveto events) — see GameWindow.cs:4834-4839 (`RouteServerMoveTo` call, early-`return` on true) followed by the `ims` construction at L4865-4874. So: for a wire event that IS a type-6..9 moveto, the style/stance field is **never separately dispatched** at this layer — `MoveToManager.PerformMovement` doesn't take or apply a style at all (it only sets `MovementTypeState` and queues nodes; no `DoMotion(style)` call anywhere in `PerformMovement`/`MoveToObject`/`MoveToPosition`/`TurnToObject`/`TurnToHeading`, confirmed by reading all of §1's L411-637). The style-on-change dispatch is explicitly flagged as unported: `RetailObserverTraceConformanceTests.cs:33` documents an EXCLUSION — "motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) style call for wire style index 0) — outside move_to_interpreted_state; **S3 wires the unpack-level style-on-change**" (not yet done). This is a concrete gap for R5/MovementManager: retail's real `unpack_movement` head does an `InqStyle`-vs-wire-style compare and issues a style `DoMotion` unconditionally (independent of movement type 0 vs 6-9), and acdream currently only applies style via the mt-0 `InboundInterpretedState.CurrentStyle` path — mt 6-9 events carry no style application at all today.
---
## 6. PhysicsBody — `src/AcDream.Core/Physics/PhysicsBody.cs` (576 lines)
- **IsFullyConstrained (TS-35, doc calls it TS-35 but file comment says "R3-W3 stub")**: `public bool IsFullyConstrained { get; set; }` (L263), doc block L251-262. Retail `CPhysicsObj::IsFullyConstrained` (0x0050f730), read by `CMotionInterp::jump_is_allowed` (`if (IsFullyConstrained(physics_obj) != 0) return 0x47`). Comment: "acdream has no equivalent constraint-tracking yet... stubbed false (never fires) — a real port needs the per-cell shadow-list contact accounting the physics digest tracks." **Always false in production** (settable property, but nothing in the grep'd files sets it true).
- **TransientState bits available** (`TransientStateFlags`, L62-70, `[Flags] enum`):
- `Contact = 0x1` (bit 0) — touching any surface
- `OnWalkable = 0x2` (bit 1) — standing on walkable surface
- `Sliding = 0x4` (bit 2) — carry sliding normal into next transition
- `Active = 0x80` (bit 7) — needs per-frame update
- Convenience getters: `OnWalkable` (L286), `IsActive` (L287), `InContact` (L288) properties on `PhysicsBody`.
- **PositionManager**: **does not exist as a class anywhere in the repo.** Grep for `PositionManager` across `src/` returns zero hits outside doc comments referencing it as a future R5 port target (e.g. MoveToManager.cs:24, L109-112 doc, UpdateMotion.cs comment). `IsInterpolating` (the seam MoveToManager consumes) is currently satisfied by ad-hoc booleans: `rmT.Interp.IsActive` for remotes (GameWindow.cs:4282) and a hardcoded `false` for the player (GameWindow.cs:13001) — neither is a real PositionManager.
---
## 7. Test files (locations, not full case enumeration)
**MoveToManager suite** — `tests/AcDream.Core.Tests/Physics/Motion/`:
- `MoveToManagerTestHarness.cs` — shared harness/fixture
- `MoveToManagerLifecycleTests.cs`
- `MoveToManagerNodePlanTests.cs`
- `MoveToManagerBeginMoveForwardTests.cs`
- `MoveToManagerAuxTurnTests.cs`
- `MoveToManagerArrivalAndProgressTests.cs`
- `MoveToManagerTurnToHeadingTests.cs`
- `MoveToManagerHandleUpdateTargetTests.cs`
- `MoveToManagerStickyAndCancelTests.cs`
- `MoveToManagerUseTimeGateTests.cs`
- `MoveToManagerEndToEndTableDriveTests.cs`
- `MoveToManagerCompletionSeamTests.cs`
- `MovementTypeWideningTests.cs`
- `MoveToMathCylinderDistanceTests.cs`
- `MotionTableDispatchSinkTests.cs`
- `InterpretedMotionStateActionFifoTests.cs`
**Funnel / 183-case conformance suite**:
- `tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs` (35+) — the live-retail-observer-cdb-trace conformance harness ("183/183" cited in MEMORY.md refers to this file's case count). Contains `LoginQueue_DrainsToEmpty_UnderProductionFeed` at **line 279** (`[Fact]`).
- `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs`
- `tests/AcDream.Core.Tests/Physics/Motion/` (see above — dispatch-adjacent)
**DispatchInterpreted / MotionInterpreter core suite** — `tests/AcDream.Core.Tests/Physics/`:
- `MotionInterpreterTests.cs`
- `MotionInterpreterDoMotionFamilyTests.cs`
- `MotionInterpreterGroundLifecycleTests.cs`
- `MotionInterpreterJumpFamilyTests.cs`
- `MotionInterpreterPendingMotionsTests.cs`
- `ServerControlledLocomotionTests.cs`
- `RemoteWeenieRunRateTests.cs`
- `InWorldLinkGuardTests.cs`
- `MotionNormalizationTests.cs`
- `MotionVelocityPipelineTests.cs`
- `AnimationSequencerCutoverTraceTests.cs`
- `WeenieErrorCodeTableTests.cs`
**Player moveto cutover**:
- `tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs`
---
## Summary answers to the task's specific questions
**Q4 (UpdateMotion sticky/longjump)**: 0x1 (StickToObject) trailer IS parsed (`UpdateMotion.cs:279-295`, code at 290-293) into `stickyObjectGuid``ServerMotionState.StickyObjectGuid`; 0x2 (StandingLongJump) is documented but the bit is never even read. **Neither has a production consumer** — grep-wide, `StickyObjectGuid` is read only by unit tests; `MotionFlags.StandingLongJump`/`0x2` has zero non-comment hits anywhere.
**Q5 (type 6-9 head-stance dispatch)**: No such dispatch exists. `RouteServerMoveTo` (GameWindow.cs:4361) is the shared funnel for both player and remote; it never touches style/stance. `MoveToManager.PerformMovement`/its four entry points never call `DoMotion(style)`. Style application only happens on the mt-0 path via `InboundInterpretedState.CurrentStyle` folded at GameWindow.cs:4867. `RetailObserverTraceConformanceTests.cs:33`'s exclusion comment explicitly flags the unpack-level style-on-change dispatch as **not yet wired** ("S3 wires the unpack-level style-on-change") — this is a known, documented gap, not an oversight this recon discovered fresh.
**Q6 (PhysicsBody / PositionManager)**: `IsFullyConstrained` is a settable bool stub, always false in practice (PhysicsBody.cs:263). `TransientStateFlags` has `Contact`/`OnWalkable`/`Sliding`/`Active` (PhysicsBody.cs:63-70). No `PositionManager` class exists anywhere in the repo — `IsInterpolating` is faked per-caller (`Interp.IsActive` for remotes, hardcoded `false` for the player).
**File path of the written map**: `C:/Users/erikn/AppData/Local/Temp/claude/C--Users-erikn-source-repos-acdream--claude-worktrees-vigorous-joliot-f0c3ad/dd264e44-6468-4b4b-8d17-a8b3b15991c9/scratchpad/r5/acdream-seams.md`

View file

@ -0,0 +1,332 @@
# 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.

View file

@ -0,0 +1,679 @@
# ConstraintManager — retail decomp extract
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named).
Struct source: `docs/research/named-retail/acclient.h`.
**Address correction:** the task listed `CPhysicsObj::IsFullyConstrained` at `0x0050f730`.
The actual address in the corpus is **`0x0050ec60`**. Verified by grepping the definition
line (`276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)`)
and cross-checked against its caller in `CMotionInterp::jump_is_allowed` at `0x005282fd`.
---
## struct ConstraintManager (acclient.h, comment `/* 3467 */`)
```c
/* 3467 */
struct __cppobj ConstraintManager
{
CPhysicsObj *physics_obj;
int is_constrained;
float constraint_pos_offset;
Position constraint_pos;
float constraint_distance_start;
float constraint_distance_max;
};
```
## struct PositionManager (acclient.h, comment `/* 3468 */`) — the owning object
```c
/* 3468 */
struct __cppobj PositionManager
{
InterpolationManager *interpolation_manager;
StickyManager *sticky_manager;
ConstraintManager *constraint_manager;
CPhysicsObj *physics_obj;
};
```
Note the field order in `PositionManager` (`interpolation_manager`, `sticky_manager`,
`constraint_manager`, `physics_obj`) matches the `PositionManager::Create` allocation
writes to offsets `0x0`, `0x4`, `0x8`, `0xc` respectively (see below) and the
`PositionManager::Destroy` teardown order (`interpolation_manager``sticky_manager`
`constraint_manager`).
`ConstraintManager` field layout maps onto `ConstraintManager::Create`'s raw offset
writes: `physics_obj`=`0x0`, `is_constrained`=`0x4`, `constraint_pos_offset`=`0x8` — wait,
per the decompiled writes below the offsets are actually `0x0`/`0x4`/`0x8`/`0xc`
(`vtable` field of `Position constraint_pos` at `0xc`)/`0x10`.../`0x48` is
`constraint_distance_start`, `0x4c` is `constraint_distance_max`. The compiler emits a
`Position` (which itself embeds a `Frame` with its own vtable-looking sentinel field —
see NOTE in `~ConstraintManager` below) between `constraint_pos_offset` and
`constraint_distance_start`, consistent with the struct's declared member order.
---
## ConstraintManager::SetPhysicsObject — `0x00556090`
```c
00556090 void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2)
00556090 {
00556096 if (this->physics_obj == 0)
00556096 {
005560ad this->physics_obj = arg2;
005560af return;
00556096 }
00556096
00556098 this->physics_obj = 0;
0055609a this->is_constrained = 0;
0055609d this->constraint_pos_offset = 0f;
005560a4 this->physics_obj = arg2;
00556090 }
```
## ConstraintManager::UnConstrain — `0x005560c0`
```c
005560c0 void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this)
005560c0 {
005560c0 this->is_constrained = 0;
005560c0 }
```
## ConstraintManager::IsFullyConstrained — `0x005560d0`
```c
005560d0 int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this)
005560d0 {
005560d0 long double x87_r7 = ((long double)this->constraint_pos_offset);
005560d6 long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002));
005560dc (x87_r6_1 - x87_r7);
005560de int32_t eax;
005560de eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0))));
005560e0 bool p = /* bool p = unimplemented {test ah, 0x5} */;
005560e0
005560e3 if (p)
005560ed return 0;
005560ed
005560ea return 1;
005560d0 }
```
NOTE (garbled bitfield mush / x87 flags mush): the `eax = (...)` line is Binary Ninja's
attempt to render the x87 `FCOMI`/`FSTSW`+`SAHF`-style compare-and-test-flags sequence as
bit-packed pseudocode. It is computing `constraint_distance_max * 0.9 <=> constraint_pos_offset`
and then `test ah, 0x5` checks the ZF/CF-equivalent bits packed into `ah` after `fnstsw ax`.
The semantic read: `p` is true when `(constraint_distance_max * 0.9) < constraint_pos_offset`
OR the compare was unordered (NaN) — i.e. `test ah,5` tests bits 0 (C0/"below") and 2
(C3/"equal") of the FPU status word as loaded into AH, the classic x87 `jbe`-equivalent
pattern. So: **`IsFullyConstrained` returns `false` (0) if `constraint_pos_offset >=
0.9 * constraint_distance_max` (or unordered), else returns `true` (1)**. In plain terms:
the object counts as "fully constrained" while it is still within 90% of the max leash
distance; once it has drifted past 90% of that distance it is no longer "fully" constrained
(this is the gate `CMotionInterp::jump_is_allowed` reads to block jump attempts while
straining at the very end of a constraint leash).
## ConstraintManager::~ConstraintManager — `0x005560f0`
```c
005560f0 void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this)
005560f0 {
005560f2 this->is_constrained = 0;
005560f5 this->constraint_pos_offset = 0f;
005560f8 this->physics_obj = 0;
005560fa this->constraint_pos.vtable = 0x79285c;
005560f0 }
```
NOTE: `this->constraint_pos.vtable = 0x79285c``constraint_pos` is a `Position` field
(struct member, not a pointer), so this is Binary Ninja's rendering of the embedded
`Frame`'s vtable-pointer slot being reset to its static vtable address as part of the
`Position`/`Frame` subobject's implicit destructor inlining. Not a real "vtable swap";
just the compiler zeroing/resetting the embedded Frame's identity field during teardown.
## ConstraintManager::Create — `0x00556110` (factory)
```c
00556110 class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1)
00556110 {
00556114 void* result = operator new(0x5c);
00556114
00556122 if (result == 0)
00556177 return 0;
00556177
00556124 *(uint32_t*)result = 0;
00556126 *(uint32_t*)((char*)result + 4) = 0;
00556129 *(uint32_t*)((char*)result + 8) = 0;
0055612f *(uint32_t*)((char*)result + 0xc) = 0x796910;
00556136 *(uint32_t*)((char*)result + 0x10) = 0;
00556139 *(uint32_t*)((char*)result + 0x14) = 0x3f800000;
0055613f *(uint32_t*)((char*)result + 0x18) = 0;
00556142 *(uint32_t*)((char*)result + 0x1c) = 0;
00556145 *(uint32_t*)((char*)result + 0x20) = 0;
00556148 *(uint32_t*)((char*)result + 0x48) = 0;
0055614b *(uint32_t*)((char*)result + 0x4c) = 0;
0055614e *(uint32_t*)((char*)result + 0x50) = 0;
00556151 Frame::cache(((char*)result + 0x14));
00556156 *(uint32_t*)((char*)result + 0x54) = 0;
00556159 *(uint32_t*)((char*)result + 0x58) = 0;
00556159
0055615e if (*(uint32_t*)result != 0)
0055615e {
00556160 *(uint32_t*)((char*)result + 4) = 0;
00556163 *(uint32_t*)((char*)result + 8) = 0;
00556166 *(uint32_t*)result = 0;
0055615e }
0055615e
0055616c *(uint32_t*)result = arg1;
00556172 return result;
00556110 }
```
NOTE: allocation is `0x5c` (92) bytes — `sizeof(ConstraintManager)`. Field-offset mapping
against the struct decl: `+0x0`=`physics_obj`, `+0x4`=`is_constrained`,
`+0x8`=`constraint_pos_offset`, `+0xc..0x50`=`constraint_pos` (embedded `Position`, whose
own `objcell_id`/`frame` subfields explain the `0x796910` vtable-like constant at `+0xc`
and the `Frame::cache` call seeding the rotation quaternion identity `w=1.0`
i.e. `0x3f800000` at `+0x14`), `+0x48`=`constraint_distance_start`,
`+0x4c`=`constraint_distance_max`. The trailing `+0x54`/`+0x58` zeroing is past the
declared struct fields in the header excerpt we have — likely padding/alignment or a
field the header comment block truncated; not load-bearing for the port (all consumed
fields — `physics_obj`, `is_constrained`, `constraint_pos_offset`, `constraint_pos`,
`constraint_distance_start`, `constraint_distance_max` — are accounted for).
The odd `if (*(uint32_t*)result != 0) { zero everything }` right after construction reads
as dead/defensive code from an inlined check that can't actually trigger here (the fields
were all just zeroed above) — flagging as NOTE, not altering.
## ConstraintManager::adjust_offset — `0x00556180`
```c
00556180 void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3)
00556180 {
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
00556186
0055618a if (physics_obj != 0)
0055618a {
00556190 int32_t is_constrained = this->is_constrained;
00556190
00556195 if (is_constrained != 0)
00556195 {
005561a7 if ((physics_obj->transient_state & 1) != 0)
005561a7 {
005561a9 long double x87_r7_1 = ((long double)this->constraint_pos_offset);
005561ac long double temp1_1 = ((long double)this->constraint_distance_max);
005561ac (x87_r7_1 - temp1_1);
005561af physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0))));
005561af
005561b4 if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0)
005561b4 {
005561e7 long double x87_r7_2 = ((long double)this->constraint_pos_offset);
005561ea long double temp2_1 = ((long double)this->constraint_distance_start);
005561ea (x87_r7_2 - temp2_1);
005561ed physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0))));
005561ef bool p_1 = /* bool p_1 = unimplemented {test ah, 0x41} */;
005561ef
005561f2 if (p_1)
005561f2 {
005561f7 int32_t is_constrained_1 = is_constrained;
00556209 Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start)))));
005561f2 }
005561b4 }
005561b4 else
005561b4 {
005561c2 arg2->m_fOrigin.x = 0;
005561c2 arg2->m_fOrigin.y = 0f;
005561c2 arg2->m_fOrigin.z = 0f;
005561b4 }
005561a7 }
005561a7
0055620e arg2->m_fOrigin;
00556211 arg2->m_fOrigin;
00556233 this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset)));
00556195 }
0055618a }
00556180 }
```
NOTE (garbled bitfield mush + BN variable-reuse artifact): `physics_obj` gets *reused* as
a scratch int32 to hold the packed x87 comparison-flags value at `005561af` and again at
`005561ed` — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the
`CPhysicsObj*` pointer. The `this->physics_obj` local captured at `00556186` is what's
actually read at `005561a7` (`physics_obj->transient_state`) and `005561b4`
(`*(uint8_t*)((char*)physics_obj)[1] & 1` — this is checking a byte of `transient_state`
again, offset `+1`, i.e. a second flag byte inside the same bitfield/word). Do not port
the reused `physics_obj` int32 as if it becomes a different physics object; it's the same
pointer, just overwritten as dead-value scratch space by the decompiler's register
allocator view.
`bool p_1 = /* unimplemented {test ah, 0x41} */` is the same x87-flags-in-AH pattern as
`IsFullyConstrained` above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on
encoding) — the classic `jbe`-vs-`jae` variant. Given the surrounding compare
(`constraint_pos_offset < constraint_distance_start` or equal), and that the guarded body
computes a **lerp fraction** `(constraint_distance_max - constraint_pos_offset) /
(constraint_distance_max - constraint_distance_start)` applied to `arg2->m_fOrigin` via
`*=`, the semantic read is: **when the object has NOT yet reached (or has just reached)
`constraint_distance_start`, scale the frame's offset delta by how far through the
start→max leash band the object currently sits** (a ramp/taper multiplier — presumably
smoothly reducing how much of the requested frame delta gets applied as the leash
tightens). `test ah,0x41` semantically reads as "less-than-or-unordered-or-equal"
(`p_1` true when NOT clearly greater), so the ramp only applies while still inside the
band; once past `constraint_distance_start` typical port intent should skip the scale
(leave `arg2->m_fOrigin` alone) — consistent with the `else` branch two levels up which
zeroes `m_fOrigin` outright when `transient_state`'s second flag bit is clear.
Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check
if the port's leash-taper feel diverges from retail), the function's shape is:
1. No-op if no `physics_obj` or not `is_constrained`.
2. If `transient_state & 1` (some "active"/"in contact" style flag):
- If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta
`arg2->m_fOrigin` by a lerp fraction based on `(max - pos_offset) / (max - start)`,
gated by a comparison of `pos_offset` vs `constraint_distance_start`.
- Else: zero the incoming frame delta entirely (fully clamp movement).
3. Unconditionally (after the above), accumulate: `constraint_pos_offset +=
arg2->m_fOrigin.x` (note: only the `.x` component is added — `arg2->m_fOrigin` is read
twice at `0055620e`/`00556211` with no visible effect, likely a debug/no-op dead read
from the decompiler, or hints there's a per-component variant BN collapsed; only the
final `.x`-add survived as an observable store).
## ConstraintManager::ConstrainTo — `0x00556240`
```c
00556240 void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4)
00556240 {
00556248 this->is_constrained = 1;
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
00556271 this->constraint_distance_start = arg3;
00556274 this->constraint_distance_max = arg4;
0055627c this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position));
00556240 }
```
Straightforward: pins the leash anchor (`constraint_pos` = copy of `arg2`'s cell+frame),
sets `is_constrained = true`, sets the start/max leash-band radii from `arg3`/`arg4`, and
initializes `constraint_pos_offset` to the CURRENT distance from the anchor to the
physics object's live position (`Position::distance(arg2, &physics_obj->m_position)`) —
i.e. the leash starts already "extended" to wherever the object presently is relative to
the constraint anchor, not to zero.
---
## PositionManager-level seams (the actual public API — ConstraintManager is
## lazily-created and private underneath)
`ConstraintManager` is never touched directly from outside `PositionManager`.
`PositionManager` lazily creates it on first `ConstrainTo` call and forwards through it.
```c
00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3)
00555190 {
00555191 int32_t ebx = arg3;
0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager;
005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4];
005551a2
005551a6 if (interpolation_manager != 0)
005551a6 {
005551a8 int32_t var_14_1 = edi;
005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx);
005551a6 }
005551a6
005551b0 class StickyManager* sticky_manager = this->sticky_manager;
005551b0
005551b5 if (sticky_manager != 0)
005551b5 {
005551b7 int32_t var_14_2 = edi;
005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx);
005551b5 }
005551b5
005551bf class ConstraintManager* constraint_manager = this->constraint_manager;
005551bf
005551c4 if (constraint_manager != 0)
005551c4 {
005551c6 int32_t var_14_3 = edi;
005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx);
005551c4 }
00555190 }
```
Chains ALL THREE sub-managers' `adjust_offset` in a fixed order:
`InterpolationManager``StickyManager``ConstraintManager`, each optional (only
called if that sub-manager exists). This is the per-frame(?) offset-adjustment
dispatcher `PositionManager` uses to let interpolation/sticky/constraint all have a say
in shaping a `Frame` delta before it's applied.
```c
00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4)
00555280 {
00555288 if (this->constraint_manager == 0)
00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj);
00555296
00555299 class ConstraintManager* constraint_manager = this->constraint_manager;
00555299
0055529f if (constraint_manager == 0)
005552a6 return;
005552a6
005552a1 /* tailcall */
005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4);
00555280 }
005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this)
005552b0 {
005552b0 class ConstraintManager* constraint_manager = this->constraint_manager;
005552b0
005552b5 if (constraint_manager == 0)
005552bc return;
005552bc
005552b7 /* tailcall */
005552b7 return ConstraintManager::UnConstrain(constraint_manager);
005552b0 }
005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this)
005552c0 {
005552c0 class ConstraintManager* constraint_manager = this->constraint_manager;
005552c0
005552c5 if (constraint_manager == 0)
005552ce return 0;
005552ce
005552c7 /* tailcall */
005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager);
005552c0 }
```
`PositionManager::Create` (`0x005552d0`) wires a freshly-allocated `PositionManager`'s
`physics_obj` back-pointer into any already-non-null sub-managers (only relevant for
copy/re-init paths since a fresh `PositionManager` starts with all-null sub-managers):
```c
005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)
005552d0 {
005552d3 void* result = operator new(0x10);
...
0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8);
0055531d
00555322 if (ecx_2 != 0)
00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1);
00555325
0055532e return result;
005552d0 }
```
`PositionManager::Destroy` (`0x00555340`) tears down and `delete`s all three
sub-managers, `ConstraintManager` last:
```c
00555340 void __fastcall PositionManager::Destroy(class PositionManager* this)
00555340 {
...
00555377 class ConstraintManager* constraint_manager = this->constraint_manager;
0055537c this->sticky_manager = nullptr;
0055537c
00555383 if (constraint_manager != 0)
00555383 {
00555387 ConstraintManager::~ConstraintManager(constraint_manager);
0055538d operator delete(constraint_manager);
00555383 }
00555383
00555396 this->constraint_manager = nullptr;
00555340 }
```
---
## CPhysicsObj-level seams (public API callers actually use)
```c
0050ec10 void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this)
0050ec10 {
0050ec16 if (this == CPhysicsObj::player_object)
0050ec16 {
0050ec18 this->m_position;
0050ec2d return;
0050ec16 }
0050ec16
0050ec35 this->m_position;
0050ec10 }
0050ebc0 void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this)
0050ebc0 {
0050ebc6 if (this == CPhysicsObj::player_object)
0050ebc6 {
0050ebc8 this->m_position;
0050ebdd return;
0050ebc6 }
0050ebc6
0050ebe5 this->m_position;
0050ebc0 }
```
NOTE (BN decompilation artifact / x87 return-value elision): both functions have `void`
signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're
called as `ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2)` immediately followed by
`(float)st0_6` casts at the call sites — an x87 FPU return value living in `st0` that
Binary Ninja failed to attach to the declared return type). Body-wise all we get is
`this->m_position;` as a bare expression on both the player-branch and fallthrough
paths — BN elided the actual field read/constant selection (this is likely
`this->m_position.something` or a per-type constant lookup that got collapsed to a
dead-looking statement). **This function needs a live cdb read of `st0` after the call,
or a Ghidra re-decompile with a corrected float-return signature, to recover the actual
values.** Given the call-site pattern (constraining the player and other movers to a
"home" position after teleport/movement-timeout in `SmartBox::HandleReceivedPosition`),
the two functions almost certainly return small fixed-radius constants (a "start easing"
radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case
(hence the `this == CPhysicsObj::player_object` branch in both). Do not guess the literal
values — flag as an open research item before porting numeric constants.
```c
00510520 void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4)
00510520 {
00510523 CPhysicsObj::MakePositionManager(this);
00510528 class PositionManager* position_manager = this->position_manager;
00510528
00510531 if (position_manager == 0)
00510538 return;
00510538
00510533 /* tailcall */
00510533 return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4);
00510520 }
```
`CPhysicsObj::ConstrainTo` lazily ensures a `PositionManager` exists
(`MakePositionManager`) then forwards. This is the entry point external code calls.
```c
0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this)
0050ec60 {
0050ec60 class PositionManager* position_manager = this->position_manager;
0050ec60
0050ec68 if (position_manager == 0)
0050ec71 return 0;
0050ec71
0050ec6a /* tailcall */
0050ec6a return PositionManager::IsFullyConstrained(position_manager);
0050ec60 }
```
(Address correction noted at top of doc: this is `0x0050ec60`, not the task-supplied
`0x0050f730`.)
There is no separate `CPhysicsObj::UnConstrain` — callers go straight to
`PositionManager::UnConstrain(this->position_manager)` (see caller list below); only
`ConstrainTo` and `IsFullyConstrained` got a `CPhysicsObj`-level convenience wrapper.
---
## CALLERS — when does retail actually constrain an object?
### 1. `SmartBox::HandleReceivedPosition` (`0x00453fd0`) — THE constrain call site
All three live `CPhysicsObj::ConstrainTo` calls in the entire corpus are inside this one
function, at three different branches of its position-update-reconciliation logic:
**Branch A — non-player mover, after a successful move/teleport resolve (`0x00454254`):**
```c
0045414d if (arg2 != this->player)
0045414d {
00454254 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0)
00454254 {
00454258 int32_t ecx_26;
00454258 ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045425d int32_t var_68_14 = ecx_26;
00454263 ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2);
00454268 int32_t var_6c_9 = ecx_28;
00454272 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6));
00454254 }
00454254
00454254 return;
0045414d }
```
For a non-player object (`arg2`), once `MoveOrTeleport` succeeds, it is constrained
**to its own current position** (`&arg2->m_position` as the anchor) with start/max radii
from `GetStartConstraintDistance`/`GetMaxConstraintDistance`.
**Branch B — player, on a fresh TELEPORT timestamp event (`0x0045415f`):**
```c
0045415f if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0)
0045415f {
00454168 SmartBox::TeleportPlayer(this, &var_48);
0045416f ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045417a ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2);
0045418a CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1));
0045418f class CPhysicsObj* player_2 = this->player;
0045419c int32_t var_54 = 0; // zero vector
004541b4 CPhysicsObj::set_velocity(player_2, &var_54, 1);
004541c0 return;
0045415f }
```
On a server teleport of the local player, `SmartBox::TeleportPlayer` snaps position, then
the player is constrained **to the newly-received server position** (`&var_48`, the
decoded incoming `Position`), and velocity is zeroed.
**Branch C — player, fallthrough / non-teleport received-position path (`0x004541c9`):**
```c
004541c9 ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player);
004541d8 ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player);
004541ec CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4));
004541f1 class CommandInterpreter* cmdinterp_1 = this->cmdinterp;
0045420a if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0))
0045420a {
... CPhysicsObj::InterpolateTo(arg2, &var_48, ...);
```
Every OTHER received server position update for the local player (not a teleport-flagged
event) ALSO constrains the player to the received position (`&var_48`), and then —
depending on `UsePositionFromServer`/autonomy settings — may additionally kick off
`InterpolateTo`. So the leash gets re-anchored on essentially every server position
correction, whether or not interpolation is used to visually smooth toward it.
**Summary for Branch A/B/C:** retail constrains an object to a `Position` (self or
server-received) with a start/max leash-band pair **every time `SmartBox` processes an
inbound position update for that object** — this is the "rubber-band" leash mechanism
that keeps the client's locally-simulated position from drifting too far from the
server-authoritative position between updates. It's re-armed (re-`ConstrainTo`'d) on
every inbound position packet, not set once.
### 2. `CPhysicsObj::teleport_hook` (`0x00514ed0`) — THE unconstrain call site
```c
00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
00514ed0 {
00514ed3 class MovementManager* movement_manager = this->movement_manager;
00514edb if (movement_manager != 0)
00514edb MovementManager::CancelMoveTo(movement_manager, edx);
00514ee4 class PositionManager* position_manager = this->position_manager;
00514eec if (position_manager != 0)
00514eee PositionManager::UnStick(position_manager);
00514ef3 class PositionManager* position_manager_1 = this->position_manager;
00514efb if (position_manager_1 != 0)
00514efd PositionManager::StopInterpolating(position_manager_1);
00514f02 class PositionManager* position_manager_2 = this->position_manager;
00514f0a if (position_manager_2 != 0)
00514f0c PositionManager::UnConstrain(position_manager_2);
00514f11 class TargetManager* target_manager = this->target_manager;
00514f19 if (target_manager != 0)
00514f19 {
00514f1b TargetManager::ClearTarget(target_manager);
00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus);
00514f19 }
00514f31 CPhysicsObj::report_collision_end(this, 1);
00514ed0 }
```
The ONLY `UnConstrain` call in the corpus. `teleport_hook` is a general "this object just
got relocated in a way that invalidates all continuity state" cleanup: it cancels any
active `MoveTo`, un-sticks (StickyManager), stops interpolation, **un-constrains**, clears
target tracking, and ends collision reporting. So the leash is torn down whenever an
object teleports (any teleport, not just the player's) — makes sense, since a teleport by
definition means "the position just legitimately jumped," so the anti-drift leash from
the PREVIOUS anchor must be dropped rather than fight the teleport.
### 3. `CMotionInterp::jump_is_allowed` (`0x005282b0`) — THE read call site
```c
005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3)
005282b0 {
005282b8 if (this->physics_obj != 0)
005282b8 {
...
005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
00528305 return 0x47;
00528305
00528308 class LListData* head_ = this->pending_motions.head_;
...
```
`jump_is_allowed` reads `IsFullyConstrained` and, if true, immediately returns error code
`0x47` (rejecting the jump attempt) before even checking pending motions / jump-charge
state. This is the ONLY read-site of `IsFullyConstrained` in the corpus. Ties back to the
mechanical read of `ConstraintManager::IsFullyConstrained` above: while the object is
still within 90% of its max leash distance from the constraint anchor, it counts as
"fully constrained" and jumping is blocked outright — i.e. **you cannot jump while the
client's simulated position is being actively rubber-banded back toward a server-received
position inside the tight leash band.** Only once you've drifted past 90% of the leash
(or the leash has been dropped via `UnConstrain`/teleport) does the jump-blocking gate
open.
---
## CPhysicsObj-level "constrain" seam grep (exhaustive)
Full result of `grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained("` across the
corpus — every call site, no filtering:
```
93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...) [SmartBox::HandleReceivedPosition, Branch A]
93024 CPhysicsObj::ConstrainTo(arg2, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch B]
93041 CPhysicsObj::ConstrainTo(this->player, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch C]
276520 CPhysicsObj::IsFullyConstrained (definition)
276529 -> PositionManager::IsFullyConstrained (tailcall)
278353 CPhysicsObj::ConstrainTo (definition)
278363 -> PositionManager::ConstrainTo (tailcall)
283140 PositionManager::UnConstrain(position_manager_2) [CPhysicsObj::teleport_hook]
305524 CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0 [CMotionInterp::jump_is_allowed]
352186 PositionManager::ConstrainTo (definition)
352198 -> ConstraintManager::ConstrainTo (tailcall)
352203 PositionManager::UnConstrain (definition)
352212 -> ConstraintManager::UnConstrain (tailcall)
352217 PositionManager::IsFullyConstrained (definition)
352226 -> ConstraintManager::IsFullyConstrained (tailcall)
353405 ConstraintManager::UnConstrain (definition)
353413 ConstraintManager::IsFullyConstrained (definition)
353528 ConstraintManager::ConstrainTo (definition)
```
No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain
mechanism is used EXCLUSIVELY by:
- `SmartBox::HandleReceivedPosition` to arm/re-arm the leash on inbound position updates
(3 branches: self-anchor for remote movers, server-anchor for player teleport, server-
anchor for player non-teleport updates), and
- `CPhysicsObj::teleport_hook` to disarm it on any teleport, and
- `CMotionInterp::jump_is_allowed` to read it as a jump-blocking gate.
This is a narrow, special-purpose "server position rubber-band leash," NOT a general
physics constraint/joint system.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,338 @@
# R5 port plan — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
Synthesis of the R5 recon (2026-07-03). Verbatim retail decomp lives in the
sibling files (`r5-*-decomp.md`); ACE cross-reference in `r5-ace-crossref.md`;
current acdream integration seams in `r5-acdream-seams.md`. This doc is the
**pseudocode + integration + sub-slice plan** (mandatory workflow step 3).
Retirement targets: **TS-39** (sticky no-op seams) and **AP-79** (the P4
TargetTracker adapter). ConstraintManager is ported for structural completeness
of PositionManager but its arming stays unported (see §Constraint scope) — **TS-35
stays open**.
---
## 0. The retail structure vs acdream's reality
**Retail** (`CPhysicsObj` owns three managers, all lazily created):
```
CPhysicsObj
├── movement_manager : MovementManager (facade → MotionInterp + MoveToManager)
├── position_manager : PositionManager (facade → Interpolation + Sticky + Constraint)
└── target_manager : TargetManager (voyeur subscription system)
```
Per-tick, `CPhysicsObj::UpdateObjectInternal` (0x005156b0) fans out in THIS order
(decomp §positionmanager-sticky callers):
1. `DetectionManager::CheckDetection`
2. `TargetManager::HandleTargetting` ← voyeur tick (no separate UseTime)
3. `MovementManager::UseTime``MoveToManager::UseTime` only
4. `CPartArray::HandleMovement`
5. `PositionManager::UseTime` → Interp/Sticky/Constraint UseTime
And `UpdatePositionInternal` (0x00512c30) composes the frame delta:
`Frame::cache(&d)``CPartArray::Update(d)` (animation) →
`PositionManager::adjust_offset(&d, quantum)` (interp+sticky+constraint ADD into
the SAME delta `d`) → `Frame::combine(out, m_position.frame, d)` → transition
(collision) → commit. **Sticky/constraint steering composes with animation motion
in the same per-tick delta frame, before collision resolves it.**
**acdream** has no per-entity `CPhysicsObj`. The per-entity owner today is:
| Retail | acdream (remote) | acdream (player) |
|---|---|---|
| `CPhysicsObj` | `GameWindow.RemoteMotion` (nested class, GameWindow.cs:411) | `_playerController` (`PlayerMovementController`) + GameWindow fields |
| `movement_manager` | `rm.Motion` (MotionInterpreter) + `rm.MoveTo` (MoveToManager) | `_playerController.Motion` + `playerMoveTo` |
| `position_manager` | **absent** | **absent** |
| `target_manager` | **AP-79 adapter**: `rm.TrackedTarget*` fields + `TickRemoteMoveTo` | **AP-79 adapter**: `_playerMoveToTarget*` fields + `OnUpdateFrame` block |
| `UpdateObjectInternal` tick | `TickRemoteMoveTo(rm)` (GameWindow.cs:4469) | `OnUpdateFrame` block (GameWindow.cs:7994) + `_playerController.Update` |
R5 adds the two missing managers per-entity. The MovementManager facade (collapsing
`Motion`+`MoveTo` into one owner) is the optional capstone (§V4) — the retirement
targets don't require it, so it lands last and only if the arc has budget.
---
## 1. Struct/field map (acclient.h → C#)
```
MovementManager (0x10): motion_interpreter, moveto_manager, physics_obj, weenie_obj
PositionManager (0x10): interpolation_manager, sticky_manager, constraint_manager, physics_obj
StickyManager (0x60): target_id:uint, target_radius:float, target_position:Position,
physics_obj, initialized:int, sticky_timeout_time:double
ConstraintManager(0x5c): physics_obj, is_constrained:int, constraint_pos_offset:float,
constraint_pos:Position, constraint_distance_start:float,
constraint_distance_max:float
TargetManager (0x18): physobj, target_info:TargetInfo*, voyeur_table:Hash<TargettedVoyeurInfo>,
last_update_time:double
TargetInfo (0xd0): context_id:uint, object_id:uint, radius:float, quantum:double,
target_position:Position, interpolated_position:Position,
interpolated_heading:Vec3, velocity:Vec3, status:TargetStatus,
last_update_time:double
TargettedVoyeurInfo(0x60): object_id:uint, quantum:double, radius:float,
last_sent_position:Position
```
acdream already has `TargetInfo` (4-field record) + `TargetStatus` enum in
`MoveToManager.cs` — R5 EXTENDS `TargetInfo` to the full 10-field shape (add
`ContextId`, `Radius`, `Quantum`, `InterpolatedHeading`, `Velocity`,
`LastUpdateTime`). The existing `MoveToManager.HandleUpdateTarget` only reads
`ObjectId`/`Status`/`InterpolatedPosition`, so the extension is additive-safe.
---
## 2. The math — decoded (ACE is the oracle for the x87 mush)
All three `adjust_offset` bodies and both timeout gates were dense x87 mush in the
BN decomp. ACE's ports resolve them cleanly and were confirmed against the mush
structure (fld/fmul/fsqrt/fcomp shapes match). **Port ACE's structure; cite the
retail address.**
### 2a. StickyManager.adjust_offset (retail 0x00555430, ACE StickyManager.cs:91)
```
// Guard: no-op unless PhysicsObj && target_id != 0 && initialized
target = GetObjectA(target_id) // live resolve
targetPos = target != null ? target.Position : cached target_position // fallback to last-known
offset = self.Position.GetOffset(targetPos) // world vector self→target
offset = self.Position.GlobalToLocalVec(offset) // rotate into own frame
offset.Z = 0 // horizontal-only
radius = self.GetRadius()
dist = Position.CylinderDistanceNoZ(radius, self.Position, target_radius, targetPos)
- 0.3f // StickyRadius const (ACE StickyRadius=0.3f)
if NormalizeCheckSmall(ref offset): offset = Vector3.Zero // too close → don't chase jitter
speed = 0
minterp = self.get_minterp()
if minterp != null: speed = minterp.get_max_speed() * 5.0f // 5× own max speed (catch up)
if speed < EPSILON: speed = 15.0f // fallback
delta = speed * quantum
if delta >= abs(dist): delta = dist // don't overshoot (dist can be NEGATIVE)
offset.Origin *= delta
curHeading = self.Position.Frame.get_heading()
targetHeading = self.Position.heading(targetPos)
h = targetHeading - curHeading
if abs(h) < EPSILON: h = 0
if h < -EPSILON: h += 360
offset.set_heading(h) // per-tick RELATIVE turn toward target
```
Net: a speed-clamped horizontal steer + bounded turn toward the stuck target,
written into the shared per-tick delta frame. `StickyRadius=0.3f`, `StickyTime=1.0f`
are the two named constants.
### 2b. ConstraintManager.adjust_offset (retail 0x00556180, ACE ConstraintManager.cs:62)
```
// Guard: no-op unless PhysicsObj && is_constrained
if (self.TransientState & Contact): // ONLY clamps while grounded
if constraint_pos_offset < constraint_distance_max:
if constraint_pos_offset > constraint_distance_start:
offset.Origin *= (constraint_distance_max - constraint_pos_offset)
/ (constraint_distance_max - constraint_distance_start) // linear brake taper
else:
offset.Origin = Vector3.Zero // fully pinned
// UNCONDITIONAL (grounded or airborne):
constraint_pos_offset = offset.Origin.Length() // NB: length of the per-tick step, NOT distance-to-anchor
```
`ConstraintPos` (the anchor) is stored by ConstrainTo but never read by
adjust_offset — the taper uses `constraint_pos_offset` (last tick's step length)
vs start/max. See §ConstraintScope for why acdream never arms this.
### 2c. ConstraintManager.IsFullyConstrained (retail 0x005560d0, ACE:37)
```
return constraint_distance_max * 0.9f < constraint_pos_offset; // 90% of leash "fully constrained"
```
Read by `jump_is_allowed`: if true → return 0x47 (block jump). Since acdream never
arms the constraint, this stays false → jump never blocked here (= TS-35 current
behavior).
### 2d. TargetManager quantum gates (retail 0x0051aa90 / 0x0051a650)
```
HandleTargetting(): // per-tick, self-throttled
if PhysicsTimer.CurrentTime - last_update_time < 0.5f: return // 0.5s throttle
if target_info != null && target_info.target_position == null: return
if target_info != null && target_info.status == Undefined
&& target_info.last_update_time + 10.0f < Timer.cur_time: // 10s staleness
target_info.status = TimedOut
PhysicsObj.HandleUpdateTarget(copy(target_info))
for voyeur in voyeur_table.Values.ToList():
CheckAndUpdateVoyeur(voyeur)
last_update_time = PhysicsTimer.CurrentTime
CheckAndUpdateVoyeur(voyeur):
newPos = GetInterpolatedPosition(voyeur.quantum) // self.pos + velocity*quantum (dead-reckon)
if newPos.Distance(voyeur.last_sent_position) > voyeur.radius: // send-on-significant-move
SendVoyeurUpdate(voyeur, newPos, Ok)
GetInterpolatedPosition(quantum):
pos = copy(self.Position); pos.Origin += self.get_velocity() * quantum; return pos
```
`0.5f` throttle, `10.0f` staleness — retail-tuned quanta (cross-checked ACE; verify
against named-retail before treating literally, per ACE's own `// ref?` caveats).
### 2e. The voyeur round-trip (peer-to-peer position notification)
```
Watcher W wants to track target T:
W.SetTarget(ctx=0, T.id, radius=0.5, quantum=q)
→ W.target_info = new TargetInfo(...)
→ T.add_voyeur(W.id, 0.5, q) // W subscribes ON T's voyeur_table
→ T immediately SendVoyeurUpdate(W-voyeur, T.pos, Ok) // initial snapshot
→ W.receive_target_update(info) → W.ReceiveUpdate(info)
→ W.target_info updated; W.HandleUpdateTarget(info)
→ W.movement_manager.HandleUpdateTarget (MoveToManager steers)
→ W.position_manager.HandleUpdateTarget (StickyManager follows)
Each tick, T.HandleTargetting → CheckAndUpdateVoyeur(W-voyeur):
if T drifted > radius since last sent → SendVoyeurUpdate → W.ReceiveUpdate → W.HandleUpdateTarget
W stops: W.ClearTarget → T.remove_voyeur(W.id)
T despawns: T.exit_world → NotifyVoyeurOfEvent(ExitWorld) → all watchers get ExitWorld
T teleports: T.teleport_hook → NotifyVoyeurOfEvent(Teleported)
```
This is exactly what the AP-79 adapter approximates (poll target pos, feed
HandleUpdateTarget when drift>radius). The voyeur port is a **faithful superset**:
same moveto behavior + correct sticky + timeout/exit/teleport events.
---
## 3. Two consumers of set_target (both radius 0.5)
| Caller | quantum | Meaning |
|---|---|---|
| `MoveToManager.MoveToObject/TurnToObject` | **0.0** | resend as fast as the 0.5s tick allows |
| `StickyManager.StickTo` | **0.5** | throttled resend |
Both go through the SAME `CPhysicsObj::set_target → TargetManager::SetTarget`, and
both receive updates through `CPhysicsObj::HandleUpdateTarget →
{MovementManager, PositionManager}::HandleUpdateTarget`. In acdream the
`_setTarget`/`_clearTarget` seams (already on MoveToManager) get repointed at the
real TargetManager; StickyManager gets its own seam to the same TargetManager.
---
## 4. acdream integration seams (delegate contracts)
The Core classes stay engine-agnostic via ctor-injected seams (same convention as
MoveToManager). Per entity the App layer supplies:
- `getPosition : () → Position` (world-space, cell + frame)
- `getVelocity : () → Vector3`
- `getRadius : () → float`, `getHeight : () → float` (from setup cylsphere — **finally
needed here; today MoveToManager passes 0** — see the P4 note)
- `getMinterpMaxSpeed : () → float?` (null if no interp) — StickyManager speed
- `getContact : () → bool` (transient Contact bit) — ConstraintManager gate
- `getObjectA : uint guid → IPhysicsTargetHandle?` — cross-entity resolve (the guid→entity
seam; drives voyeur delivery + sticky live-target). Backed by GameWindow's
`_entitiesByServerGuid`.
- `handleUpdateTarget : TargetInfo → void` — fans to MoveToManager + PositionManager(Sticky)
- `clearTarget`/`interruptCurrentMovement` — teardown (already exist on MoveToManager/Motion)
- clock : `() → double` (real clock, as R4-V5 fixed)
`getObjectA` returns a small handle exposing the OTHER entity's
`receive_target_update(TargetInfo)` (→ its TargetManager.ReceiveUpdate) + its live
Position — enough for `SendVoyeurUpdate` and sticky's live-target resolve.
---
## 5. Sub-slice plan
### R5-V1 — Core classes + conformance tests (NO wiring; low risk)
New files under `src/AcDream.Core/Physics/Motion/` (Position/Sticky/Constraint) and
`src/AcDream.Core/Physics/Combat/` (TargetManager — retail namespace split):
- `PositionManager` (facade: adjust_offset chain interp→sticky→constraint, UseTime,
StickTo/Unstick/ConstrainTo/UnConstrain/IsFullyConstrained/HandleUpdateTarget).
- `StickyManager` (§2a math, StickTo/UnStick/UseTime timeout/HandleUpdateTarget/adjust_offset).
- `ConstraintManager` (§2b/§2c, ConstrainTo/UnConstrain/IsFullyConstrained/adjust_offset).
- `TargetManager` (§2d/§2e full voyeur system) + `TargettedVoyeurInfo` + extended `TargetInfo`.
- InterpolationManager: acdream already has a remote `Interp` (PhysicsBody interp) —
keep it; PositionManager's interp slot delegates to a thin adapter or is left null
for the player. (Interp is NOT an R5 retirement target; don't re-port it.)
- Seam interfaces + a test harness with a fake `getObjectA` wiring two managers to
each other. Golden values from ACE + the decoded math above. Tests:
sticky steer/clamp/heading, sticky 1s timeout, constraint taper + IsFullyConstrained
90% gate, voyeur subscribe/immediate-snapshot/distance-gate/10s-timeout/exit/teleport,
ReceiveUpdate match+heading-fallback, the full W↔T round-trip.
- `dotnet build`+`dotnet test` green. Commit. **No behavior change** (nothing wired).
### R5-V2 — Wire TargetManager per-entity; retire AP-79
- Add `TargetManager` to `RemoteMotion` + a player-side owner; construct beside the
MoveToManager binds. Repoint MoveToManager's `_setTarget`/`_clearTarget`/quantum
seams at `TargetManager.SetTarget`/`ClearTarget`/quantum.
- `getObjectA` backed by `_entitiesByServerGuid` (returns a handle to the target's
TargetManager). Per-tick: call `TargetManager.HandleTargetting()` in
`TickRemoteMoveTo` + the player block, BEFORE `MoveToManager.UseTime()` (retail order).
- Delete the AP-79 tracker fields (`TrackedTarget*` / `_playerMoveToTarget*`) and the
manual poll blocks — the voyeur round-trip replaces them.
- Register: delete AP-79 row same commit. **Visual gate**: a server-directed creature
chasing the player still tracks + moves identically (the exact behavior AP-79 drove).
### R5-V3 — Wire PositionManager (Sticky); retire TS-39; apply mt-0 sticky guid
- Add `PositionManager` to each entity. Bind `MoveToManager.StickTo →
PositionManager.StickTo`, `MoveToManager.Unstick → PositionManager.UnStick`.
- Integrate `PositionManager.adjust_offset` into the per-frame body integration
(the composed delta-frame chokepoint — retail's UpdatePositionInternal). This is
the load-bearing wiring: sticky steer must ADD to the body's per-tick motion.
- Per-tick `PositionManager.UseTime()` AFTER MoveToManager.UseTime (retail order).
- Apply the mt-0 wire flags (UpdateMotion.cs already parses them, unconsumed):
`0x1 StickToObject``stick_to_object(guid)``PositionManager.StickTo`;
`0x2 StandingLongJump``MotionInterpreter.StandingLongJump`.
- Register: delete TS-39 row same commit. **Visual gate**: a sticky scenario (server
/follow-style sticky moveto or a moving-platform stick) holds the target instead of
stopping.
### R5-V4 (capstone, optional) — MovementManager facade + #164 + head stance dispatch + docs
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner (structural;
must keep the 183-case + funnel + moveto suites green UNMODIFIED).
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy flag,
raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
- Head stance-change dispatch for mt 6-9 (verification item 1 / the S3 exclusion at
`RetailObserverTraceConformanceTests.cs:33`).
- Final register/ISSUES/roadmap/memory + successor handoff.
---
## Constraint scope (why TS-35 stays open)
ConstraintManager is the **server-position rubber-band leash**, armed ONLY from
`SmartBox::HandleReceivedPosition` (arm on every inbound position packet, anchor =
self or server-received position) with two constants from `GetStart/MaxConstraintDistance`
(0x0050ebc0 / 0x0050ec10) that are **x87 float returns BN elided — literal values
unknown** (need a live cdb read or Ghidra float-return fix). acdream has no SmartBox
leash (its position reconciliation is separate), so:
- Port the class + wire it into PositionManager (IsFullyConstrained routes through it;
adjust_offset participates in the chain) for structural faithfulness.
- **Do NOT arm it** (no ConstrainTo call site) — the leash + jump-blocking + the two
unknown constants are out of R5 scope. IsFullyConstrained stays false = TS-35's
current behavior. Update TS-35's `where` to point at the now-real-but-unarmed
ConstraintManager; keep the row OPEN. Filing an issue for the leash port + the two
unknown constants.
## Open verification items (carry into V1 tests / cdb)
1. `GetStart/MaxConstraintDistance` x87 return constants — unknown (constraint arming,
deferred). File as issue.
2. `HandleTargetting`'s 0.5s/10s quanta + `StickyTime` 1.0s / `StickyRadius` 0.3f /
sticky speed `×5` + `15.0f` fallback — ACE values; verify against named-retail.
3. mt-0 header flags: confirm `0x1`/`0x2` (wire) == decomp `0x100`/`0x200` shift
(recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword).
4. `TargetInfo` pass-by-value-vs-ref (ACE's two `// ref?` markers) — retail copy-constructs
(`TargetInfo::TargetInfo(&copy, src)` at every fan-out); port as copy.
## Do-NOT-re-port (already shipped)
MoveToManager (R4, full incl. queue + HandleUpdateTarget consumer), MotionInterpreter
(R3, #161 apply-pass), MotionTableManager+GetObjectSequence (R2), CSequence (R1),
RemoteWeenie + PhysicsBody.InWorld (R4-V5), InterpolationManager (acdream's remote
Interp — not an R5 target).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -548,8 +548,8 @@ public sealed class GameWindow : IDisposable
/// queue catch-up REPLACES anim when active; anim stands when queue /// queue catch-up REPLACES anim when active; anim stands when queue
/// is idle. /// is idle.
/// </summary> /// </summary>
public AcDream.Core.Physics.PositionManager Position { get; } = public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
new AcDream.Core.Physics.PositionManager(); new AcDream.Core.Physics.RemoteMotionCombiner();
/// <summary> /// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the /// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the

View file

@ -457,7 +457,7 @@ public sealed class AnimationSequencer
/// <see cref="MotionTableManager.PerformMovement"/>, then (on a /// <see cref="MotionTableManager.PerformMovement"/>, then (on a
/// successful InterpretedCommand) the locomotion velocity synthesis /// successful InterpretedCommand) the locomotion velocity synthesis
/// (register AP-75; the consumers are remote body translation via /// (register AP-75; the consumers are remote body translation via
/// PositionManager.ComputeOffset and the local Option-B /// RemoteMotionCombiner.ComputeOffset and the local Option-B
/// get_state_velocity — retire in R6 when root motion drives the body). /// get_state_velocity — retire in R6 when root motion drives the body).
/// Omega is deliberately NOT synthesized here: remote rotation is the /// Omega is deliberately NOT synthesized here: remote rotation is the
/// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6); /// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);

View file

@ -0,0 +1,120 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — verbatim port of retail's <c>ConstraintManager</c> (acclient.h:31529,
/// struct #3467; decomp 0x00556090-0x005562xx,
/// <c>r5-constraintmanager-decomp.md</c>). The server-position <b>rubber-band
/// leash</b>: while the owning object is in ground contact it progressively
/// resists movement past a start→max distance band from a pinned anchor, and
/// hard-clamps at the max. Read as a jump gate by
/// <c>CMotionInterp::jump_is_allowed</c> (block jump while
/// <see cref="IsFullyConstrained"/>).
///
/// <para><b>Arming is UNPORTED in acdream (R5).</b> Retail arms the leash ONLY
/// from <c>SmartBox::HandleReceivedPosition</c> (on every inbound server
/// position packet) with two constants from
/// <c>CPhysicsObj::GetStart/MaxConstraintDistance</c> whose values BN elided
/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation
/// is not SmartBox, so nothing calls <see cref="ConstrainTo"/> — the leash
/// stays disarmed and <see cref="IsFullyConstrained"/> stays false, matching
/// register TS-35's current stub behavior. The class is ported for structural
/// completeness of <see cref="PositionManager"/>; the leash-arming port + the
/// two unknown constants are a deferred issue (port-plan §Constraint scope).</para>
/// </summary>
public sealed class ConstraintManager
{
private readonly IPhysicsObjHost _host;
/// <summary>+0x04 retail <c>is_constrained</c>.</summary>
public bool IsConstrained { get; private set; }
/// <summary>+0x08 retail <c>constraint_pos_offset</c> — recomputed every
/// <see cref="AdjustOffset"/> as the LENGTH of that tick's step (NOT the
/// distance to the anchor — see the ACE correctness note, port-plan §2b);
/// the next tick's contact branch compares it against start/max.</summary>
public float ConstraintPosOffset { get; private set; }
/// <summary>+0x0c retail <c>constraint_pos</c> — the leash anchor. Stored
/// by <see cref="ConstrainTo"/>, never read by <see cref="AdjustOffset"/>
/// (retail + ACE — write-only in this class).</summary>
public Position ConstraintPos { get; private set; }
/// <summary>+0x48 retail <c>constraint_distance_start</c> — the near edge
/// of the brake band.</summary>
public float ConstraintDistanceStart { get; private set; }
/// <summary>+0x4c retail <c>constraint_distance_max</c> — the far edge
/// (full clamp).</summary>
public float ConstraintDistanceMax { get; private set; }
public ConstraintManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>
/// Retail <c>ConstraintManager::ConstrainTo</c> (0x00556240). Pin the leash
/// anchor and band; initialize <see cref="ConstraintPosOffset"/> to the
/// CURRENT distance from anchor to the mover (the leash starts already
/// extended to wherever the object is, not at zero).
/// </summary>
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
{
IsConstrained = true;
ConstraintPos = anchor;
ConstraintDistanceStart = startDistance;
ConstraintDistanceMax = maxDistance;
ConstraintPosOffset = Vector3.Distance(anchor.Frame.Origin, _host.Position.Frame.Origin);
}
/// <summary>Retail <c>ConstraintManager::UnConstrain</c> (0x005560c0) —
/// clears the constrained flag only (leaves the band/anchor/offset stale
/// until the next <see cref="ConstrainTo"/>).</summary>
public void UnConstrain() => IsConstrained = false;
/// <summary>
/// Retail <c>ConstraintManager::IsFullyConstrained</c> (0x005560d0):
/// <c>constraint_distance_max * 0.9 &lt; constraint_pos_offset</c> — the
/// object counts as fully constrained once it has strained past 90 % of the
/// max leash. Read by <c>jump_is_allowed</c> to block jumps. Always false
/// while the leash is disarmed (acdream never arms it — see class note).
/// </summary>
public bool IsFullyConstrained()
=> ConstraintDistanceMax * 0.9f < ConstraintPosOffset;
/// <summary>
/// Retail <c>ConstraintManager::adjust_offset</c> (0x00556180). The last
/// stage of <see cref="PositionManager.AdjustOffset"/>'s chain — clamps the
/// ALREADY-composed per-tick <paramref name="offset"/> (interp + sticky)
/// while grounded, then records its length for the next tick. No-op unless
/// <see cref="IsConstrained"/>. See port-plan §2b.
/// </summary>
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
_ = quantum; // retail body doesn't use the quantum directly.
if (!IsConstrained)
return;
if (_host.InContact) // transient_state & 1 — clamp only while grounded.
{
if (ConstraintPosOffset < ConstraintDistanceMax)
{
if (ConstraintPosOffset > ConstraintDistanceStart)
{
// Linear brake taper: 1.0 just past start → 0.0 at max.
float taper = (ConstraintDistanceMax - ConstraintPosOffset)
/ (ConstraintDistanceMax - ConstraintDistanceStart);
offset.Origin *= taper;
}
}
else
{
offset.Origin = Vector3.Zero; // past max — fully pinned.
}
}
// Unconditional (grounded OR airborne): track this tick's step length.
ConstraintPosOffset = offset.Origin.Length();
}
}

View file

@ -0,0 +1,100 @@
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 seam — the acdream stand-in for retail's <c>CPhysicsObj</c> as seen BY
/// its owned managers. Retail's <c>StickyManager</c> / <c>ConstraintManager</c>
/// / <c>TargetManager</c> each hold a raw <c>physics_obj</c> pointer and call
/// back through it (position/velocity/radius accessors, target-tracking
/// registration, the <c>HandleUpdateTarget</c> fan-out) and — for the voyeur
/// system — resolve OTHER physics objects via <c>CObjectMaint::GetObjectA</c>
/// and drive their <c>add_voyeur</c> / <c>receive_target_update</c> /
/// <c>remove_voyeur</c> entry points. This interface is that back-pointer.
///
/// <para>The App layer implements one host per entity (a remote
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
/// <see cref="GetObjectA"/> is backed by the App's live entity table
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
/// cross-entity delivery path.</para>
/// </summary>
public interface IPhysicsObjHost
{
/// <summary>Retail <c>physics_obj-&gt;id</c> — this object's guid.</summary>
uint Id { get; }
/// <summary>Retail <c>physics_obj-&gt;m_position</c> — world-space cell +
/// frame (acdream seams carry WORLD space; see the MoveToManager binding
/// note).</summary>
Position Position { get; }
/// <summary>Retail <c>CPhysicsObj::get_velocity</c>.</summary>
Vector3 Velocity { get; }
/// <summary>Retail <c>CPhysicsObj::GetRadius</c> — the mover's cylinder
/// radius.</summary>
float Radius { get; }
/// <summary>Retail <c>physics_obj-&gt;transient_state &amp; 1</c> — the
/// CONTACT bit (ConstraintManager's grounded gate).</summary>
bool InContact { get; }
/// <summary>Retail <c>CPhysicsObj::get_minterp()-&gt;get_max_speed()</c> —
/// the mover's max locomotion speed, or <c>null</c> if it has no motion
/// interpreter yet (StickyManager falls back to a 15.0 constant).</summary>
float? MinterpMaxSpeed { get; }
/// <summary>Retail <c>Timer::cur_time</c> — the wall/game clock (seconds).
/// Drives the sticky 1 s timeout and target 10 s staleness deadlines.</summary>
double CurTime { get; }
/// <summary>Retail <c>PhysicsTimer::curr_time</c> — the physics-tick clock
/// (seconds). Drives <c>TargetManager::HandleTargetting</c>'s 0.5 s
/// throttle. Retail uses a DIFFERENT clock here than <see cref="CurTime"/>;
/// acdream may bind both to the same source.</summary>
double PhysicsTimerTime { get; }
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
/// physics object by guid, or <c>null</c> if not currently known/visible.
/// The cross-entity seam for the voyeur round-trip and sticky live-target
/// resolve.</summary>
IPhysicsObjHost? GetObjectA(uint id);
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
/// Called from <see cref="TargetManager.ReceiveUpdate"/> and the timeout
/// path.</summary>
void HandleUpdateTarget(TargetInfo info);
/// <summary>Retail <c>CPhysicsObj::interrupt_current_movement</c> →
/// <c>MovementManager::CancelMoveTo(0x36)</c>.</summary>
void InterruptCurrentMovement();
/// <summary>Retail <c>CPhysicsObj::set_target(ctx, objId, radius,
/// quantum)</c> → <see cref="TargetManager.SetTarget"/> (lazily creating
/// the TargetManager). Called by <c>StickyManager::StickTo</c> and
/// <c>MoveToManager</c>'s object-move entry points.</summary>
void SetTarget(uint contextId, uint objectId, float radius, double quantum);
/// <summary>Retail <c>CPhysicsObj::clear_target</c> →
/// <see cref="TargetManager.ClearTarget"/>.</summary>
void ClearTarget();
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
void ReceiveTargetUpdate(TargetInfo info);
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
void AddVoyeur(uint watcherId, float radius, double quantum);
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
/// watcher unsubscribes.</summary>
void RemoveVoyeur(uint watcherId);
}

View file

@ -0,0 +1,40 @@
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// Mutable stand-in for retail's <c>Frame</c> when it is used as the per-tick
/// <b>delta accumulator</b> that <c>PositionManager::adjust_offset</c> and its
/// three sub-managers (Interpolation / Sticky / Constraint) write into
/// (retail arg2, e.g. <c>StickyManager::adjust_offset</c> 0x00555430,
/// <c>ConstraintManager::adjust_offset</c> 0x00556180). acdream's
/// <see cref="CellFrame"/> is an immutable record — retail's per-tick math
/// mutates <c>m_fOrigin</c> in place across the interp→sticky→constraint chain
/// (each sub-manager composes on top of the previous one's write), so the
/// accumulator needs a mutable shape.
///
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
/// position delta, in the mover's LOCAL frame after
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
/// (P5 convention, degrees).</para>
/// </summary>
public sealed class MotionDeltaFrame
{
/// <summary>Retail <c>m_fOrigin</c> — the accumulated per-tick position
/// delta (mover-local frame).</summary>
public Vector3 Origin;
/// <summary>Retail <c>Frame</c> rotation — carries the
/// <c>Frame::set_heading</c> output.</summary>
public Quaternion Orientation = Quaternion.Identity;
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
public float GetHeading() => MoveToMath.GetHeading(Orientation);
/// <summary>Retail <c>Frame::set_heading(headingDeg)</c> — pure
/// yaw-about-Z setter (P5 compass degrees).</summary>
public void SetHeading(float headingDeg) =>
Orientation = MoveToMath.SetHeading(Orientation, headingDeg);
}

View file

@ -1558,10 +1558,21 @@ public sealed class MoveToManager
/// <summary> /// <summary>
/// Retail <c>TargetInfo</c> (acclient.h:31591, struct #3482) — the callback /// Retail <c>TargetInfo</c> (acclient.h:31591, struct #3482) — the callback
/// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes. The P4 /// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes AND the wire
/// TargetTracker adapter (App-side, R4-V4 scope) is the ONLY producer; /// record the R5 <see cref="TargetManager"/> voyeur system exchanges between
/// <see cref="MoveToManager"/> itself has no target-tracking machinery of its /// hosts (<c>SendVoyeurUpdate</c> → <c>receive_target_update</c> →
/// own (TargetManager bodies are R5 — decomp §9f, do-not-invent list). /// <see cref="TargetManager.ReceiveUpdate"/>).
///
/// <para>R5 EXTENDED this from the R4 4-field callback shape to the full retail
/// 10-field struct. The extra fields (<see cref="ContextId"/>,
/// <see cref="Radius"/>, <see cref="Quantum"/>,
/// <see cref="InterpolatedHeading"/>, <see cref="Velocity"/>,
/// <see cref="LastUpdateTime"/>) default to zero, so the existing 4-argument
/// <c>new TargetInfo(id, status, tp, ip)</c> call sites (MoveToManager tests,
/// the AP-79 adapter pre-V2) still compile unchanged.
/// <see cref="MoveToManager.HandleUpdateTarget"/> only reads
/// <see cref="ObjectId"/>/<see cref="Status"/>/<see cref="InterpolatedPosition"/>;
/// the extra fields are consumed by the voyeur system.</para>
/// </summary> /// </summary>
/// <param name="ObjectId">Retail <c>object_id</c> — matched against /// <param name="ObjectId">Retail <c>object_id</c> — matched against
/// <see cref="MoveToManager.TopLevelObjectId"/>; a mismatch is silently /// <see cref="MoveToManager.TopLevelObjectId"/>; a mismatch is silently
@ -1572,11 +1583,30 @@ public sealed class MoveToManager
/// <param name="InterpolatedPosition">Retail <c>interpolated_position</c> — /// <param name="InterpolatedPosition">Retail <c>interpolated_position</c> —
/// the smoothed tracking point <see cref="MoveToManager.MoveToObject_Internal"/> /// the smoothed tracking point <see cref="MoveToManager.MoveToObject_Internal"/>
/// steers toward.</param> /// steers toward.</param>
/// <param name="ContextId">Retail <c>context_id</c> — the tracking context
/// (0 = the movement context; <c>CPhysicsObj::HandleUpdateTarget</c> only fans
/// out context 0).</param>
/// <param name="Radius">Retail <c>radius</c> — the voyeur's send-on-move
/// threshold (game units).</param>
/// <param name="Quantum">Retail <c>quantum</c> — the dead-reckoning lookahead
/// horizon (seconds) for <c>GetInterpolatedPosition</c>.</param>
/// <param name="InterpolatedHeading">Retail <c>interpolated_heading</c> —
/// normalized self→target direction (falls back to +Z when degenerate).</param>
/// <param name="Velocity">Retail <c>velocity</c> — the target's velocity at
/// send time.</param>
/// <param name="LastUpdateTime">Retail <c>last_update_time</c> — receipt
/// timestamp (drives the 10 s staleness timeout).</param>
public readonly record struct TargetInfo( public readonly record struct TargetInfo(
uint ObjectId, uint ObjectId,
TargetStatus Status, TargetStatus Status,
Position TargetPosition, Position TargetPosition,
Position InterpolatedPosition); Position InterpolatedPosition,
uint ContextId = 0,
float Radius = 0f,
double Quantum = 0.0,
Vector3 InterpolatedHeading = default,
Vector3 Velocity = default,
double LastUpdateTime = 0.0);
/// <summary> /// <summary>
/// Retail <c>TargetStatus</c> (acclient.h:7264). Only <see cref="Ok"/> vs /// Retail <c>TargetStatus</c> (acclient.h:7264). Only <see cref="Ok"/> vs

View file

@ -230,6 +230,58 @@ public static class MoveToMath
return edgeDist > 0f ? edgeDist : 0f; return edgeDist > 0f ? edgeDist : 0f;
} }
/// <summary>
/// Retail <c>Position::cylinder_distance_no_z</c> — the <b>signed</b>
/// horizontal (X/Y) edge-to-edge distance between two cylinders:
/// <c>centerDist ownRadius targetRadius</c>. Unlike
/// <see cref="CylinderDistance"/> (the arrival-gate variant, which CLAMPS at
/// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE
/// value. <c>StickyManager::adjust_offset</c> (0x00555430) relies on the
/// sign: when the follower is inside the desired 0.3 m stick gap the
/// distance goes negative, the per-tick delta inverts, and the mover backs
/// off to restore the gap (ACE StickyManager.cs:156).
/// </summary>
public static float CylinderDistanceNoZ(
float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos)
{
float dx = targetPos.X - ownPos.X;
float dy = targetPos.Y - ownPos.Y;
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
return centerDist - ownRadius - targetRadius;
}
/// <summary>
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c> — normalize
/// <paramref name="v"/> in place, returning <c>true</c> if the vector was
/// too small to normalize (near-zero) and leaving it unchanged in that
/// case. Consumed by <c>StickyManager::adjust_offset</c> (don't chase
/// jitter when already at the target) and
/// <see cref="TargetManager.ReceiveUpdate"/> (interpolated-heading
/// fallback). A public shared twin of the private helper in
/// <c>ParticleSystem</c>; same 1e-8 near-zero length guard.
/// </summary>
/// <returns><c>true</c> = too small (left unchanged); <c>false</c> =
/// normalized.</returns>
public static bool NormalizeCheckSmall(ref Vector3 v)
{
float length = v.Length();
if (length < 1e-8f)
return true;
v /= length;
return false;
}
/// <summary>
/// Retail <c>Position::globaltolocalvec</c> — rotate a WORLD-space vector
/// into a frame's LOCAL coordinates by the inverse of the frame's
/// orientation. Consumed by <c>StickyManager::adjust_offset</c>
/// (0x00555430) to express the self→target offset in the mover's own frame
/// before flattening Z and steering. Pure rotation (no translation) —
/// <paramref name="worldVec"/> is a direction/offset, not a point.
/// </summary>
public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec)
=> Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation));
/// <summary> /// <summary>
/// Landblock-local wire origin → world space (verbatim relocation from /// Landblock-local wire origin → world space (verbatim relocation from
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo / /// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /

View file

@ -0,0 +1,102 @@
using System;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — port of retail's <c>PositionManager</c> facade (acclient.h:30952,
/// struct #3468; decomp 0x00555160-0x005553d0,
/// <c>r5-positionmanager-sticky-decomp.md</c>). A thin fan-out over three
/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's
/// <see cref="IPhysicsObjHost"/> (retail <c>CPhysicsObj::position_manager</c>,
/// lazily created).
///
/// <para><b>Interpolation note:</b> retail's <c>adjust_offset</c> chains
/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in
/// <see cref="RemoteMotionCombiner"/> (the R5-renamed remote-motion combiner,
/// formerly the misnamed <c>Physics.PositionManager</c>) and is NOT chained
/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39;
/// Constraint is structural — see <see cref="ConstraintManager"/>). Folding the
/// combiner in as the interp stage is a wiring-slice cleanup.</para>
/// </summary>
public sealed class PositionManager
{
private readonly IPhysicsObjHost _host;
// Lazily created (retail: sticky on first StickTo, constraint on first
// ConstrainTo — 0x00555230 / 0x00555280).
private StickyManager? _sticky;
private ConstraintManager? _constraint;
public PositionManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>Exposed for wiring/tests — the lazily-created sub-managers
/// (null until first use).</summary>
public StickyManager? Sticky => _sticky;
public ConstraintManager? Constraint => _constraint;
/// <summary>Retail <c>PositionManager::StickTo</c> (0x00555230) — lazily
/// create the <see cref="StickyManager"/> and begin following
/// <paramref name="objectId"/>.</summary>
public void StickTo(uint objectId, float radius, float height)
{
_sticky ??= new StickyManager(_host);
_sticky.StickTo(objectId, radius, height);
}
/// <summary>Retail <c>PositionManager::UnStick</c> (0x005551e0) — forward
/// to the sticky sub-manager if it exists.</summary>
public void UnStick() => _sticky?.UnStick();
/// <summary>Retail <c>PositionManager::GetStickyObjectID</c>
/// (0x00555270).</summary>
public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u;
/// <summary>Retail <c>PositionManager::ConstrainTo</c> (0x00555280) —
/// lazily create the <see cref="ConstraintManager"/> and arm the leash.
/// (Unused in acdream — no arming call site; see
/// <see cref="ConstraintManager"/>.)</summary>
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
{
_constraint ??= new ConstraintManager(_host);
_constraint.ConstrainTo(anchor, startDistance, maxDistance);
}
/// <summary>Retail <c>PositionManager::UnConstrain</c>
/// (0x005552b0).</summary>
public void UnConstrain() => _constraint?.UnConstrain();
/// <summary>Retail <c>PositionManager::IsFullyConstrained</c>
/// (0x005552c0) — false when no constraint sub-manager exists.</summary>
public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false;
/// <summary>
/// Retail <c>PositionManager::HandleUpdateTarget</c> (0x005553d0) — only
/// the sticky sub-manager cares about live target positions (interpolation
/// and constraint don't). Fanned out from
/// <c>CPhysicsObj::HandleUpdateTarget</c>.
/// </summary>
public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info);
/// <summary>
/// Retail <c>PositionManager::adjust_offset</c> (0x00555190) — chains the
/// sub-managers' contributions into the SAME <paramref name="offset"/>
/// accumulator, in retail order. Retail runs Interpolation → Sticky →
/// Constraint; acdream's interpolation stays in the separate remote-motion
/// combiner (see class note), so this chains Sticky → Constraint only.
/// Constraint is LAST because it clamps the already-composed displacement.
/// </summary>
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
_sticky?.AdjustOffset(offset, quantum);
_constraint?.AdjustOffset(offset, quantum);
}
/// <summary>
/// Retail <c>PositionManager::UseTime</c> (0x00555160) — per-tick pump.
/// Retail order Interpolation → Constraint → Sticky; acdream runs the two
/// owned managers (constraint's UseTime is a retail no-op, so effectively
/// just the sticky 1 s watchdog).
/// </summary>
public void UseTime() => _sticky?.UseTime();
}

View file

@ -0,0 +1,235 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — verbatim port of retail's <c>StickyManager</c> (acclient.h:31518,
/// struct #3466; decomp block 0x00555400-0x00555866,
/// <c>r5-positionmanager-sticky-decomp.md</c>). Makes the owning object
/// FOLLOW a target object at a bounded gap: each tick
/// <see cref="AdjustOffset"/> steers the mover horizontally toward the
/// target's live (or last-known) position and turns it to face the target,
/// speed- and turn-rate-limited. A 1-second watchdog (<see cref="UseTime"/>)
/// drops the stick if no target-position update arrives.
///
/// <para>Owned by <see cref="PositionManager"/> (lazily created on first
/// <see cref="PositionManager.StickTo"/>). Establishes its target-tracking
/// subscription through the owning <see cref="IPhysicsObjHost"/>'s
/// <c>set_target</c> (→ <see cref="TargetManager"/>); receives the target's
/// live position back through <see cref="HandleUpdateTarget"/>, fanned out from
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>.</para>
///
/// <para>The dense x87 back half of retail's <c>adjust_offset</c> is decoded
/// against ACE's <c>StickyManager.cs</c> (the two constants
/// <see cref="StickyRadius"/>=0.3 and <see cref="StickyTime"/>=1.0 are ACE's,
/// verified against the retail mush structure — see the port-plan §2a).</para>
/// </summary>
public sealed class StickyManager
{
/// <summary>Retail <c>StickyRadius</c> const (ACE: 0.3f) — the desired
/// follow gap subtracted from the cylinder distance.</summary>
public const float StickyRadius = 0.3f;
/// <summary>Retail <c>StickyTime</c> const (ACE: 1.0f) — the one-shot grace
/// window: if no target update refreshes the stick within this many
/// seconds of <see cref="StickTo"/>, <see cref="UseTime"/> drops it.</summary>
public const float StickyTime = 1.0f;
/// <summary>Retail <c>get_max_speed</c> multiplier for the follow speed
/// (ACE: ×5 — the follower catches up faster than a normal walk/run).</summary>
private const float FollowSpeedFactor = 5.0f;
/// <summary>Retail fallback follow speed when no motion interpreter exists
/// (ACE: 15.0f, i.e. the <c>MAX_VELOCITY</c> constant the mush loads).</summary>
private const float FallbackFollowSpeed = 15.0f;
private readonly IPhysicsObjHost _host;
/// <summary>+0x00 retail <c>target_id</c> — the object we are stuck to
/// (0 = not stuck).</summary>
public uint TargetId { get; private set; }
/// <summary>+0x04 retail <c>target_radius</c> — the target's cylinder
/// radius (from <c>CPartArray::GetRadius</c> of the stuck-to object).</summary>
public float TargetRadius { get; private set; }
/// <summary>+0x08 retail <c>target_position</c> — last-known target
/// position (from <see cref="HandleUpdateTarget"/>), used when the live
/// <c>GetObjectA</c> resolve fails.</summary>
public Position TargetPosition { get; private set; }
/// <summary>+0x54 retail <c>initialized</c> — false until the first
/// <c>Ok</c> target update arrives (gates <see cref="AdjustOffset"/> and
/// the <see cref="UseTime"/> timeout).</summary>
public bool Initialized { get; private set; }
/// <summary>+0x58 retail <c>sticky_timeout_time</c> — the wall-clock
/// deadline set once at <see cref="StickTo"/> time (now + 1 s).</summary>
public double StickyTimeoutTime { get; private set; }
public StickyManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>
/// Retail <c>StickyManager::UnStick</c> (0x00555400). No-op if not stuck;
/// otherwise the standard 4-step teardown: clear <see cref="TargetId"/> +
/// <see cref="Initialized"/>, tell the host to <c>clear_target</c> (drop
/// the voyeur subscription), then <c>interrupt_current_movement</c>.
/// </summary>
public void UnStick()
{
if (TargetId == 0)
return;
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
/// <summary>
/// Retail <c>StickyManager::StickTo</c> (0x00555710). Begin following
/// <paramref name="objectId"/>. If already stuck, tears the old stick down
/// first (same 4-step sequence as <see cref="UnStick"/>). Sets the 1 s
/// timeout deadline and registers as a voyeur of the target via the host's
/// <c>set_target(context=0, objectId, radius=0.5, quantum=0.5)</c> — the
/// live target position then arrives asynchronously through
/// <see cref="HandleUpdateTarget"/>.
/// </summary>
/// <param name="objectId">Retail <c>arg2</c> — target object id.</param>
/// <param name="targetRadius">Retail <c>arg3</c> — the target's cylinder
/// radius (feeds <see cref="AdjustOffset"/>'s distance math).</param>
/// <param name="targetHeight">Retail <c>arg4</c> — accepted for call-shape
/// parity but UNUSED in the body (matches retail + ACE; the height feeds
/// the caller-side geometry only).</param>
public void StickTo(uint objectId, float targetRadius, float targetHeight)
{
_ = targetHeight; // retail/ACE: arg4 is read nowhere in this body.
if (TargetId != 0)
{
// Inlined 4-step teardown of the previous stick (retail 0x00555716).
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
TargetRadius = targetRadius;
TargetId = objectId;
Initialized = false;
StickyTimeoutTime = _host.CurTime + StickyTime;
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
_host.SetTarget(0, objectId, 0.5f, 0.5);
}
/// <summary>
/// Retail <c>StickyManager::UseTime</c> (0x00555610). The 1 s watchdog: if
/// <c>Timer::cur_time &gt;= sticky_timeout_time</c>, force-unstick (same
/// 4-step teardown). The deadline is set once in <see cref="StickTo"/> and
/// NOT refreshed by <see cref="HandleUpdateTarget"/> (retail + ACE) — a
/// stick survives at most 1 s of wall-clock unless re-issued.
/// </summary>
public void UseTime()
{
if (TargetId == 0)
return;
if (_host.CurTime >= StickyTimeoutTime)
{
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
}
/// <summary>
/// Retail <c>StickyManager::HandleUpdateTarget</c> (0x00555780). The
/// target-position callback (fanned out from
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>).
/// Ignores updates whose <see cref="TargetInfo.ObjectId"/> doesn't match
/// our <see cref="TargetId"/>. On <see cref="TargetStatus.Ok"/>: cache the
/// target position and mark <see cref="Initialized"/>. On any other status
/// (lost/exit/teleport): tear the stick down (4-step).
/// </summary>
public void HandleUpdateTarget(TargetInfo info)
{
if (info.ObjectId != TargetId)
return;
if (info.Status == TargetStatus.Ok)
{
Initialized = true;
TargetPosition = info.TargetPosition;
return;
}
if (TargetId != 0)
{
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
}
/// <summary>
/// Retail <c>StickyManager::adjust_offset</c> (0x00555430). Writes this
/// tick's follow steering into the shared <paramref name="offset"/>
/// accumulator: a speed-clamped horizontal position delta toward the
/// target plus a bounded turn to face it. No-op unless stuck AND
/// initialized. See port-plan §2a for the x87-mush decode.
/// </summary>
/// <param name="offset">The per-tick delta frame
/// (<see cref="PositionManager.AdjustOffset"/>'s shared accumulator).</param>
/// <param name="quantum">Elapsed time this tick, seconds.</param>
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
if (TargetId == 0 || !Initialized)
return;
var self = _host.Position;
var target = _host.GetObjectA(TargetId);
var targetPos = target != null ? target.Position : TargetPosition;
// offset = local-frame, Z-flattened vector from self to target.
Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset
Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset);
local.Z = 0f;
offset.Origin = local;
// Signed horizontal cylinder distance past the 0.3 m stick gap.
float dist = MoveToMath.CylinderDistanceNoZ(
_host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius;
if (MoveToMath.NormalizeCheckSmall(ref offset.Origin))
offset.Origin = Vector3.Zero;
// Follow speed = 5× own max locomotion speed (catch up), fallback 15.
float speed = 0f;
float? maxSpeed = _host.MinterpMaxSpeed;
if (maxSpeed.HasValue)
speed = maxSpeed.Value * FollowSpeedFactor;
if (speed < MoveToMath.Epsilon)
speed = FallbackFollowSpeed;
// Don't overshoot: clamp the per-tick step to the remaining (signed)
// distance — a negative dist inverts the direction (back off).
float delta = speed * (float)quantum;
if (delta >= MathF.Abs(dist))
delta = dist;
offset.Origin *= delta;
// Bounded turn to face the target (relative heading this tick).
float curHeading = MoveToMath.GetHeading(self.Frame.Orientation);
float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin);
float heading = targetHeading - curHeading;
if (MathF.Abs(heading) < MoveToMath.Epsilon)
heading = 0f;
if (heading < -MoveToMath.Epsilon)
heading += 360f;
offset.SetHeading(heading);
}
}

View file

@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — port of retail's <c>TargetManager</c> (acclient.h:31024, struct #3484;
/// decomp 0x0051a370-0x0051ad90, <c>r5-targetmanager-decomp.md</c>). A
/// peer-to-peer <b>voyeur subscription</b> system with two roles per object:
///
/// <list type="bullet">
/// <item><b>Watcher</b> (<see cref="TargetInfo"/>): <see cref="SetTarget"/>
/// registers this object as a voyeur ON a target; the target's per-tick
/// <see cref="HandleTargetting"/> pushes position updates back here via
/// <see cref="ReceiveUpdate"/>, which fans them to the owning host's
/// MoveToManager + PositionManager (sticky) through
/// <see cref="IPhysicsObjHost.HandleUpdateTarget"/>.</item>
/// <item><b>Watched</b> (<see cref="VoyeurTable"/>): other objects'
/// <see cref="AddVoyeur"/> subscribe to THIS object; each tick
/// <see cref="HandleTargetting"/> → <see cref="CheckAndUpdateVoyeur"/> sends a
/// dead-reckoned update to any subscriber the object has drifted past the
/// subscriber's radius from.</item>
/// </list>
///
/// <para>This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow
/// polling the entity table). It is a faithful superset: the same
/// move-to tracking (distance &gt; radius → HandleUpdateTarget(Ok)) plus the
/// correct sticky, 10 s timeout, and exit/teleport event handling.</para>
///
/// <para>Owned 1:1 by an <see cref="IPhysicsObjHost"/> (retail
/// <c>CPhysicsObj::target_manager</c>, lazily created on first
/// <c>set_target</c>/<c>add_voyeur</c>). The two throttle constants
/// (<see cref="ThrottleSeconds"/>=0.5, <see cref="StalenessSeconds"/>=10) are
/// ACE's, verified against the retail x87 mush — port-plan §2d.</para>
/// </summary>
public sealed class TargetManager
{
/// <summary>Retail <c>HandleTargetting</c> per-tick throttle (ACE: 0.5s) —
/// the voyeur sweep runs at most this often.</summary>
public const double ThrottleSeconds = 0.5;
/// <summary>Retail target-info staleness timeout (ACE: 10.0s) — an
/// Undefined-status target with no update for this long is marked
/// TimedOut.</summary>
public const double StalenessSeconds = 10.0;
private readonly IPhysicsObjHost _host;
private TargetInfo? _targetInfo; // retail target_info (watcher role)
private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role)
private double _lastUpdateTime; // retail last_update_time (throttle base)
public TargetManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>The current watched-target info, or null when tracking
/// nothing.</summary>
public TargetInfo? TargetInfo => _targetInfo;
/// <summary>The subscriber table (null until the first
/// <see cref="AddVoyeur"/>).</summary>
public IReadOnlyDictionary<uint, TargettedVoyeurInfo>? VoyeurTable => _voyeurTable;
/// <summary>Retail <c>get_target_quantum</c> — the current target's
/// quantum, 0 when tracking nothing.</summary>
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
// ── watcher role ───────────────────────────────────────────────────────
/// <summary>
/// Retail <c>TargetManager::SetTarget</c> (0x0051ac30). Tear down any
/// existing target, then: if <paramref name="objectId"/> is 0, synthesize a
/// TimedOut clear-update to the host and leave <see cref="TargetInfo"/>
/// null; otherwise create a fresh <see cref="TargetInfo"/> (status
/// Undefined) and subscribe as a voyeur ON the target
/// (<c>target.add_voyeur(self.id, radius, quantum)</c>). The target's live
/// position arrives asynchronously via <see cref="ReceiveUpdate"/>.
/// </summary>
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
{
ClearTarget();
if (objectId == 0)
{
// Clear/cancel: report a TimedOut update carrying the context,
// leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.)
var cleared = new TargetInfo(
ObjectId: 0, Status: TargetStatus.TimedOut,
TargetPosition: default, InterpolatedPosition: default,
ContextId: contextId);
_host.HandleUpdateTarget(cleared);
return;
}
_targetInfo = new TargetInfo(
ObjectId: objectId, Status: TargetStatus.Undefined,
TargetPosition: default, InterpolatedPosition: default,
ContextId: contextId, Radius: radius, Quantum: quantum,
LastUpdateTime: _host.CurTime);
var target = _host.GetObjectA(objectId);
target?.AddVoyeur(_host.Id, radius, quantum);
}
/// <summary>
/// Retail <c>TargetManager::SetTargetQuantum</c> (0x0051a4a0). Update the
/// current target's resend interval and re-register the voyeur subscription
/// on the target with the new quantum.
/// </summary>
public void SetTargetQuantum(double quantum)
{
if (_targetInfo is not { } ti)
return;
_targetInfo = ti with { Quantum = quantum };
var target = _host.GetObjectA(ti.ObjectId);
target?.AddVoyeur(_host.Id, ti.Radius, quantum);
}
/// <summary>
/// Retail <c>TargetManager::ClearTarget</c> (0x0051a7e0). Unsubscribe from
/// the current target's voyeur table and drop <see cref="TargetInfo"/>.
/// </summary>
public void ClearTarget()
{
if (_targetInfo is not { } ti)
return;
var target = _host.GetObjectA(ti.ObjectId);
target?.RemoveVoyeur(_host.Id);
_targetInfo = null;
}
/// <summary>
/// Retail <c>TargetManager::ReceiveUpdate</c> (0x0051a930). The inbound
/// handler when a target we watch sends us its position (via
/// <c>SendVoyeurUpdate</c> → <c>receive_target_update</c>). Ignores updates
/// for anything but our current target. Copies the payload, stamps receipt
/// time, recomputes the self→target interpolated heading (falls back to +Z
/// when degenerate), fans the snapshot to the host, and drops the
/// subscription on an ExitWorld status.
/// </summary>
public void ReceiveUpdate(TargetInfo update)
{
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId)
return;
// Copy radius/quantum/positions/velocity/status from the wire; keep our
// object_id; stamp receipt time.
Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin
- _host.Position.Frame.Origin;
if (MoveToMath.NormalizeCheckSmall(ref interpHeading))
interpHeading = Vector3.UnitZ;
var updated = ti with
{
Radius = update.Radius,
Quantum = update.Quantum,
TargetPosition = update.TargetPosition,
InterpolatedPosition = update.InterpolatedPosition,
Velocity = update.Velocity,
Status = update.Status,
InterpolatedHeading = interpHeading,
LastUpdateTime = _host.CurTime,
};
_targetInfo = updated;
_host.HandleUpdateTarget(updated);
if (update.Status == TargetStatus.ExitWorld)
ClearTarget();
}
// ── watched role ───────────────────────────────────────────────────────
/// <summary>
/// Retail <c>TargetManager::AddVoyeur</c> (0x0051a830). A subscriber
/// registers to watch this object. If already subscribed, updates its
/// radius/quantum in place (no immediate send); otherwise creates the entry
/// and pushes an immediate initial snapshot (<c>Ok</c>).
/// </summary>
public void AddVoyeur(uint watcherId, float radius, double quantum)
{
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
if (_voyeurTable.TryGetValue(watcherId, out var existing))
{
existing.Radius = radius;
existing.Quantum = quantum;
return;
}
var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum);
_voyeurTable[watcherId] = voyeur;
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
}
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
public bool RemoveVoyeur(uint watcherId)
=> _voyeurTable?.Remove(watcherId) ?? false;
/// <summary>
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
/// driver (no separate <c>UseTime</c>): self-throttled to
/// <see cref="ThrottleSeconds"/>, promotes a stale target to TimedOut after
/// <see cref="StalenessSeconds"/>, then sweeps every subscriber through
/// <see cref="CheckAndUpdateVoyeur"/>.
/// </summary>
public void HandleTargetting()
{
if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds)
return;
if (_targetInfo is { } ti)
{
if (ti.Status == TargetStatus.Undefined
&& ti.LastUpdateTime + StalenessSeconds < _host.CurTime)
{
var timedOut = ti with { Status = TargetStatus.TimedOut };
_targetInfo = timedOut;
_host.HandleUpdateTarget(timedOut);
}
}
if (_voyeurTable != null)
{
foreach (var voyeur in _voyeurTable.Values.ToList())
CheckAndUpdateVoyeur(voyeur);
}
_lastUpdateTime = _host.PhysicsTimerTime;
}
/// <summary>
/// Retail <c>TargetManager::CheckAndUpdateVoyeur</c> (0x0051a650). Push an
/// update to <paramref name="voyeur"/> only if this object's dead-reckoned
/// position has drifted more than the voyeur's radius since the last send.
/// </summary>
public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)
{
Position newPos = GetInterpolatedPosition(voyeur.Quantum);
float drift = Vector3.Distance(
newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin);
if (drift > voyeur.Radius)
SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok);
}
/// <summary>
/// Retail <c>TargetManager::GetInterpolatedPosition</c> (0x0051a5e0).
/// Dead-reckon this object's position forward by <paramref name="quantum"/>
/// seconds using its current velocity.
/// </summary>
public Position GetInterpolatedPosition(double quantum)
{
var pos = _host.Position;
Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum;
return new Position(pos.ObjCellId, origin, pos.Frame.Orientation);
}
/// <summary>
/// Retail <c>TargetManager::SendVoyeurUpdate</c> (0x0051a4f0). Record the
/// sent position on the voyeur, build a <see cref="TargetInfo"/> carrying
/// this object's CURRENT authoritative position + the extrapolated
/// <paramref name="pos"/> + velocity + status, and deliver it to the
/// subscriber's <see cref="ReceiveUpdate"/> (via its host).
/// </summary>
public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)
{
voyeur.LastSentPosition = pos;
var info = new TargetInfo(
ObjectId: _host.Id,
Status: status,
TargetPosition: _host.Position, // current authoritative
InterpolatedPosition: pos, // the extrapolated position
ContextId: 0,
Radius: voyeur.Radius,
Quantum: voyeur.Quantum,
Velocity: _host.Velocity);
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
voyeurObj?.ReceiveTargetUpdate(info);
}
/// <summary>
/// Retail <c>TargetManager::NotifyVoyeurOfEvent</c> (0x0051a6f0). Broadcast
/// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber
/// with this object's CURRENT position — no distance gate.
/// </summary>
public void NotifyVoyeurOfEvent(TargetStatus status)
{
if (_voyeurTable == null)
return;
foreach (var voyeur in _voyeurTable.Values.ToList())
SendVoyeurUpdate(voyeur, _host.Position, status);
}
}

View file

@ -0,0 +1,36 @@
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — port of retail's <c>TargettedVoyeurInfo</c> (acclient.h:52807,
/// struct #5801). One entry in a <see cref="TargetManager"/>'s voyeur table:
/// a subscriber watching THIS object, the send-on-move <see cref="Radius"/>
/// threshold it registered, its dead-reckoning <see cref="Quantum"/>, and the
/// <see cref="LastSentPosition"/> already delivered to it (the delta baseline
/// <c>CheckAndUpdateVoyeur</c> compares against). Mutable class (retail heap
/// record updated in place by <c>AddVoyeur</c>/<c>SendVoyeurUpdate</c>).
/// </summary>
public sealed class TargettedVoyeurInfo
{
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
public uint ObjectId { get; }
/// <summary>+0x04 retail <c>quantum</c> — the subscriber's dead-reckoning
/// lookahead horizon (seconds).</summary>
public double Quantum { get; set; }
/// <summary>+0x10 retail <c>radius</c> — the send-on-move threshold: an
/// update is pushed only when the tracked object drifts more than this from
/// <see cref="LastSentPosition"/>.</summary>
public float Radius { get; set; }
/// <summary>+0x14 retail <c>last_sent_position</c> — the position last
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
public Position LastSentPosition { get; set; }
public TargettedVoyeurInfo(uint objectId, float radius, double quantum)
{
ObjectId = objectId;
Radius = radius;
Quantum = quantum;
}
}

View file

@ -17,8 +17,16 @@ namespace AcDream.Core.Physics;
/// active locomotion cycle). We rotate that by the body's orientation /// active locomotion cycle). We rotate that by the body's orientation
/// to get a world-space delta, then add the InterpolationManager's /// to get a world-space delta, then add the InterpolationManager's
/// world-space correction. /// world-space correction.
///
/// <para><b>Renamed R5</b> (was <c>PositionManager</c>): this class is only the
/// InterpolationManager-composition portion of retail's
/// <c>PositionManager::adjust_offset</c> — NOT the retail PositionManager
/// facade. The faithful facade (Sticky/Constraint, owned per entity) is
/// <see cref="Motion.PositionManager"/>. The name was freed to remove the
/// ambiguity that broke every file importing both
/// <c>AcDream.Core.Physics</c> and <c>AcDream.Core.Physics.Motion</c>.</para>
/// </summary> /// </summary>
public sealed class PositionManager public sealed class RemoteMotionCombiner
{ {
/// <summary> /// <summary>
/// Compute the per-frame world-space delta to add to body.Position. /// Compute the per-frame world-space delta to add to body.Position.

View file

@ -0,0 +1,133 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance — <see cref="ConstraintManager"/> (retail 0x00556090-…).
/// The leash-band taper + the 90 % IsFullyConstrained jump gate (port-plan §2b/§2c).
/// </summary>
public sealed class ConstraintManagerTests
{
private static (R5Host host, ConstraintManager cm) Setup()
{
var world = new Dictionary<uint, R5Host>();
var host = new R5Host(10u, world);
return (host, new ConstraintManager(host));
}
private static Position Anchor(float x) => new(1u, new Vector3(x, 0f, 0f), Quaternion.Identity);
[Fact]
public void ConstrainTo_InitializesOffsetToCurrentDistanceFromAnchor()
{
var (host, cm) = Setup();
host.SetOrigin(Vector3.Zero);
cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f);
Assert.True(cm.IsConstrained);
Assert.Equal(5f, cm.ConstraintPosOffset, 3); // distance(anchor(5,0,0), self(0,0,0))
}
[Fact]
public void IsFullyConstrained_TrueOnlyBeyond90PercentOfMax()
{
var (host, cm) = Setup();
host.SetOrigin(Vector3.Zero);
cm.ConstrainTo(Anchor(8f), 2f, 10f); // offset 8, 90% of 10 = 9 → 8 < 9
Assert.False(cm.IsFullyConstrained());
cm.ConstrainTo(Anchor(9.5f), 2f, 10f); // offset 9.5 > 9
Assert.True(cm.IsFullyConstrained());
}
[Fact]
public void IsFullyConstrained_FalseWhenNotConstrainedYet()
{
var (_, cm) = Setup();
Assert.False(cm.IsFullyConstrained()); // max 0 → 0 < 0 is false
}
[Fact]
public void AdjustOffset_InBand_AppliesLinearTaper()
{
var (host, cm) = Setup();
host.SetOrigin(Vector3.Zero);
host.InContact = true;
cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f); // offset 5 in (2,10)
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
cm.AdjustOffset(frame, 0.1);
// taper = (10-5)/(10-2) = 5/8 = 0.625.
Assert.Equal(0.625f, frame.Origin.X, 3);
Assert.Equal(0.625f, cm.ConstraintPosOffset, 3); // recomputed = |offset|
}
[Fact]
public void AdjustOffset_PastMax_HardClampsToZero()
{
var (host, cm) = Setup();
host.SetOrigin(Vector3.Zero);
host.InContact = true;
cm.ConstrainTo(Anchor(20f), 2f, 10f); // offset 20 >= max 10
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
cm.AdjustOffset(frame, 0.1);
Assert.Equal(Vector3.Zero, frame.Origin);
}
[Fact]
public void AdjustOffset_BelowStart_PassesThrough()
{
var (host, cm) = Setup();
host.SetOrigin(Vector3.Zero);
host.InContact = true;
cm.ConstrainTo(Anchor(1f), startDistance: 2f, maxDistance: 10f); // offset 1 < start 2
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
cm.AdjustOffset(frame, 0.1);
Assert.Equal(1f, frame.Origin.X, 3); // unscaled
Assert.Equal(1f, cm.ConstraintPosOffset, 3);
}
[Fact]
public void AdjustOffset_Airborne_SkipsClampButStillTracksLength()
{
var (host, cm) = Setup();
host.SetOrigin(Vector3.Zero);
host.InContact = false; // airborne
cm.ConstrainTo(Anchor(20f), 2f, 10f); // would clamp if grounded
var frame = new MotionDeltaFrame { Origin = new Vector3(3f, 4f, 0f) };
cm.AdjustOffset(frame, 0.1);
Assert.Equal(new Vector3(3f, 4f, 0f), frame.Origin); // untouched while airborne
Assert.Equal(5f, cm.ConstraintPosOffset, 3); // |(3,4,0)| = 5, still updated
}
[Fact]
public void AdjustOffset_NotConstrained_IsNoOp()
{
var (host, cm) = Setup();
var frame = new MotionDeltaFrame { Origin = new Vector3(7f, 0f, 0f) };
cm.AdjustOffset(frame, 0.1);
Assert.Equal(new Vector3(7f, 0f, 0f), frame.Origin);
}
[Fact]
public void UnConstrain_ClearsFlag()
{
var (host, cm) = Setup();
cm.ConstrainTo(Anchor(5f), 2f, 10f);
cm.UnConstrain();
Assert.False(cm.IsConstrained);
}
}

View file

@ -0,0 +1,112 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance — <see cref="PositionManager"/> facade (retail
/// 0x00555160-0x005553d0). Lazy sub-manager creation + fan-out. (Test class is
/// suffixed "Facade" to read distinctly from the renamed
/// <c>RemoteMotionCombiner</c> combiner tests.)
/// </summary>
public sealed class PositionManagerFacadeTests
{
private static (R5Host self, R5Host target, PositionManager pm) Setup()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(20u, world);
return (self, target, new PositionManager(self));
}
[Fact]
public void UnStick_BeforeAnyStick_IsNoOp()
{
var (_, _, pm) = Setup();
pm.UnStick(); // no sticky manager yet — must not throw
Assert.Null(pm.Sticky);
Assert.Equal(0u, pm.GetStickyObjectId());
}
[Fact]
public void StickTo_LazilyCreatesSticky_AndForwards()
{
var (self, target, pm) = Setup();
pm.StickTo(target.Id, radius: 0.5f, height: 1.0f);
Assert.NotNull(pm.Sticky);
Assert.Equal(target.Id, pm.GetStickyObjectId());
}
[Fact]
public void IsFullyConstrained_FalseWhenNoConstraintManager()
{
var (_, _, pm) = Setup();
Assert.False(pm.IsFullyConstrained());
Assert.Null(pm.Constraint);
}
[Fact]
public void ConstrainTo_LazilyCreatesConstraint()
{
var (self, _, pm) = Setup();
self.SetOrigin(Vector3.Zero);
pm.ConstrainTo(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), 2f, 10f);
Assert.NotNull(pm.Constraint);
Assert.True(pm.Constraint!.IsConstrained);
}
[Fact]
public void HandleUpdateTarget_ForwardsToSticky()
{
var (self, target, pm) = Setup();
pm.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity);
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
Assert.True(pm.Sticky!.Initialized);
}
[Fact]
public void AdjustOffset_ChainsStickyThenConstraint()
{
var (self, target, pm) = Setup();
self.Radius = 0.5f;
self.MinterpMaxSpeed = 1.0f;
self.InContact = true;
// Sticky toward +X: produces an offset the constraint then tapers.
pm.StickTo(target.Id, 0.5f, 1.0f);
target.SetOrigin(new Vector3(5f, 0f, 0f));
var tp = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity);
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
// Arm a constraint whose band tapers by 0.5.
self.SetOrigin(Vector3.Zero);
pm.ConstrainTo(new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity),
startDistance: 2f, maxDistance: 10f); // offset 6 → taper (10-6)/(10-2)=0.5
var frame = new MotionDeltaFrame();
pm.AdjustOffset(frame, quantum: 0.1);
// Sticky writes +0.5 X; constraint tapers by 0.5 → 0.25.
Assert.Equal(0.25f, frame.Origin.X, 3);
}
[Fact]
public void UseTime_DrivesStickyTimeout()
{
var (self, target, pm) = Setup();
self.CurTime = 100.0;
pm.StickTo(target.Id, 0.5f, 1.0f); // deadline 101
self.CurTime = 101.0;
pm.UseTime();
Assert.Equal(0u, pm.GetStickyObjectId()); // unstuck
}
}

View file

@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance harness — a scriptable <see cref="IPhysicsObjHost"/> fake
/// backed by a shared <see cref="World"/> so <see cref="GetObjectA"/> resolves
/// OTHER hosts (the cross-entity seam the voyeur round-trip needs). Each host
/// lazily owns a <see cref="TargetManager"/> and a <see cref="PositionManager"/>
/// (the two R5 managers under test), and records every
/// <see cref="HandleUpdateTarget"/> the managers fan out so tests can assert on
/// delivery.
///
/// <para>Position/velocity/radius/contact/max-speed and both clocks are mutable
/// fields tests drive directly (retail's <c>CPhysicsObj</c> accessors). The
/// <c>set_target</c>/<c>clear_target</c>/<c>add_voyeur</c>/<c>remove_voyeur</c>/
/// <c>receive_target_update</c> seams forward to the owned
/// <see cref="TargetManager"/> exactly as retail's <c>CPhysicsObj</c> does.</para>
/// </summary>
internal sealed class R5Host : IPhysicsObjHost
{
public readonly Dictionary<uint, R5Host> World;
public R5Host(uint id, Dictionary<uint, R5Host> world)
{
Id = id;
World = world;
World[id] = this;
}
// ── scriptable CPhysicsObj state ───────────────────────────────────────
public uint Id { get; }
public Position Position { get; set; } = new(1u, Vector3.Zero, Quaternion.Identity);
public Vector3 Velocity { get; set; } = Vector3.Zero;
public float Radius { get; set; } = 0.5f;
public bool InContact { get; set; } = true;
public float? MinterpMaxSpeed { get; set; } = 1.0f;
public double CurTime { get; set; }
public double PhysicsTimerTime { get; set; }
/// <summary>Set to false to simulate a target that isn't currently
/// resolvable (out of streaming view) — <see cref="GetObjectA"/> returns
/// null for it even while it's in <see cref="World"/>.</summary>
public bool Resolvable { get; set; } = true;
// ── owned R5 managers ──────────────────────────────────────────────────
private TargetManager? _targetManager;
public TargetManager TargetManager => _targetManager ??= new TargetManager(this);
public TargetManager? TargetManagerOrNull => _targetManager;
private PositionManager? _positionManager;
public PositionManager PositionManager => _positionManager ??= new PositionManager(this);
// ── recorded fan-outs ──────────────────────────────────────────────────
public readonly List<TargetInfo> HandleUpdateTargetCalls = new();
public int InterruptCurrentMovementCalls;
// ── IPhysicsObjHost ────────────────────────────────────────────────────
public IPhysicsObjHost? GetObjectA(uint id)
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
public void HandleUpdateTarget(TargetInfo info)
{
HandleUpdateTargetCalls.Add(info);
// Retail CPhysicsObj::HandleUpdateTarget also fans to the position
// manager's sticky sub-manager (context 0). Mirror that so sticky tests
// that rely on the full CPhysicsObj fan-out see the callback.
if (info.ContextId == 0)
_positionManager?.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => InterruptCurrentMovementCalls++;
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> TargetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager?.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> TargetManager.AddVoyeur(watcherId, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId);
// ── test helpers ───────────────────────────────────────────────────────
public void SetOrigin(Vector3 origin)
=> Position = new Position(Position.ObjCellId, origin, Position.Frame.Orientation);
public void AdvanceClocks(double seconds)
{
CurTime += seconds;
PhysicsTimerTime += seconds;
}
}

View file

@ -0,0 +1,220 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance — <see cref="StickyManager"/> (retail 0x00555400-0x00555866).
/// Lifecycle (StickTo/UnStick/timeout/HandleUpdateTarget) + the decoded
/// <c>adjust_offset</c> steering math (port-plan §2a).
/// </summary>
public sealed class StickyManagerTests
{
private static (R5Host self, R5Host target, StickyManager sticky) Setup(
uint targetId = 20u, float targetRadius = 0.5f)
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(targetId, world);
var sticky = new StickyManager(self);
return (self, target, sticky);
}
private static StickyManager StuckAndInitialized(
R5Host self, R5Host target, Vector3 targetOrigin, float targetRadius = 0.5f)
{
var sticky = new StickyManager(self);
sticky.StickTo(target.Id, targetRadius, targetHeight: 1.0f);
target.SetOrigin(targetOrigin);
var tp = new Position(1u, targetOrigin, Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
return sticky;
}
[Fact]
public void StickTo_SetsTargetAndTimeout_AndSubscribesVoyeurOnTarget()
{
var (self, target, sticky) = Setup();
self.CurTime = 100.0;
sticky.StickTo(target.Id, targetRadius: 0.5f, targetHeight: 2.0f);
Assert.Equal(target.Id, sticky.TargetId);
Assert.Equal(0.5f, sticky.TargetRadius);
Assert.False(sticky.Initialized);
Assert.Equal(101.0, sticky.StickyTimeoutTime); // now + StickyTime(1.0)
// set_target(0, target, 0.5, 0.5) → watcher subscribes ON the target.
Assert.NotNull(target.TargetManagerOrNull);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
}
[Fact]
public void HandleUpdateTarget_Ok_MarksInitializedAndCachesPosition()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
Assert.True(sticky.Initialized);
Assert.Equal(new Vector3(3f, 0f, 0f), sticky.TargetPosition.Frame.Origin);
}
[Fact]
public void HandleUpdateTarget_ForeignObject_Ignored()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, Vector3.Zero, Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(999u, TargetStatus.Ok, tp, tp));
Assert.False(sticky.Initialized);
Assert.Equal(target.Id, sticky.TargetId); // still stuck to the real target
}
[Fact]
public void HandleUpdateTarget_NonOkStatus_TearsDown()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f);
int interruptsBefore = self.InterruptCurrentMovementCalls;
var tp = new Position(1u, Vector3.Zero, Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.ExitWorld, tp, tp));
Assert.Equal(0u, sticky.TargetId);
Assert.False(sticky.Initialized);
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls);
}
[Fact]
public void UseTime_BeforeDeadline_KeepsStick()
{
var (self, target, sticky) = Setup();
self.CurTime = 100.0;
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
self.CurTime = 100.9;
sticky.UseTime();
Assert.Equal(target.Id, sticky.TargetId);
}
[Fact]
public void UseTime_AtDeadline_UnsticksAndInterrupts()
{
var (self, target, sticky) = Setup();
self.CurTime = 100.0;
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
int interruptsBefore = self.InterruptCurrentMovementCalls;
self.CurTime = 101.0;
sticky.UseTime();
Assert.Equal(0u, sticky.TargetId);
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls);
}
[Fact]
public void ReStick_TearsDownPreviousBeforeSettingNew()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var a = new R5Host(20u, world);
var b = new R5Host(21u, world);
var sticky = new StickyManager(self);
sticky.StickTo(a.Id, 0.5f, 1.0f);
int interruptsBefore = self.InterruptCurrentMovementCalls;
sticky.StickTo(b.Id, 0.5f, 1.0f);
Assert.Equal(b.Id, sticky.TargetId);
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls); // old stick torn down
}
[Fact]
public void AdjustOffset_NotInitialized_IsNoOp()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f); // no HandleUpdateTarget → not initialized
var frame = new MotionDeltaFrame { Origin = new Vector3(9f, 9f, 9f) };
sticky.AdjustOffset(frame, 0.1);
Assert.Equal(new Vector3(9f, 9f, 9f), frame.Origin); // untouched
}
[Fact]
public void AdjustOffset_SteersTowardTarget_ClampedToStep()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
var sticky = StuckAndInitialized(self, target, new Vector3(5f, 0f, 0f));
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
// dir = +X (east); speed = 1.0 * 5 = 5; delta = 5 * 0.1 = 0.5 (< dist 3.7).
Assert.Equal(0.5f, frame.Origin.X, 3);
Assert.Equal(0f, frame.Origin.Y, 3);
Assert.Equal(0f, frame.Origin.Z, 3); // horizontal-only
// heading toward +X (east) = 90° compass.
Assert.Equal(90f, frame.GetHeading(), 1);
}
[Fact]
public void AdjustOffset_TooClose_BacksOff_SignedDistance()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
// centerDist 0.9, cyl = 0.9-0.5-0.5 = -0.1, minus 0.3 → dist = -0.4.
var sticky = StuckAndInitialized(self, target, new Vector3(0.9f, 0f, 0f));
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
// delta = 5*0.1 = 0.5 >= |dist|=0.4 → delta = dist = -0.4; dir +X * -0.4 → back off (-X).
Assert.Equal(-0.4f, frame.Origin.X, 3);
}
[Fact]
public void AdjustOffset_UsesCachedPosition_WhenTargetUnresolvable()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
var sticky = new StickyManager(self);
sticky.StickTo(target.Id, 0.5f, 1.0f);
// Cache a position via HandleUpdateTarget, then make the target vanish.
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
Assert.Equal(0.5f, frame.Origin.X, 3); // still steers toward cached (4,0,0)
}
[Fact]
public void AdjustOffset_NoMinterp_UsesFallbackSpeed()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = null };
var target = new R5Host(20u, world);
var sticky = StuckAndInitialized(self, target, new Vector3(50f, 0f, 0f));
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
// fallback speed 15 → delta = 15 * 0.1 = 1.5 (dist ≈ 49 → not clamped).
Assert.Equal(1.5f, frame.Origin.X, 3);
}
}

View file

@ -0,0 +1,255 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance — <see cref="TargetManager"/> voyeur system (retail
/// 0x0051a370-0x0051ad90). Watcher role (SetTarget/ReceiveUpdate/timeout),
/// watched role (AddVoyeur/HandleTargetting/CheckAndUpdateVoyeur), and the full
/// cross-entity round-trip (port-plan §2d/§2e). Replaces the AP-79 adapter.
/// </summary>
public sealed class TargetManagerTests
{
private static (R5Host self, R5Host target, Dictionary<uint, R5Host> world) TwoHosts()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(20u, world);
return (self, target, world);
}
// ── watcher role ───────────────────────────────────────────────────────
[Fact]
public void SetTarget_SubscribesOnTarget_AndReceivesImmediateSnapshot()
{
var (self, target, _) = TwoHosts();
target.SetOrigin(new Vector3(3f, 0f, 0f));
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
// Watcher subscribed on the target.
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
// Immediate Ok snapshot fanned to the watcher's host.
Assert.Single(self.HandleUpdateTargetCalls);
Assert.Equal(TargetStatus.Ok, self.HandleUpdateTargetCalls[0].Status);
Assert.Equal(target.Id, self.HandleUpdateTargetCalls[0].ObjectId);
Assert.Equal(new Vector3(3f, 0f, 0f),
self.HandleUpdateTargetCalls[0].InterpolatedPosition.Frame.Origin);
Assert.Equal(TargetStatus.Ok, self.TargetManager.TargetInfo!.Value.Status);
}
[Fact]
public void SetTarget_Zero_SynthesizesTimedOut_LeavesTargetInfoNull()
{
var (self, _, _) = TwoHosts();
self.TargetManager.SetTarget(contextId: 7, objectId: 0, radius: 1.0f, quantum: 0.0);
Assert.Null(self.TargetManager.TargetInfo);
Assert.Single(self.HandleUpdateTargetCalls);
Assert.Equal(TargetStatus.TimedOut, self.HandleUpdateTargetCalls[0].Status);
Assert.Equal(7u, self.HandleUpdateTargetCalls[0].ContextId);
}
[Fact]
public void ClearTarget_UnsubscribesFromTarget()
{
var (self, target, _) = TwoHosts();
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
self.TargetManager.ClearTarget();
Assert.Null(self.TargetManager.TargetInfo);
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
}
[Fact]
public void ReceiveUpdate_ForWrongObject_Ignored()
{
var (self, target, _) = TwoHosts();
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
int before = self.HandleUpdateTargetCalls.Count;
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p));
Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
}
[Fact]
public void ReceiveUpdate_ExitWorld_ClearsTarget()
{
var (self, target, _) = TwoHosts();
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p));
Assert.Null(self.TargetManager.TargetInfo); // cleared
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
}
[Fact]
public void ReceiveUpdate_ComputesInterpolatedHeadingTowardTarget()
{
var (self, target, _) = TwoHosts();
self.SetOrigin(Vector3.Zero);
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
// self→target interp position (0,5,0) normalized = +Y.
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
Assert.Equal(0f, heading.X, 3);
Assert.Equal(1f, heading.Y, 3);
}
[Fact]
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
{
var (self, target, _) = TwoHosts();
target.Resolvable = false; // no immediate snapshot → status stays Undefined
self.CurTime = 0;
self.PhysicsTimerTime = 0;
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
Assert.Equal(TargetStatus.Undefined, self.TargetManager.TargetInfo!.Value.Status);
self.AdvanceClocks(11.0);
self.TargetManager.HandleTargetting();
Assert.Equal(TargetStatus.TimedOut, self.TargetManager.TargetInfo!.Value.Status);
Assert.Contains(self.HandleUpdateTargetCalls, c => c.Status == TargetStatus.TimedOut);
}
// ── watched role + gates ────────────────────────────────────────────────
[Fact]
public void HandleTargetting_Throttles_Within500ms()
{
var (self, target, _) = TwoHosts();
var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0);
// seed a voyeur directly + advance so a sweep WOULD send if not throttled.
target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0);
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
target.PhysicsTimerTime = 0.3; // < 0.5 throttle
int before = self.HandleUpdateTargetCalls.Count;
target.TargetManager.HandleTargetting();
Assert.Equal(before, self.HandleUpdateTargetCalls.Count); // throttled, no sweep
}
[Fact]
public void CheckAndUpdateVoyeur_SendsOnlyPastRadius()
{
var (self, target, _) = TwoHosts();
// self must have a matching target_info for ReceiveUpdate to record.
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
int afterSubscribe = self.HandleUpdateTargetCalls.Count; // includes immediate snapshot
// small move → within radius → no send
target.SetOrigin(new Vector3(0.5f, 0f, 0f));
target.AdvanceClocks(1.0);
target.TargetManager.HandleTargetting();
Assert.Equal(afterSubscribe, self.HandleUpdateTargetCalls.Count);
// large move → past radius → send
target.SetOrigin(new Vector3(2f, 0f, 0f));
target.AdvanceClocks(1.0);
target.TargetManager.HandleTargetting();
Assert.Equal(afterSubscribe + 1, self.HandleUpdateTargetCalls.Count);
var last = self.HandleUpdateTargetCalls[^1];
Assert.Equal(TargetStatus.Ok, last.Status);
Assert.Equal(2f, last.InterpolatedPosition.Frame.Origin.X, 3);
}
[Fact]
public void GetInterpolatedPosition_DeadReckonsWithVelocity()
{
var (_, target, _) = TwoHosts();
target.SetOrigin(new Vector3(1f, 2f, 0f));
target.Velocity = new Vector3(10f, 0f, 0f);
var p = target.TargetManager.GetInterpolatedPosition(quantum: 0.5);
Assert.Equal(new Vector3(6f, 2f, 0f), p.Frame.Origin); // 1 + 10*0.5 = 6
}
[Fact]
public void AddVoyeur_Existing_UpdatesInPlace_NoResend()
{
var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero);
target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0);
var voyeur = target.TargetManager.VoyeurTable![self.Id];
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
target.SetOrigin(new Vector3(5f, 0f, 0f));
target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only
Assert.Equal(2.5f, voyeur.Radius);
Assert.Equal(0.3, voyeur.Quantum);
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); // NOT re-sent
}
[Fact]
public void RemoveVoyeur_RemovesEntry()
{
var (self, target, _) = TwoHosts();
target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0);
Assert.True(target.TargetManager.RemoveVoyeur(self.Id));
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone
}
[Fact]
public void NotifyVoyeurOfEvent_BroadcastsToAll_NoDistanceGate()
{
var world = new Dictionary<uint, R5Host>();
var target = new R5Host(20u, world);
var w1 = new R5Host(10u, world);
var w2 = new R5Host(11u, world);
// both watch the target (each gets a target_info via SetTarget)
w1.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
w2.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
int w1Before = w1.HandleUpdateTargetCalls.Count;
int w2Before = w2.HandleUpdateTargetCalls.Count;
target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
Assert.Equal(w1Before + 1, w1.HandleUpdateTargetCalls.Count);
Assert.Equal(w2Before + 1, w2.HandleUpdateTargetCalls.Count);
Assert.Equal(TargetStatus.Teleported, w1.HandleUpdateTargetCalls[^1].Status);
}
// ── full round-trip ─────────────────────────────────────────────────────
[Fact]
public void RoundTrip_WatcherTracksMovingTarget()
{
var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero);
// Watcher sets target with a 1.0 radius (moveto-style, quantum 0).
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
Assert.Single(self.HandleUpdateTargetCalls); // immediate snapshot at (0,0,0)
// Target walks to (3,0,0); its per-tick HandleTargetting notices the
// drift and pushes an update the watcher receives.
target.SetOrigin(new Vector3(3f, 0f, 0f));
target.AdvanceClocks(1.0);
target.TargetManager.HandleTargetting();
Assert.Equal(2, self.HandleUpdateTargetCalls.Count);
Assert.Equal(new Vector3(3f, 0f, 0f),
self.HandleUpdateTargetCalls[^1].InterpolatedPosition.Frame.Origin);
// The watcher's cached target position tracks the target.
Assert.Equal(3f, self.TargetManager.TargetInfo!.Value.InterpolatedPosition.Frame.Origin.X, 3);
}
}

View file

@ -6,18 +6,19 @@ using Xunit;
namespace AcDream.Core.Tests.Physics; namespace AcDream.Core.Tests.Physics;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// PositionManagerTests — 6 tests covering ComputeOffset. // RemoteMotionCombinerTests — 6 tests covering ComputeOffset (class renamed R5
// from PositionManager; see RemoteMotionCombiner's class doc).
// //
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730). // Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
// Pure-function combiner: animation root motion (seqVel × dt, rotated by // Pure-function combiner: animation root motion (seqVel × dt, rotated by
// body orientation) + InterpolationManager.AdjustOffset correction. // body orientation) + InterpolationManager.AdjustOffset correction.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
public sealed class PositionManagerTests public sealed class RemoteMotionCombinerTests
{ {
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
private static PositionManager Make() => new(); private static RemoteMotionCombiner Make() => new();
private static InterpolationManager EmptyInterp() => new(); private static InterpolationManager EmptyInterp() => new();