From 3d89446d9855b596385738cb30e319fc3d02a765 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 19:34:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20R5-V1=20=E2=80=94=20port=20Pos?= =?UTF-8?q?itionManager/Sticky/Constraint=20+=20TargetManager=20(Core,=20u?= =?UTF-8?q?nwired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../r5-acdream-seams.md | 272 +++ .../2026-07-03-r5-managers/r5-ace-crossref.md | 332 ++++ .../r5-constraintmanager-decomp.md | 679 ++++++++ .../r5-movementmanager-decomp.md | 1015 +++++++++++ .../2026-07-03-r5-managers/r5-port-plan.md | 338 ++++ .../r5-positionmanager-sticky-decomp.md | 1533 +++++++++++++++++ .../r5-targetmanager-decomp.md | 1256 ++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 4 +- .../Physics/AnimationSequencer.cs | 2 +- .../Physics/Motion/ConstraintManager.cs | 120 ++ .../Physics/Motion/IPhysicsObjHost.cs | 100 ++ .../Physics/Motion/MotionDeltaFrame.cs | 40 + .../Physics/Motion/MoveToManager.cs | 40 +- src/AcDream.Core/Physics/Motion/MoveToMath.cs | 52 + .../Physics/Motion/PositionManager.cs | 102 ++ .../Physics/Motion/StickyManager.cs | 235 +++ .../Physics/Motion/TargetManager.cs | 300 ++++ .../Physics/Motion/TargettedVoyeurInfo.cs | 36 + ...tionManager.cs => RemoteMotionCombiner.cs} | 10 +- .../Physics/Motion/ConstraintManagerTests.cs | 133 ++ .../Motion/PositionManagerFacadeTests.cs | 112 ++ .../Physics/Motion/R5ManagerHarness.cs | 98 ++ .../Physics/Motion/StickyManagerTests.cs | 220 +++ .../Physics/Motion/TargetManagerTests.cs | 255 +++ ...rTests.cs => RemoteMotionCombinerTests.cs} | 7 +- 25 files changed, 7279 insertions(+), 12 deletions(-) create mode 100644 docs/research/2026-07-03-r5-managers/r5-acdream-seams.md create mode 100644 docs/research/2026-07-03-r5-managers/r5-ace-crossref.md create mode 100644 docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md create mode 100644 docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md create mode 100644 docs/research/2026-07-03-r5-managers/r5-port-plan.md create mode 100644 docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md create mode 100644 docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md create mode 100644 src/AcDream.Core/Physics/Motion/ConstraintManager.cs create mode 100644 src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs create mode 100644 src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs create mode 100644 src/AcDream.Core/Physics/Motion/PositionManager.cs create mode 100644 src/AcDream.Core/Physics/Motion/StickyManager.cs create mode 100644 src/AcDream.Core/Physics/Motion/TargetManager.cs create mode 100644 src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs rename src/AcDream.Core/Physics/{PositionManager.cs => RemoteMotionCombiner.cs} (91%) create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs rename tests/AcDream.Core.Tests/Physics/{PositionManagerTests.cs => RemoteMotionCombinerTests.cs} (97%) diff --git a/docs/research/2026-07-03-r5-managers/r5-acdream-seams.md b/docs/research/2026-07-03-r5-managers/r5-acdream-seams.md new file mode 100644 index 00000000..b21ad0c3 --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-acdream-seams.md @@ -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? 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` — 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` diff --git a/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md b/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md new file mode 100644 index 00000000..90ee2ca1 --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md @@ -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` | 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. diff --git a/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md b/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md new file mode 100644 index 00000000..92f9201e --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md @@ -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. diff --git a/docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md b/docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md new file mode 100644 index 00000000..d60f2a5d --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md @@ -0,0 +1,1015 @@ +# Retail decomp extract: MovementManager facade + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers as of +this extraction), struct from `docs/research/named-retail/acclient.h`. + +## Struct: `MovementManager` (acclient.h line 30942-30949, comment `/* 3463 */`) + +```c +/* 3463 */ +struct __cppobj MovementManager +{ + CMotionInterp *motion_interpreter; + MoveToManager *moveto_manager; + CPhysicsObj *physics_obj; + CWeenieObject *weenie_obj; +}; +``` + +Field offsets (inferred from `MovementManager::Create` below, all uint32_t/pointer, 0x10 total size): +- `+0x0` motion_interpreter +- `+0x4` moveto_manager +- `+0x8` physics_obj +- `+0xc` weenie_obj + +## Struct: `PositionManager` (acclient.h line 30951-30958, comment `/* 3468 */`) — adjacent, R5's other target, NOT extracted here (out of scope) + +```c +/* 3468 */ +struct __cppobj PositionManager +{ + InterpolationManager *interpolation_manager; + StickyManager *sticky_manager; + ConstraintManager *constraint_manager; + CPhysicsObj *physics_obj; +}; +``` + +## Ownership: `CPhysicsObj` fields (acclient.h ~line 30715-30717) + +```c + MovementManager *movement_manager; + PositionManager *position_manager; + int last_move_was_autonomous; + int jumped_this_frame; +``` + +`CPhysicsObj` owns exactly one `MovementManager*` and one `PositionManager*`, both lazily +constructed. No eager allocation at `CPhysicsObj` construction time was found in this pass. + +--- + +## `MovementManager::MakeMoveToManager` — 00524000 + +```c +00524000 void __fastcall MovementManager::MakeMoveToManager(class MovementManager* this) + +00524000 { +00524008 if (this->moveto_manager == 0) +0052401a this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); +00524000 } +``` + +Lazy-construct `moveto_manager` if null, via `MoveToManager::Create(physics_obj, weenie_obj)`. +No-op if already present. Called from `unpack_movement` cases 6/7/8/9 before touching +`moveto_manager`. + +--- + +## `MovementManager::SetWeenieObject` — 00524020 + +```c +00524020 void __thiscall MovementManager::SetWeenieObject(class MovementManager* this, class CWeenieObject* arg2) + +00524020 { +00524023 class CMotionInterp* motion_interpreter = this->motion_interpreter; +0052402c this->weenie_obj = arg2; +0052402c +0052402f if (motion_interpreter != 0) +00524032 CMotionInterp::SetWeenieObject(motion_interpreter, arg2); +00524032 +00524037 class MoveToManager* moveto_manager = this->moveto_manager; +00524037 +0052403c if (moveto_manager != 0) +0052403f MoveToManager::SetWeenieObject(moveto_manager, arg2); +00524020 } +``` + +Stores `weenie_obj` on `this`, then forwards to both children (`motion_interpreter`, +`moveto_manager`) if they exist yet. Pure propagate-to-children setter. + +--- + +## `MovementManager::Create` (factory / constructor seam) — 00524050 + +Not in the requested list by name but is the actual "constructor" — grep found no +`MovementManager::MovementManager` ctor; all construction goes through this static factory. + +```c +00524050 class MovementManager* MovementManager::Create(class CPhysicsObj* arg1, class CWeenieObject* arg2) + +00524050 { +00524054 void* result_1 = operator new(0x10); +0052405e void* result; +0052405e +0052405e if (result_1 == 0) +0052407f result = nullptr; +0052405e else +0052405e { +00524060 *(uint32_t*)result_1 = 0; +00524066 *(uint32_t*)((char*)result_1 + 4) = 0; +0052406d *(uint32_t*)((char*)result_1 + 8) = 0; +00524074 *(uint32_t*)((char*)result_1 + 0xc) = 0; +0052407b result = result_1; +0052405e } +0052405e +00524081 class CMotionInterp* ecx = *(uint32_t*)result; +00524089 *(uint32_t*)((char*)result + 8) = arg1; +00524089 +0052408c if (ecx != 0) +0052408f CMotionInterp::SetPhysicsObject(ecx, arg1); +0052408f +00524094 class MoveToManager* ecx_1 = *(uint32_t*)((char*)result + 4); +00524094 +00524099 if (ecx_1 != 0) +0052409c MoveToManager::SetPhysicsObject(ecx_1, arg1); +0052409c +005240a1 class CMotionInterp* ecx_2 = *(uint32_t*)result; +005240a9 *(uint32_t*)((char*)result + 0xc) = arg2; +005240a9 +005240ac if (ecx_2 != 0) +005240af CMotionInterp::SetWeenieObject(ecx_2, arg2); +005240af +005240b4 class MoveToManager* ecx_3 = *(uint32_t*)((char*)result + 4); +005240b4 +005240b9 if (ecx_3 != 0) +005240bc MoveToManager::SetWeenieObject(ecx_3, arg2); +005240bc +005240c5 return result; +00524050 } +``` + +`operator new(0x10)` (16 bytes, matches 4 pointer fields), zero-fills all 4 fields, then +sets `physics_obj = arg1` / `weenie_obj = arg2` and — since `motion_interpreter` and +`moveto_manager` are freshly zeroed — the `if (ecx != 0)` / `if (ecx_1 != 0)` branches are +dead on a fresh object (always false right after the zero-fill in this call path; they only +matter if this same field-setting logic is reused elsewhere). Effectively: **plain-old-data +zero-init, no real constructor logic beyond storing the two pointers.** No standalone +`MovementManager::SetPhysicsObject` exists — the physics_obj is set once here, at Create time, +and never independently. + +NOTE: this reads like dead/degenerate branches (checking a field it just zeroed two lines +earlier) — likely because Binary Ninja inlined a shared "SetPhysicsObject/SetWeenieObject +propagate" helper that's also called from non-fresh contexts (matches the pattern seen in +`SetWeenieObject` above). Keep verbatim; not garbled bitfield mush, just dead-code-looking +symmetry from inlining. + +--- + +## `MovementManager::PerformMovement` — 005240d0 + +```c +005240d0 uint32_t __thiscall MovementManager::PerformMovement(class MovementManager* this, class MovementStruct const* arg2) + +005240d0 { +005240d9 CPhysicsObj::set_active(this->physics_obj, 1); +005240e4 void* eax_1 = (arg2->type - 1); +005240e4 +005240e8 if (eax_1 > 8) +00524159 return 0x47; +00524159 +005240f1 switch (eax_1) +005240f1 { +005240fb case nullptr: +005240fb case 1: +005240fb case 2: +005240fb case 3: +005240fb case 4: +005240fb { +005240fb if (this->motion_interpreter == 0) +005240fb { +00524105 class CMotionInterp* eax_3 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); +00524110 bool cond:0_1 = this->physics_obj == 0; +00524112 this->motion_interpreter = eax_3; +00524112 +00524114 if (!(cond:0_1)) +00524118 CMotionInterp::enter_default_state(eax_3); +005240fb } +005240fb +00524127 return CMotionInterp::PerformMovement(this->motion_interpreter, arg2); +005240fb break; +005240fb } +0052412f case 5: +0052412f case 6: +0052412f case 7: +0052412f case 8: +0052412f { +0052412f if (this->moveto_manager == 0) +00524141 this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); +00524141 +00524148 MoveToManager::PerformMovement(this->moveto_manager, arg2); +0052414f return 0; +0052412f break; +0052412f } +005240f1 } +005240d0 } + +0052415c uint32_t jump_table_52415c[0x2] = +0052415c { +0052415c [0x0] = 0x005240f8 +00524160 [0x1] = 0x0052412a +00524164 } +00524164 uint8_t lookup_table_524164[0x9] = +00524164 { +00524164 [0x0] = 0x00 +00524165 [0x1] = 0x00 +00524166 [0x2] = 0x00 +00524167 [0x3] = 0x00 +00524168 [0x4] = 0x00 +00524169 [0x5] = 0x01 +0052416a [0x6] = 0x01 +0052416b [0x7] = 0x01 +0052416c [0x8] = 0x01 +0052416d } +``` + +`arg2->type` is 1-based; `eax_1 = type - 1` is the 0-based dispatch index, range-checked +against 8 (types 1..9 valid, else return error code `0x47`). Two-way split via +`lookup_table_524164`: types 1-5 (index 0-4, i.e. `arg2->type` 1..5) route through +`CMotionInterp` (lazy-create + `enter_default_state` if not yet built, then delegate +`CMotionInterp::PerformMovement`); types 6-9 (index 5-8) route through `MoveToManager` +(lazy-create, delegate `MoveToManager::PerformMovement`, always return 0 — return value of +the MoveToManager path is NOT propagated, unlike the CMotionInterp path which returns +whatever `CMotionInterp::PerformMovement` returns). Always marks the physics object active +first (`CPhysicsObj::set_active(this->physics_obj, 1)`). + +--- + +## `MovementManager::move_to_interpreted_state` — 00524170 + +```c +00524170 void __thiscall MovementManager::move_to_interpreted_state(class MovementManager* this, class InterpretedMotionState const* arg2) + +00524170 { +00524176 if (this->motion_interpreter == 0) +00524176 { +00524180 class CMotionInterp* eax_2 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); +0052418b bool cond:0_1 = this->physics_obj == 0; +0052418d this->motion_interpreter = eax_2; +0052418d +0052418f if (!(cond:0_1)) +00524193 CMotionInterp::enter_default_state(eax_2); +00524176 } +00524176 +0052419f CMotionInterp::move_to_interpreted_state(this->motion_interpreter, arg2); +00524170 } +``` + +Lazy-create `motion_interpreter` (same idiom as everywhere else: create, then +`enter_default_state` ONLY if `physics_obj != 0`), then delegate to +`CMotionInterp::move_to_interpreted_state(interp, arg2)`. Called from `unpack_movement` +case 0 (the raw/interpreted network unpack path). + +--- + +## `MovementManager::CancelMoveTo` — 005241b0 + +```c +005241b0 void __fastcall MovementManager::CancelMoveTo(class MovementManager* this, uint32_t arg2) + +005241b0 { +005241b0 class MoveToManager* moveto_manager = this->moveto_manager; +005241b0 +005241b5 if (moveto_manager == 0) +005241bc return; +005241bc +005241b7 uint32_t edx; +005241b7 /* tailcall */ +005241b7 return MoveToManager::CancelMoveTo(moveto_manager, edx); +005241b0 } +``` + +No-op if `moveto_manager` is null; else tailcalls `MoveToManager::CancelMoveTo`. + +NOTE: `arg2` is loaded but the tailcall passes an **uninitialized** local `edx` instead of +`arg2` — decompiler register-tracking artifact (arg2 IS in edx per `__fastcall` ABI, this +is BN failing to alias the parameter register to the "edx" pseudo-var name); functionally +it's `MoveToManager::CancelMoveTo(moveto_manager, arg2)`. + +--- + +## `MovementManager::EnterDefaultState` — 005241c0 + +```c +005241c0 void __fastcall MovementManager::EnterDefaultState(class MovementManager* this) + +005241c0 { +005241c3 class CPhysicsObj* physics_obj = this->physics_obj; +005241c3 +005241c8 if (physics_obj == 0) +005241f5 return; +005241f5 +005241cd if (this->motion_interpreter == 0) +005241cd { +005241d4 class CMotionInterp* eax = CMotionInterp::Create(physics_obj, this->weenie_obj); +005241df bool cond:0_1 = this->physics_obj == 0; +005241e1 this->motion_interpreter = eax; +005241e1 +005241e3 if (!(cond:0_1)) +005241e7 CMotionInterp::enter_default_state(eax); +005241cd } +005241cd +005241ef /* tailcall */ +005241ef return CMotionInterp::enter_default_state(this->motion_interpreter); +005241c0 } +``` + +Early-return no-op if `physics_obj == 0` (i.e. never called meaningfully before the +MovementManager is attached to a physics object). Otherwise lazy-create +`motion_interpreter` (same idiom), then **unconditionally** tailcalls +`CMotionInterp::enter_default_state` again at the end — meaning on the fresh-create path +`enter_default_state` runs twice in a row (once inside the lazy-create block, once at the +tail). Verbatim as decompiled; flagging as a NOTE since double-invoke looks odd but matches +the repeated idiom seen in every other lazy-create call site in this file (all of them +gate the inner call on `physics_obj != 0` which is already guaranteed true here since we +already early-returned above). + +--- + +## `MovementManager::IsMovingTo` — 00524260 + +```c +00524260 int32_t __fastcall MovementManager::IsMovingTo(class MovementManager const* this) + +00524260 { +00524260 class MoveToManager* moveto_manager = this->moveto_manager; +00524260 +00524265 if ((moveto_manager != 0 && MoveToManager::is_moving_to(moveto_manager) != 0)) +00524275 return 1; +00524275 +00524278 return 0; +00524260 } +``` + +Returns 1 iff `moveto_manager` exists AND `MoveToManager::is_moving_to()` is true, else 0. + +--- + +## `MovementManager::motions_pending` — 00524280 + +```c +00524280 int32_t __fastcall MovementManager::motions_pending(class MovementManager const* this) + +00524280 { +00524280 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524280 +00524284 if ((motion_interpreter != 0 && CMotionInterp::motions_pending(motion_interpreter) != 0)) +00524294 return 1; +00524294 +00524297 return 0; +00524280 } +``` + +Returns 1 iff `motion_interpreter` exists AND `CMotionInterp::motions_pending()` is true, +else 0. Mirror-shape of `IsMovingTo` but checks the interp side instead of the moveto side. + +--- + +## `MovementManager::get_minterp` — 005242a0 (bonus, referenced by callers; not in the +original ask but needed for context — CPhysicsObj::get_minterp tailcalls into it) + +```c +005242a0 class CMotionInterp* __fastcall MovementManager::get_minterp(class MovementManager* this) + +005242a0 { +005242a6 if (this->motion_interpreter == 0) +005242a6 { +005242b0 class CMotionInterp* eax_2 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); +005242bb bool cond:0_1 = this->physics_obj == 0; +005242bd this->motion_interpreter = eax_2; +005242bd +005242bf if (!(cond:0_1)) +005242c3 CMotionInterp::enter_default_state(eax_2); +005242a6 } +005242a6 +005242cb return this->motion_interpreter; +005242a0 } +``` + +Same lazy-create idiom; returns the (possibly freshly created) `motion_interpreter`. + +--- + +## `MovementManager::MotionDone` — 005242d0 + +```c +005242d0 void __thiscall MovementManager::MotionDone(class MovementManager* this, uint32_t arg2, int32_t arg3) + +005242d0 { +005242d0 class CMotionInterp* motion_interpreter = this->motion_interpreter; +005242d0 +005242d4 if (motion_interpreter != 0) +005242d4 { +005242da int32_t var_4_1 = arg3; +005242db int32_t edx; +005242db CMotionInterp::MotionDone(motion_interpreter, edx); +005242d4 } +005242d0 } +``` + +No-op if `motion_interpreter` is null; else forwards to `CMotionInterp::MotionDone`. + +NOTE: same register-aliasing artifact as `CancelMoveTo` — `arg2` is stashed but the call +passes an uninitialized-looking local `edx` (and `arg3` is stored to `var_4_1` but that +local is never read/passed either); functionally this is +`CMotionInterp::MotionDone(motion_interpreter, arg2, arg3)` — BN's `__thiscall` register +tracking dropped the second/third args' names. Not evidence of a bug in the real function; +just decompiler noise on a two/three-arg thiscall forward. + +--- + +## `MovementManager::UseTime` — 005242f0 + +```c +005242f0 void __fastcall MovementManager::UseTime(class MovementManager* this) + +005242f0 { +005242f0 class MoveToManager* moveto_manager = this->moveto_manager; +005242f0 +005242f5 if (moveto_manager == 0) +005242fc return; +005242fc +005242f7 /* tailcall */ +005242f7 return MoveToManager::UseTime(moveto_manager); +005242f0 } +``` + +No-op if `moveto_manager` null; else tailcalls `MoveToManager::UseTime`. Does NOT touch +`motion_interpreter` at all (unlike `HitGround`/`LeaveGround`/`ReportExhaustion` below, +which forward to both children). + +--- + +## `MovementManager::HitGround` — 00524300 + +```c +00524300 void __fastcall MovementManager::HitGround(class MovementManager* this) + +00524300 { +00524303 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524303 +00524307 if (motion_interpreter != 0) +00524309 CMotionInterp::HitGround(motion_interpreter); +00524309 +0052430e class MoveToManager* moveto_manager = this->moveto_manager; +0052430e +00524314 if (moveto_manager == 0) +0052431b return; +0052431b +00524316 /* tailcall */ +00524316 return MoveToManager::HitGround(moveto_manager); +00524300 } +``` + +Fans out to BOTH children unconditionally-if-present: `CMotionInterp::HitGround` first, +then tailcalls `MoveToManager::HitGround`. + +--- + +## `MovementManager::LeaveGround` — 00524320 + +```c +00524320 void __fastcall MovementManager::LeaveGround(class MovementManager* this) + +00524320 { +00524323 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524323 +00524327 if (motion_interpreter != 0) +00524329 CMotionInterp::LeaveGround(motion_interpreter); +00524329 +0052432e class MoveToManager* moveto_manager = this->moveto_manager; +0052432e +00524334 if (moveto_manager == 0) +0052433b return; +0052433b +00524336 /* tailcall */ +00524336 return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); +00524320 } +``` + +Same shape as `HitGround`: fan out to `CMotionInterp::LeaveGround` then tailcall the +moveto-manager equivalent. + +**NOTE (BN mislabel, HIGH CONFIDENCE):** the tail call target is decompiled as +`IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager)` — a destructor for an +unrelated ID-wrapper template class. This is obviously wrong for the context (nothing is +being destroyed here; the pattern is identical to `HitGround`/`UseTime`/`ReportExhaustion` +which all call the matching `MoveToManager::XxxMethod`). Binary Ninja's static analysis +matched the call target address to the wrong overload/thunk. The real call is almost +certainly `MoveToManager::LeaveGround(moveto_manager)`. Keep the raw decompiled text above +for the record; the lead should treat the semantic target as `MoveToManager::LeaveGround`. + +--- + +## `MovementManager::HandleEnterWorld` — 00524340 + +```c +00524340 void __fastcall MovementManager::HandleEnterWorld(class MovementManager* this) + +00524340 { +00524340 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524340 +00524344 if (motion_interpreter == 0) +0052434b return; +0052434b +00524346 /* tailcall */ +00524346 return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(motion_interpreter); +00524340 } +``` + +No-op if `motion_interpreter` null; else tailcalls what should be +`CMotionInterp::HandleEnterWorld(motion_interpreter)`. + +**NOTE (BN mislabel, HIGH CONFIDENCE):** same spurious `IDClass<...>::~IDClass<...>` +destructor mislabel as in `LeaveGround` above. Given the neighboring function +`HandleExitWorld` (below) correctly shows `CMotionInterp::HandleExitWorld`, the real target +here is almost certainly `CMotionInterp::HandleEnterWorld(motion_interpreter)`. Notably, +this function does NOT touch `moveto_manager` at all (unlike HitGround/LeaveGround/ +ReportExhaustion) — only the motion interpreter gets the enter-world notification. + +--- + +## `MovementManager::HandleExitWorld` — 00524350 + +```c +00524350 void __fastcall MovementManager::HandleExitWorld(class MovementManager* this) + +00524350 { +00524350 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524350 +00524354 if (motion_interpreter == 0) +0052435b return; +0052435b +00524356 /* tailcall */ +00524356 return CMotionInterp::HandleExitWorld(motion_interpreter); +00524350 } +``` + +No-op if `motion_interpreter` null; else tailcalls `CMotionInterp::HandleExitWorld`. This +one resolved cleanly (no mislabel) — cross-check anchor confirming the sibling functions' +correct semantic targets. Also does NOT touch `moveto_manager`. + +--- + +## `MovementManager::ReportExhaustion` — 00524360 + +```c +00524360 void __fastcall MovementManager::ReportExhaustion(class MovementManager* this) + +00524360 { +00524363 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524363 +00524367 if (motion_interpreter != 0) +00524369 CMotionInterp::ReportExhaustion(motion_interpreter); +00524369 +0052436e class MoveToManager* moveto_manager = this->moveto_manager; +0052436e +00524374 if (moveto_manager == 0) +0052437b return; +0052437b +00524376 /* tailcall */ +00524376 return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); +00524360 } +``` + +Fans out to both children like `HitGround`: `CMotionInterp::ReportExhaustion` first +(resolved cleanly), then tailcalls the moveto-manager equivalent. + +**NOTE (BN mislabel, HIGH CONFIDENCE):** same spurious `IDClass<...>::~IDClass<...>` on the +`moveto_manager` tail call — real target is almost certainly +`MoveToManager::ReportExhaustion(moveto_manager)`, matching the pattern of every other +fan-out function (HitGround, LeaveGround) where the CMotionInterp call resolves correctly +but the MoveToManager tailcall gets the same wrong destructor label. This looks like a +systematic BN issue with one specific MoveToManager vtable slot / thunk rather than three +independent misreads. + +--- + +## `MovementManager::Destroy` — 005243f0 (bonus — the matching teardown for `Create`; +not originally requested but directly relevant to the facade's lifecycle) + +```c +005243f0 void __fastcall MovementManager::Destroy(class MovementManager* this) + +005243f0 { +005243f4 class CMotionInterp* motion_interpreter = this->motion_interpreter; +005243f4 +005243f8 if (motion_interpreter != 0) +005243f8 { +005243fc CMotionInterp::~CMotionInterp(motion_interpreter); +00524402 operator delete(motion_interpreter); +005243f8 } +005243f8 +0052440a class MoveToManager* moveto_manager = this->moveto_manager; +0052440f this->motion_interpreter = 0; +0052440f +00524415 if (moveto_manager != 0) +00524415 { +00524419 MoveToManager::~MoveToManager(moveto_manager); +0052441f operator delete(moveto_manager); +00524415 } +00524415 +00524428 this->moveto_manager = nullptr; +005243f0 } +``` + +Destroys+frees both children if present, nulls both pointers. Does NOT free `this` itself +(matches — `MovementManager::Create` used `operator new`, but `Destroy` is presumably +called before the owning `CPhysicsObj` does its own `operator delete(movement_manager)` +elsewhere; that final delete call wasn't in this extraction's scope). + +--- + +## `MovementManager::unpack_movement` — 00524440 (FULL FUNCTION — the movement-type switch) + +```c +00524440 int32_t __thiscall MovementManager::unpack_movement(class MovementManager* this, void** arg2, uint32_t arg3) + +00524440 { +0052444f if (this->motion_interpreter != 0) +0052444f { +00524455 class CPhysicsObj* physics_obj = this->physics_obj; +00524455 +0052445a if (physics_obj != 0) +0052445a { +00524460 CPhysicsObj::interrupt_current_movement(physics_obj); +00524468 CPhysicsObj::unstick_from_object(this->physics_obj); +00524471 int32_t var_70 = 0x796910; +00524479 int32_t var_6c_1 = 0; +00524481 int32_t var_68 = 0x3f800000; +00524489 int32_t var_64_1 = 0; +00524491 int32_t var_60_1 = 0; +00524499 int32_t var_5c_1 = 0; +005244a1 int32_t var_34_1 = 0; +005244ac int32_t var_30_1 = 0; +005244b7 int32_t var_2c_1 = 0; +005244c2 Frame::cache(&var_68); +005244cb void var_9c; +005244cb MovementParameters::MovementParameters(&var_9c); +005244d7 void var_28; +005244d7 InterpretedMotionState::InterpretedMotionState(&var_28); +005244e3 void* eax_1 = *(uint32_t*)arg2; +005244e7 int16_t ecx_4 = *(uint16_t*)eax_1; +005244ed *(uint32_t*)arg2 = ((char*)eax_1 + 2); +005244ef uint32_t ebp_1 = ((uint32_t)ecx_4); +005244f5 int16_t var_a4_1 = ecx_4; +005244f9 ecx_4 = *(uint16_t*)((char*)eax_1 + 2); +005244fd *(uint32_t*)arg2 = ((char*)eax_1 + 4); +00524502 uint32_t ecx_5 = command_ids[((uint32_t)ecx_4)]; +00524502 +00524522 if (CBaseFilter::GetPinVersion(this->motion_interpreter) != ecx_5) +0052452c CMotionInterp::DoMotion(this->motion_interpreter, ecx_5, &var_9c); +0052452c +0052453a void* var_b8_15; +0052453a +0052453a switch (((uint32_t)ebp_1)) +0052453a { +00524551 case 0: +00524551 { +00524551 InterpretedMotionState::UnPack(&var_28, arg2, arg3); +0052455d uint32_t ebx_3; +0052455d +0052455d if ((*(uint8_t*)((char*)var_a4_1)[1] & 1) == 0) +0052456a ebx_3 = 0; +0052455d else +0052455d { +0052455f uint32_t* eax_8 = *(uint32_t*)arg2; +00524561 ebx_3 = *(uint32_t*)eax_8; +00524566 *(uint32_t*)arg2 = &eax_8[1]; +0052455d } +0052455d +0052457c MovementManager::move_to_interpreted_state(this, &var_28); +0052457c +00524583 if (ebx_3 != 0) +00524589 CPhysicsObj::stick_to_object(this->physics_obj, ebx_3); +00524589 +0052458e this->motion_interpreter->standing_longjump = (ebp_1 & 0x200); +0052459a InterpretedMotionState::~InterpretedMotionState(&var_28); +005245ae return 1; +00524551 break; +00524551 } +005245b3 case 6: +005245b3 { +005245b3 MovementManager::MakeMoveToManager(this); +005245b8 void* eax_11 = *(uint32_t*)arg2; +005245ba uint32_t ebx_4 = *(uint32_t*)eax_11; +005245cc *(uint32_t*)arg2 = ((char*)eax_11 + 4); +005245ce Position::UnPackOrigin(&var_70, arg2, arg3); +005245db MovementParameters::UnPackNet(&var_9c, MoveToObject, arg2, arg3); +005245e0 void* eax_13 = *(uint32_t*)arg2; +005245e2 long double x87_r7 = ((long double)*(uint32_t*)eax_13); +005245e7 *(uint32_t*)arg2 = ((char*)eax_13 + 4); +005245e9 this->motion_interpreter->my_run_rate = ((float)x87_r7); +005245e9 +005245f9 if (CPhysicsObj::GetObjectA(ebx_4) == 0) +005245f9 goto label_524668; +005245f9 +00524604 CPhysicsObj::MoveToObject(this->physics_obj, ebx_4, &var_9c); +00524610 InterpretedMotionState::~InterpretedMotionState(&var_28); +00524624 return 1; +005245b3 break; +005245b3 } +00524629 case 7: +00524629 { +00524629 MovementManager::MakeMoveToManager(this); +0052463b Position::UnPackOrigin(&var_70, arg2, arg3); +00524648 MovementParameters::UnPackNet(&var_9c, MoveToPosition, arg2, arg3); +0052464d void* eax_18 = *(uint32_t*)arg2; +0052464f long double x87_r7_1 = ((long double)*(uint32_t*)eax_18); +00524654 *(uint32_t*)arg2 = ((char*)eax_18 + 4); +00524656 this->motion_interpreter->my_run_rate = ((float)x87_r7_1); +00524668 label_524668: +00524668 MoveToManager::MoveToPosition(this->moveto_manager, &var_70, &var_9c); +00524674 InterpretedMotionState::~InterpretedMotionState(&var_28); +00524688 return 1; +00524629 break; +00524629 } +0052468d case 8: +0052468d { +0052468d MovementManager::MakeMoveToManager(this); +00524692 void* eax_21 = *(uint32_t*)arg2; +00524694 uint32_t ebx_6 = *(uint32_t*)eax_21; +005246a0 *(uint32_t*)arg2 = ((char*)eax_21 + 4); +005246a2 int32_t ecx_25 = *(uint32_t*)((char*)eax_21 + 4); +005246b3 *(uint32_t*)arg2 = ((char*)eax_21 + 8); +005246b5 MovementParameters::UnPackNet(&var_9c, TurnToObject, arg2, arg3); +005246b5 +005246c5 if (CPhysicsObj::GetObjectA(ebx_6) == 0) +005246c5 { +005246fb int32_t var_8c_1 = ecx_25; +005246ff var_b8_15 = &var_9c; +005246c5 goto label_524725; +005246c5 } +005246c5 +005246d0 CPhysicsObj::TurnToObject(this->physics_obj, ebx_6, &var_9c); +005246dc InterpretedMotionState::~InterpretedMotionState(&var_28); +005246f0 return 1; +0052468d break; +0052468d } +00524704 case 9: +00524704 { +00524704 MovementManager::MakeMoveToManager(this); +00524718 MovementParameters::UnPackNet(&var_9c, TurnToHeading, arg2, arg3); +00524721 var_b8_15 = &var_9c; +00524725 label_524725: +00524725 MoveToManager::TurnToHeading(this->moveto_manager, var_b8_15); +00524731 InterpretedMotionState::~InterpretedMotionState(&var_28); +00524745 return 1; +00524704 break; +00524704 } +0052453a } +0052453a +0052474f InterpretedMotionState::~InterpretedMotionState(&var_28); +0052445a } +0052444f } +0052444f +00524760 return 0; +00524440 } + +00524764 uint32_t jump_table_524764[0xa] = +00524764 { +00524764 [0x0] = 0x00524541 // case 0 +00524768 [0x1] = 0x00524748 // (unused index -> falls to default-exit path) +0052476c [0x2] = 0x00524748 +00524770 [0x3] = 0x00524748 +00524774 [0x4] = 0x00524748 +00524778 [0x5] = 0x00524748 +0052477c [0x6] = 0x005245b1 // case 6 +00524780 [0x7] = 0x00524627 // case 7 +00524784 [0x8] = 0x0052468b // case 8 +00524788 [0x9] = 0x00524702 // case 9 +0052478c } +``` + +**Function-level structure (top of function, before the type switch):** + +1. Entire function is a no-op (falls through to `return 0`) unless `this->motion_interpreter != 0` + AND `this->physics_obj != 0`. **This means `unpack_movement` requires the interpreter to + already exist** — unlike every OTHER MovementManager method, this one does NOT lazily + construct `motion_interpreter` on demand. If the interpreter hasn't been created yet + (e.g. via `EnterDefaultState`/`PerformMovement`/`get_minterp`), an inbound network + movement packet is silently dropped (returns 0, meaning presumably "0 bytes consumed" or + "not handled" to the caller). +2. On the happy path: `CPhysicsObj::interrupt_current_movement(physics_obj)` then + `CPhysicsObj::unstick_from_object(physics_obj)` — every unpacked movement command first + cancels any in-flight movement and un-sticks the object from whatever it was stuck to + (relevant to the R4-era sticky-guid work). +3. Builds a stack `Frame` (`var_70`..`var_2c_1`, `Frame::cache(&var_68)` — identity-ish frame + init, `0x3f800000` = 1.0f, rest zeroed) and default-constructs a `MovementParameters` + (`var_9c`) and an `InterpretedMotionState` (`var_28`) as scratch locals for the switch + below. +4. Reads a 16-bit **header word** `ebp_1` from the wire (`*(uint16_t*)eax_1`, advances + `arg2` by 2) — this is the `mt` (movement-type) value the switch dispatches on. A SECOND + 16-bit value `ecx_4` is read right after (advances `arg2` by another 2) and used as an + index into a `command_ids[]` table to get a motion-command id `ecx_5`; if that differs + from the interpreter's current pin/version (`CBaseFilter::GetPinVersion` — **BN + mislabel, see NOTE below**), it calls `CMotionInterp::DoMotion(interp, ecx_5, &var_9c)`. + This happens **unconditionally before the switch**, for every movement type. +5. `switch (ebp_1)` dispatches on the FULL 16-bit header word, not a masked/shifted + sub-field — cases match `0`, `6`, `7`, `8`, `9` literally. **This directly answers the + task's ask about 0x100/0x200 header-flag handling: those bits are NOT separate switch + cases or pre-switch branches.** They are only consumed in the `case 0` body (see below). + No case for 0x100 or 0x200 as a distinct dispatch value exists — the jump table only has + 9 accounted-for indices (0,6,7,8,9 real; 1-5 fall through to the same "no case" exit at + `0x524748`, which is the shared post-switch cleanup + `return 0`... actually the decompiled + text shows those return 0 via falling out of the switch to `InterpretedMotionState::~InterpretedMotionState(&var_28)` then `return 0`, since no case body ran). + +**`case 0` (mt == 0) — the raw/interpreted-motion unpack path, WITH the header-flag handling:** + +- `InterpretedMotionState::UnPack(&var_28, arg2, arg3)` — unpacks the actual motion state + payload from the wire buffer. +- **Sticky-guid extraction (this is where a header-derived flag conditionally reads an + extra guid off the wire):** `if ((*(uint8_t*)((char*)var_a4_1)[1] & 1) == 0) ebx_3 = 0; + else { read a uint32_t off the wire into ebx_3, advance arg2 by 4 }`. NOTE: the condition + reads byte 1 of `var_a4_1` (the ORIGINAL 16-bit header value, stored earlier as + `int16_t var_a4_1 = ecx_4` where `ecx_4` was the first 16-bit read == same value as + `ebp_1`) and tests bit 0 of that HIGH byte — i.e. bit 8 of the 16-bit header, which is + **`0x100`**. This is the "sticky guid" bit the task asked about: `mt & 0x100` gates + whether an extra `uint32_t` object-guid is read off the wire right after + `InterpretedMotionState::UnPack`. + + **NOTE (garbled-looking but NOT bitfield mush — it's a byte-address cast):** + `*(uint8_t*)((char*)var_a4_1)[1]` looks bizarre (casting a 16-bit local's VALUE to a + `char*` and indexing) — this is Binary Ninja's clumsy way of expressing "take the address + of the local `var_a4_1`, then read byte offset 1 of it" (i.e. the high byte of the + 16-bit `short`, since x86 is little-endian). Read literally it would be UB (treating + the int16 VALUE as a pointer), so this is almost certainly BN mis-rendering + `*((uint8_t*)&var_a4_1 + 1) & 1` (high byte of the header word, bit 0 of that byte = + bit 8 of the word = `0x100`). Keeping the raw text per instructions, but the lead should + read this as "bit `0x100` of the mt header word." + +- `MovementManager::move_to_interpreted_state(this, &var_28)` — feeds the unpacked state + into the interpreter (see that function's extract above; itself has a redundant lazy-create + guard even though we already know `motion_interpreter != 0` at this point since the whole + function is gated on that at the top). +- `if (ebx_3 != 0) CPhysicsObj::stick_to_object(this->physics_obj, ebx_3)` — if the sticky + bit was set AND the guid we read is non-zero, stick the physics object to that target. + This is the **0x100 sticky-guid handling** the task asked about, confirmed. +- `this->motion_interpreter->standing_longjump = (ebp_1 & 0x200)` — **the 0x200 + standing_longjump bit, confirmed.** Stored directly as a raw masked int (not normalized + to 0/1) into `CMotionInterp::standing_longjump` on the (already-guaranteed-non-null) + interpreter. This is a plain field write, not mush — the field just stores the + raw-masked-bit value (nonzero-but-not-necessarily-1 when set) rather than a boolean 0/1. +- destructs the scratch `InterpretedMotionState`, returns 1 (success/consumed). + +**`case 6` (MoveToObject):** +- `MovementManager::MakeMoveToManager(this)` — ensures `moveto_manager` exists. +- reads a target-object guid (`ebx_4`) off the wire, then `Position::UnPackOrigin(&var_70, ...)`, + then `MovementParameters::UnPackNet(&var_9c, MoveToObject, arg2, arg3)` (the network-unpack + overload, taking a `MovementType`/context enum `MoveToObject` as a literal tag), then reads + a float `my_run_rate` off the wire (via x87 float-load pattern, `long double` round-trip) + and stores it on `motion_interpreter->my_run_rate`. +- `if (CPhysicsObj::GetObjectA(ebx_4) == 0) goto label_524668` — if the target object can't be + resolved (not currently visible/known?), falls through to the shared `MoveToPosition` tail + (reusing the just-unpacked `var_70`/`var_9c` as a position-based fallback) instead of the + object-based move. +- else `CPhysicsObj::MoveToObject(physics_obj, ebx_4, &var_9c)` — object exists, move directly + to it. +- returns 1 either way. + +**`case 7` (MoveToPosition):** +- `MakeMoveToManager`, `Position::UnPackOrigin`, `MovementParameters::UnPackNet(..., MoveToPosition, ...)`, + reads `my_run_rate` float, falls straight into `label_524668` (shared with case 6's + object-not-found fallback): `MoveToManager::MoveToPosition(moveto_manager, &var_70, &var_9c)`. +- returns 1. + +**`case 8` (TurnToObject):** +- `MakeMoveToManager`, reads target guid `ebx_6` + an extra dword `ecx_25` (context_id?) off + the wire, `MovementParameters::UnPackNet(&var_9c, TurnToObject, arg2, arg3)`. +- `if (CPhysicsObj::GetObjectA(ebx_6) == 0)`: object not resolvable — falls through to the + shared `label_524725` tail (`var_b8_15 = &var_9c`, i.e. degrades to a TurnToHeading-style + call using just the unpacked params) instead of the object-based turn. +- else `CPhysicsObj::TurnToObject(physics_obj, ebx_6, &var_9c)` directly. +- returns 1 either way. + +**`case 9` (TurnToHeading):** +- `MakeMoveToManager`, `MovementParameters::UnPackNet(&var_9c, TurnToHeading, arg2, arg3)`, + falls into shared `label_524725`: `MoveToManager::TurnToHeading(moveto_manager, var_b8_15)`. +- returns 1. + +**Fall-through / no matching case (mt in {1,2,3,4,5} or any other 16-bit value not 0/6/7/8/9):** +- switch body produces no case match, control falls to + `InterpretedMotionState::~InterpretedMotionState(&var_28)` then `return 0` — i.e. silently + treated as "0 bytes handled" / not consumed, same as the "interpreter doesn't exist yet" + early-out at the top. + +**NOTE (BN mislabel, MEDIUM CONFIDENCE):** `CBaseFilter::GetPinVersion(this->motion_interpreter)` +at line 300597 — `CBaseFilter` is a DirectShow filter-graph base class, wildly out of place +for a `CMotionInterp*` argument. This is almost certainly a mislabeled call to some +`CMotionInterp` accessor (a "get current motion id / pin version"-shaped getter, maybe +`CMotionInterp::InqPendingMotion` or similar) that BN matched to the wrong vtable-slot +symbol. Kept verbatim per instructions; flagging so the lead doesn't chase DirectShow. + +**NOTE (data table, not extracted in full):** `command_ids[]` (referenced at line 300595, +`command_ids[(uint32_t)ecx_4]`) is a lookup table mapping a wire-encoded small integer to a +`MotionCommand` enum id. Not dumped here — out of scope for this extraction pass, but the +lead may want it if porting the exact `DoMotion` pre-switch call. + +--- + +## `MovementManager::HandleUpdateTarget` — 00524790 + +```c +00524790 void __fastcall MovementManager::HandleUpdateTarget(class MovementManager* this, class TargetInfo arg2) + +00524790 { +00524790 class MoveToManager* moveto_manager = this->moveto_manager; +00524790 +00524795 if (moveto_manager != 0) +0052479c MoveToManager::HandleUpdateTarget(moveto_manager, &arg2); +00524790 } +``` + +No-op if `moveto_manager` null; else forwards `arg2` (a `TargetInfo`, passed by value into +this function but forwarded by address) to `MoveToManager::HandleUpdateTarget`. Does not +touch `motion_interpreter`. + +--- + +## `CPhysicsObj::unpack_movement` — 00512040 (the CALLER seam / where `movement_manager` +gets lazily constructed from the `CPhysicsObj` side — directly relevant context, not in the +original list but requested implicitly via "CPhysicsObj's creation seam") + +```c +00512040 void __thiscall CPhysicsObj::unpack_movement(class CPhysicsObj* this, void** arg2, uint32_t arg3) + +00512040 { +0051204b if (this->movement_manager == 0) +0051204b { +0051205a this->movement_manager = MovementManager::Create(this, this->weenie_obj); +00512060 class MovementManager* eax_2; +00512060 eax_2 = this->state; +00512060 +0051206b if ((eax_2 & 1) == 0) +0051206b { +0051206d uint32_t transient_state = this->transient_state; +0051206d +00512075 if (transient_state >= 0) +00512075 { +00512083 this->update_time = (*(uint32_t*)Timer::cur_time); +00512089 *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); +00512075 } +00512075 +00512094 this->transient_state = (transient_state | 0x80); +0051206b } +0051204b } +0051204b +005120aa MovementManager::unpack_movement(this->movement_manager, arg2, arg3); +00512040 } +``` + +Lazy-creates `this->movement_manager` via `MovementManager::Create(this, this->weenie_obj)` +if null (note: does NOT call `EnterDefaultState` here, unlike `get_minterp`'s lazy-create +below — just `Create` then straight into `unpack_movement`). If bit 0 of `state` is clear +(NOT static?), stamps `update_time = Timer::cur_time` (conditionally, if +`transient_state >= 0`, i.e. sign bit clear) and ORs `0x80` into `transient_state` — +looks like a "wake up / mark dynamic-and-recently-updated" side effect that happens on +first-ever movement-manager creation for this physics object, gated behind the same +`(state & 1) == 0` check seen again below. Then unconditionally tailcalls +`MovementManager::unpack_movement(movement_manager, arg2, arg3)` (the function extracted +above) — this is THE entry point that feeds inbound wire movement bytes into the facade. + +## `CPhysicsObj::get_minterp` — 005120c0 (sibling lazy-create seam, for contrast) + +```c +005120c0 class CMotionInterp* __fastcall CPhysicsObj::get_minterp(class CPhysicsObj* this) + +005120c0 { +005120cb if (this->movement_manager == 0) +005120cb { +005120d5 class MovementManager* eax_2 = MovementManager::Create(this, this->weenie_obj); +005120df this->movement_manager = eax_2; +005120e5 MovementManager::EnterDefaultState(eax_2); +005120e5 +005120f1 if ((this->state & 1) == 0) +005120f1 { +005120f3 uint32_t transient_state = this->transient_state; +005120f3 +005120fb if (transient_state >= 0) +005120fb { +00512109 this->update_time = (*(uint32_t*)Timer::cur_time); +0051210f *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); +00512109 } +005120fb +0051211a this->transient_state = (transient_state | 0x80); +005120f1 } +005120cb } +005120cb +00512127 /* tailcall */ +00512127 return MovementManager::get_minterp(this->movement_manager); +005120c0 } +``` + +Same lazy-create-and-stamp idiom, but this path DOES call `MovementManager::EnterDefaultState` +right after `Create` (unlike `unpack_movement`'s seam above, which skips it). Confirms +`CPhysicsObj` has (at least) two independent lazy-construction call sites for +`movement_manager`, each with a slightly different post-create step — `unpack_movement` +skips `EnterDefaultState` (presumably because `unpack_movement` itself, or the subsequent +`MovementManager::unpack_movement` call, drives the interpreter into the right state via +`move_to_interpreted_state`/`PerformMovement`'s own lazy-create+enter-default-state guards), +while `get_minterp` needs the interpreter immediately ready to answer a query and so forces +`EnterDefaultState` explicitly. + +**No standalone `MovementManager::SetPhysicsObject` exists anywhere in the corpus** — grep +for the literal symbol returned zero matches. `physics_obj` is set exactly once, inside +`MovementManager::Create`, at construction time, and never reassigned. diff --git a/docs/research/2026-07-03-r5-managers/r5-port-plan.md b/docs/research/2026-07-03-r5-managers/r5-port-plan.md new file mode 100644 index 00000000..b0ec1b2a --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-port-plan.md @@ -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, + 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(©, 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). diff --git a/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md b/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md new file mode 100644 index 00000000..c3285b6f --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md @@ -0,0 +1,1533 @@ +# Retail decomp extract: PositionManager + StickyManager + CPhysicsObj sticky seams + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C, +Sept 2013 EoR build, PDB-named). Struct defs from `docs/research/named-retail/acclient.h`. +All function bodies below are VERBATIM (unedited) except for the surrounding-context +excerpts marked as such. + +--- + +## Struct definitions (acclient.h) + +### `struct PositionManager` (acclient.h:30952, type id 3468) + +```c +/* 3468 */ +struct __cppobj PositionManager +{ + InterpolationManager *interpolation_manager; + StickyManager *sticky_manager; + ConstraintManager *constraint_manager; + CPhysicsObj *physics_obj; +}; +``` + +### `struct StickyManager` (acclient.h:31518, type id 3466) + +```c +/* 3466 */ +struct __cppobj StickyManager +{ + unsigned int target_id; + float target_radius; + Position target_position; + CPhysicsObj *physics_obj; + int initialized; + long double sticky_timeout_time; +}; +``` + +### `struct ConstraintManager` (acclient.h:31529, type id 3467, for context — sibling manager) + +```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; +}; +``` + +NOTE: `PositionManager` is a small 4-pointer facade/dispatcher struct (0x10 bytes — +matches `PositionManager::Create`'s `operator new(0x10)`). It owns three independently +lazily-created sub-managers: `InterpolationManager` (network position interpolation), +`StickyManager` (parent-object stick/follow), `ConstraintManager` (radius-bounded tether). +`StickyManager` itself is 0x60 bytes (`StickyManager::Create`'s `operator new(0x60)`) and +holds a single sticky target: id, radius, cached target position (a full `Position`, +which embeds a `Frame`), back-pointer to owner `CPhysicsObj`, an `initialized` bool gating +whether `target_position` is valid yet, and an `x87 long double` timeout timestamp +(`sticky_timeout_time`) compared against `Timer::cur_time`. + +--- + +## PositionManager methods + +### `PositionManager::UseTime` — 0x00555160 + +```c +00555160 void __fastcall PositionManager::UseTime(class PositionManager* this) + +00555160 { +00555163 class InterpolationManager* interpolation_manager = this->interpolation_manager; +00555163 +00555167 if (interpolation_manager != 0) +00555169 InterpolationManager::UseTime(interpolation_manager); +00555169 +0055516e class ConstraintManager* constraint_manager = this->constraint_manager; +0055516e +00555173 if (constraint_manager != 0) +00555175 IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(constraint_manager); +00555175 +0055517a class StickyManager* sticky_manager = this->sticky_manager; +0055517a +00555180 if (sticky_manager == 0) +00555187 return; +00555187 +00555182 /* tailcall */ +00555182 return StickyManager::UseTime(sticky_manager); +00555160 } +``` + +NOTE: the `constraint_manager != 0` branch calls +`IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(constraint_manager)` — this is +almost certainly a Binary Ninja mis-decompilation / bad-symbol-resolution artifact +(the destructor of an unrelated template ID class being called on a `ConstraintManager*` +makes no structural sense). The real call is very likely `ConstraintManager::UseTime` +(mirroring the `InterpolationManager::UseTime` / `StickyManager::UseTime` pattern on +either side of it) — flagging for the lead rather than silently correcting. + +### `PositionManager::adjust_offset` — 0x00555190 + +```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 } +``` + +NOTE: the `int32_t edi = *(uint32_t*)((char*)arg3)[4];` line is BN garbling `arg3` +(a `double` passed as quantum/elapsed-time) — this looks like a decompiler artifact from +the double being passed partly in a register pair; the actual semantic is simply "pass +`arg3` (the quantum) through to all three sub-manager `adjust_offset` calls unchanged." +Each sub-manager gets the SAME `Frame*` (`arg2`, an in/out accumulator) and the SAME +quantum. This mirrors `UseTime`'s dispatch pattern: PositionManager is a pure fan-out +facade over its three sub-managers, called once per tick. + +### `PositionManager::UnStick` — 0x005551e0 + +```c +005551e0 void __fastcall PositionManager::UnStick(class PositionManager* this) + +005551e0 { +005551e0 class StickyManager* sticky_manager = this->sticky_manager; +005551e0 +005551e5 if (sticky_manager == 0) +005551ec return; +005551ec +005551e7 /* tailcall */ +005551e7 return StickyManager::UnStick(sticky_manager); +005551e0 } +``` + +### `PositionManager::InterpolateTo` — 0x005551f0 (context — sibling method) + +```c +005551f0 void __thiscall PositionManager::InterpolateTo(class PositionManager* this, class Position const* arg2, int32_t arg3) + +005551f0 { +005551f6 if (this->interpolation_manager == 0) +00555204 this->interpolation_manager = InterpolationManager::Create(this->physics_obj); +00555204 +00555212 InterpolationManager::InterpolateTo(this->interpolation_manager, arg2, arg3); +005551f0 } +``` + +### `PositionManager::StopInterpolating` — 0x00555220 (context) + +```c +00555220 void __fastcall PositionManager::StopInterpolating(class PositionManager* this) + +00555220 { +00555220 class InterpolationManager* interpolation_manager = this->interpolation_manager; +00555220 +00555224 if (interpolation_manager == 0) +0055522b return; +0055522b +00555226 /* tailcall */ +00555226 return InterpolationManager::StopInterpolating(interpolation_manager); +00555220 } +``` + +### `PositionManager::StickTo` — 0x00555230 + +```c +00555230 void __thiscall PositionManager::StickTo(class PositionManager* this, uint32_t arg2, float arg3, float arg4) + +00555230 { +00555238 if (this->sticky_manager == 0) +00555246 this->sticky_manager = StickyManager::Create(this->physics_obj); +00555246 +0055525b StickyManager::StickTo(this->sticky_manager, arg2, arg3, arg4); +00555230 } +``` + +Lazy-creates the `StickyManager` on first stick, then delegates. `arg2` = target object +id, `arg3` = radius, `arg4` = height (per callers below). + +### `PositionManager::GetStickyObjectID` — 0x00555270 (context) + +```c +00555270 uint32_t __fastcall PositionManager::GetStickyObjectID(class PositionManager const* this) + +00555270 { +00555270 class StickyManager* sticky_manager = this->sticky_manager; +00555270 +00555275 if (sticky_manager == 0) +0055527e return 0; +0055527e +00555277 /* tailcall */ +00555277 return CommandList::GetHead(sticky_manager); +00555270 } +``` + +NOTE: `CommandList::GetHead(sticky_manager)` is an obviously wrong callee name for a +tailcall that's supposed to read `sticky_manager->target_id` (a plain `uint32_t` at +offset 0 of `StickyManager`). This looks like a BN symbol-resolution mixup (the real +function returns `this->sticky_manager->target_id` directly — likely inlined/no +separate symbol, and BN picked a spurious nearby symbol for the tailcall target). +Flagging, not correcting. + +### `PositionManager::ConstrainTo` — 0x00555280 (context — sibling method) + +```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 } +``` + +### `PositionManager::UnConstrain` — 0x005552b0 (context) + +```c +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 } +``` + +### `PositionManager::IsFullyConstrained` — 0x005552c0 + +```c +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 (ctor/factory) + +```c +005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1) + +005552d0 { +005552d3 void* result = operator new(0x10); +005552d3 +005552df if (result == 0) +00555332 return 0; +00555332 +005552e1 *(uint32_t*)result = 0; +005552e7 *(uint32_t*)((char*)result + 4) = 0; +005552ee *(uint32_t*)((char*)result + 8) = 0; +005552f5 *(uint32_t*)((char*)result + 0xc) = 0; +005552fc class QuickWindow* ecx = *(uint32_t*)result; +00555305 *(uint32_t*)((char*)result + 0xc) = arg1; +00555305 +00555308 if (ecx != 0) +0055530b QuickWindow::SetWindowID(ecx, arg1); +0055530b +00555310 class StickyManager* ecx_1 = *(uint32_t*)((char*)result + 4); +00555310 +00555315 if (ecx_1 != 0) +00555318 StickyManager::SetPhysicsObject(ecx_1, arg1); +00555318 +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 } +``` + +NOTE: `QuickWindow* ecx = *(uint32_t*)result;` / `QuickWindow::SetWindowID(ecx, arg1)` +is another BN symbol-mixup artifact — `ecx` here is really the freshly-zeroed +`interpolation_manager` field (offset 0, which was just set to 0 two lines above), so +this branch is structurally dead at construction time (the null-check always fails right +after `operator new` zeroes the block) and the "QuickWindow::SetWindowID" callee name is +spurious. Read this constructor as: `operator new(0x10)` → zero all 4 fields +(interpolation_manager, sticky_manager, constraint_manager @ offsets 0/4/8) → set +`physics_obj` (offset 0xc) = `arg1` → then three no-op "if member != 0, call +member::SetPhysicsObject(arg1)" guards that are all unreachable immediately after the +zero-fill (they'd only fire if a sub-manager pointer were already non-null, which can't +happen on a freshly allocated struct). This is very likely how the retail source +literally reads even though the guards are dead code at this call site — the same +"if (member) member->SetPhysicsObject(this)" idiom recurs in `StickyManager::Create`. + +### `PositionManager::Destroy` — 0x00555340 (dtor helper) + +```c +00555340 void __fastcall PositionManager::Destroy(class PositionManager* this) + +00555340 { +00555344 class InterpolationManager* interpolation_manager = this->interpolation_manager; +00555344 +00555348 if (interpolation_manager != 0) +00555348 { +0055534c InterpolationManager::~InterpolationManager(interpolation_manager); +00555352 operator delete(interpolation_manager); +00555348 } +00555348 +0055535a class StickyManager* sticky_manager = this->sticky_manager; +0055535f this->interpolation_manager = 0; +0055535f +00555365 if (sticky_manager != 0) +00555365 { +00555369 StickyManager::~StickyManager(sticky_manager); +0055536f operator delete(sticky_manager); +00555365 } +00555365 +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 } +``` + +### `PositionManager::~PositionManager` — 0x005553a0 (dtor, tailcalls Destroy) + +```c +005553a0 void __fastcall PositionManager::~PositionManager(class PositionManager* this) + +005553a0 { +005553a0 /* tailcall */ +005553a0 return PositionManager::Destroy(this); +005553a0 } +``` + +### `PositionManager::IsInterpolating` — 0x005553b0 (context) + +```c +005553b0 int32_t __fastcall PositionManager::IsInterpolating(class PositionManager const* this) + +005553b0 { +005553b0 class InterpolationManager* interpolation_manager = this->interpolation_manager; +005553b0 +005553b4 if (interpolation_manager == 0) +005553c4 return 0; +005553c4 +005553bc int32_t result; +005553bc result = interpolation_manager->position_queue.head_ != 0; +005553c1 return result; +005553b0 } +``` + +### `PositionManager::HandleUpdateTarget` — 0x005553d0 + +```c +005553d0 void __fastcall PositionManager::HandleUpdateTarget(class PositionManager* this, class TargetInfo arg2) + +005553d0 { +005553d8 if (this->sticky_manager != 0) +005553d8 { +005553ea void var_d4; +005553ea TargetInfo::TargetInfo(&var_d4, &arg2); +005553f2 StickyManager::HandleUpdateTarget(this->sticky_manager, var_d4); +005553d8 } +005553d0 } +``` + +Only forwards to `StickyManager` (the `InterpolationManager` / `ConstraintManager` +siblings don't care about target-info updates — only sticky-follow does, since it needs +the live position of the object it's stuck to). + +--- + +## StickyManager methods + +### `StickyManager::UnStick` — 0x00555400 + +```c +00555400 void __fastcall StickyManager::UnStick(class StickyManager* this) + +00555400 { +00555406 if (this->target_id == 0) +00555427 return; +00555427 +00555408 class CPhysicsObj* physics_obj = this->physics_obj; +0055540b this->target_id = 0; +00555411 this->initialized = 0; +00555418 CPhysicsObj::clear_target(physics_obj); +00555421 /* tailcall */ +00555421 return CPhysicsObj::interrupt_current_movement(this->physics_obj); +00555400 } +``` + +No-op if not currently stuck. Otherwise: clear `target_id` + `initialized`, tell the +owning `CPhysicsObj` to clear its target-tracking registration +(`CPhysicsObj::clear_target`), then interrupt whatever movement is in flight +(`CPhysicsObj::interrupt_current_movement`) — this is the standard +"unstick invalidates in-progress movement" pattern repeated in every unstick path +below (`UseTime`'s timeout branch, `StickTo`'s re-stick branch, +`HandleUpdateTarget`'s failure branch all do the exact same 4-line sequence). + +### `StickyManager::adjust_offset` — 0x00555430 + +```c +00555430 void __thiscall StickyManager::adjust_offset(class StickyManager* this, class Frame* arg2, double arg3) + +00555430 { +00555436 uint32_t target_id = this->target_id; +00555436 +00555445 if ((target_id != 0 && this->initialized != 0)) +00555445 { +00555459 class Position* edi_2 = &this->physics_obj->m_position; +0055545c class CPhysicsObj* eax = CPhysicsObj::GetObjectA(target_id); +00555466 class Position* ebp_1 = &eax->m_position; +00555466 +00555469 if (eax == 0) +0055546b ebp_1 = &this->target_position; +0055546b +00555476 int32_t __return; +00555476 class AC1Legacy::Vector3* eax_1 = Position::get_offset(edi_2, &__return, ebp_1); +00555456 arg2->m_fOrigin.x = eax_1->x; +00555456 arg2->m_fOrigin.y = eax_1->y; +00555456 arg2->m_fOrigin.z = eax_1->z; +00555495 class AC1Legacy::Vector3* eax_3 = Position::globaltolocalvec(edi_2, &__return, &arg2->m_fOrigin); +00555456 arg2->m_fOrigin.x = eax_3->x; +00555456 arg2->m_fOrigin.y = eax_3->y; +00555456 arg2->m_fOrigin.z = eax_3->z; +00555456 arg2->m_fOrigin.z = 0f; +005554b3 float target_radius = this->target_radius; +005554c1 int32_t var_34_1 = CPhysicsObj::GetRadius(this->physics_obj); +005554d5 long double x87_r0; +005554d5 float var_14_1 = ((float)(Position::cylinder_distance_no_z(((float)x87_r0), edi_2, target_radius, ebp_1) - ((long double)0.300000012f))); +005554d5 int16_t top_1 = 1; +005554d5 +005554e0 if (AC1Legacy::Vector3::normalize_check_small(&arg2->m_fOrigin) != 0) +005554e0 { +005554e2 __return = 0; +00555456 arg2->m_fOrigin.x = __return; +00555456 arg2->m_fOrigin.y = 0f; +00555456 arg2->m_fOrigin.z = 0f; +005554e0 } +005554e0 +00555522 class CMotionInterp* eax_7; +00555522 long double st0_2; +00555522 +00555522 if (CPhysicsObj::get_minterp(this->physics_obj) == 0) +0055553b top_1 = 0; +00555522 else +0055552e eax_7 = CMotionInterp::get_max_speed(CPhysicsObj::get_minterp(this->physics_obj)); +0055553f long double temp1_1 = ((long double)0.000199999995f); +0055553f (/* unimplemented {fcom st0, dword [&F_EPSILON]} f- temp1_1 */ - temp1_1); +0055553f bool c0_1 = /* bool c0_1 = unimplemented {fcom st0, dword [&F_EPSILON]} f< temp1_1 */ < temp1_1; +0055553f bool c2_1 = (FCMP_UO(/* bool c2_1 = is_unordered.t(unimplemented {fcom st0, dword [&F_EPSILON]}, temp1_1) */, temp1_1)); +0055553f bool c3_1 = /* bool c3_1 = unimplemented {fcom st0, dword [&F_EPSILON]} f== temp1_1 */ == temp1_1; +00555545 eax_7 = ((((c0_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_1) ? 1 : 0) << 0xa) | ((((c3_1) ? 1 : 0) << 0xe) | ((top_1 & 7) << 0xb))))); +00555547 bool p_1 = /* bool p_1 = unimplemented {test ah, 0x5} */; +00555547 +0055554a if (!(p_1)) +0055554a { +0055554c /* unimplemented {fstp st0, st0} */; +0055554c /* unimplemented {fstp st0, st0} */; +0055554e /* unimplemented {fld st0, dword [&MAX_VELOCITY]} */; +0055554a } +0055554a +00555554 /* unimplemented {fmul st0, qword [esp+0x2c]} */; +00555558 /* unimplemented {fld st0, dword [esp+0x10]} */; +0055555c /* unimplemented {fabs } */; +0055555e /* unimplemented {fld st0, st1} */; +00555560 (/* unimplemented {fcompp } f- unimplemented {fcompp } */ - /* unimplemented {fcompp } f- unimplemented {fcompp } */); +00555560 bool c0_2 = /* bool c0_2 = unimplemented {fcompp } f< unimplemented {fcompp } */ < /* bool c0_2 = unimplemented {fcompp } f< unimplemented {fcompp } */; +00555560 bool c2_2 = (FCMP_UO(/* bool c2_2 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */, /* bool c2_2 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */)); +00555560 bool c3_2 = /* bool c3_2 = unimplemented {fcompp } f== unimplemented {fcompp } */ == /* bool c3_2 = unimplemented {fcompp } f== unimplemented {fcompp } */; +00555560 /* unimplemented {fcompp } */; +00555560 /* unimplemented {fcompp } */; +00555562 eax_7 = ((((c0_2) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_2) ? 1 : 0) << 0xa) | ((((c3_2) ? 1 : 0) << 0xe) | ((top_1 & 7) << 0xb))))); +00555564 bool p_2 = /* bool p_2 = unimplemented {test ah, 0x5} */; +00555567 int16_t top_9; +00555567 +00555567 if (p_2) +00555567 { +00555579 /* unimplemented {fstp st0, st0} */; +00555579 /* unimplemented {fstp st0, st0} */; +0055557b /* unimplemented {fld st0, dword [esp+0x10]} */; +0055557f /* unimplemented {fmul st0, dword [esi]} */; +00555456 arg2->m_fOrigin.x = ((float)/* arg2->m_fOrigin.x = fconvert.s(unimplemented {fstp dword [esi], st0}) */); +00555581 /* unimplemented {fstp dword [esi], st0} */; +00555583 /* unimplemented {fld st0, dword [esp+0x10]} */; +00555587 /* unimplemented {fmul st0, dword [esi+0x4]} */; +00555456 arg2->m_fOrigin.y = ((float)/* arg2->m_fOrigin.y = fconvert.s(unimplemented {fstp dword [esi+0x4], st0}) */); +0055558a /* unimplemented {fstp dword [esi+0x4], st0} */; +0055558d top_9 = top_1; +0055558d /* unimplemented {fld st0, dword [esp+0x10]} */; +00555567 } +00555567 else +00555567 { +00555569 /* unimplemented {fld st0, st0} */; +0055556b /* unimplemented {fmul st0, dword [esi]} */; +00555456 arg2->m_fOrigin.x = ((float)/* arg2->m_fOrigin.x = fconvert.s(unimplemented {fstp dword [esi], st0}) */); +0055556d /* unimplemented {fstp dword [esi], st0} */; +0055556f /* unimplemented {fld st0, st0} */; +00555571 /* unimplemented {fmul st0, dword [esi+0x4]} */; +00555456 arg2->m_fOrigin.y = ((float)/* arg2->m_fOrigin.y = fconvert.s(unimplemented {fstp dword [esi+0x4], st0}) */); +00555574 /* unimplemented {fstp dword [esi+0x4], st0} */; +00555574 top_9 = top_1; +00555567 } +00555567 +00555591 /* unimplemented {fmul st0, dword [esi+0x8]} */; +00555456 arg2->m_fOrigin.z = ((float)/* arg2->m_fOrigin.z = fconvert.s(unimplemented {fstp dword [esi+0x8], st0}) */); +00555597 /* unimplemented {fstp dword [esi+0x8], st0} */; +0055559a Position::heading(edi_2, ebp_1); +005555a2 arg3 = ((float)/* arg3.d = fconvert.s(unimplemented {fstp dword [esp+0x2c], st0}) */); +005555a2 /* unimplemented {fstp dword [esp+0x2c], st0} */; +005555a6 Frame::get_heading(&edi_2->frame); +005555a6 /* unimplemented {call Frame::get_heading} */; +005555ab /* unimplemented {fsubr st0, dword [esp+0x2c]} */; +005555b1 arg3 = ((float)/* arg3.d = fconvert.s(unimplemented {fst dword [esp+0x24], st0}) */); +005555b6 /* unimplemented {fabs } */; +005555b8 /* unimplemented {fld st0, dword [&F_EPSILON]} */; +005555be (/* unimplemented {fcompp } f- unimplemented {fcompp } */ - /* unimplemented {fcompp } f- unimplemented {fcompp } */); +005555be bool c0_3 = /* bool c0_3 = unimplemented {fcompp } f< unimplemented {fcompp } */ < /* bool c0_3 = unimplemented {fcompp } f< unimplemented {fcompp } */; +005555be bool c2_3 = (FCMP_UO(/* bool c2_3 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */, /* bool c2_3 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */)); +005555be bool c3_3 = /* bool c3_3 = unimplemented {fcompp } f== unimplemented {fcompp } */ == /* bool c3_3 = unimplemented {fcompp } f== unimplemented {fcompp } */; +005555be /* unimplemented {fcompp } */; +005555be /* unimplemented {fcompp } */; +005555c5 if ((*(uint8_t*)((char*)((((c0_3) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_3) ? 1 : 0) << 0xa) | ((((c3_3) ? 1 : 0) << 0xe) | (((top_9 + 3) & 7) << 0xb))))))[1] & 0x41) == 0) +005555c7 arg3 = 0; +005555c7 +005555cf /* unimplemented {fld st0, dword [&F_EPSILON]} */; +005555d5 /* unimplemented {fchs } */; +005555d7 long double temp2_1 = ((long double)arg3); +005555d7 (/* unimplemented {fcomp st0, dword [esp+0x20]} f- temp2_1 */ - temp2_1); +005555d7 bool c0_4 = /* bool c0_4 = unimplemented {fcomp st0, dword [esp+0x20]} f< temp2_1 */ < temp2_1; +005555d7 bool c2_4 = (FCMP_UO(/* bool c2_4 = is_unordered.t(unimplemented {fcomp st0, dword [esp+0x20]}, temp2_1) */, temp2_1)); +005555d7 bool c3_4 = /* bool c3_4 = unimplemented {fcomp st0, dword [esp+0x20]} f== temp2_1 */ == temp2_1; +005555d7 /* unimplemented {fcomp st0, dword [esp+0x20]} */; +005555d7 +005555e0 if ((*(uint8_t*)((char*)((((c0_4) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_4) ? 1 : 0) << 0xa) | ((((c3_4) ? 1 : 0) << 0xe) | (((top_9 + 3) & 7) << 0xb))))))[1] & 0x41) == 0) +005555e0 { +005555e2 /* unimplemented {fld st0, dword [esp+0x20]} */; +005555e6 /* unimplemented {fadd dword [&data_79bc60]} */; +005555ec arg3 = ((float)/* arg3.d = fconvert.s(unimplemented {fstp dword [esp+0x20], st0}) */); +005555ec /* unimplemented {fstp dword [esp+0x20], st0} */; +005555e0 } +005555e0 +005555f9 Frame::set_heading(arg2, arg3); +00555445 } +00555430 } +``` + +NOTE (heavy x87 mush, flagging for lead decode — do not treat as porting-ready without +decode): the whole back half of this function (from `005554d5` on) is a dense block of +`/* unimplemented */` x87 FPU stack operations (fcom/fcomp/fcompp/fabs/fchs/fld/fstp) that +BN could not lift to structured expressions, interleaved with FCOM-condition-code +decoding boilerplate (`c0`/`c2`/`c3` bit extraction feeding a fake "ah test 0x45" parity +check — this is the compiler's expansion of `if (x < y)` / `if (fabs(x) < EPSILON)` style +float comparisons via `fcomp` + `fnstsw ax` + `sahf`, which BN failed to fold back into a +plain comparison). Mechanically, best-effort reading of what's happening despite the mush: + +1. Early-out unless `target_id != 0 && initialized != 0` (mirrors `UnStick`'s guard). +2. Resolve the live target object via `CPhysicsObj::GetObjectA(target_id)`; if it's gone, + fall back to the cached `this->target_position` (the last known position from + `HandleUpdateTarget`). +3. Compute `Position::get_offset(my_position, &out, target_position)` — the offset vector + from self to target — store into `arg2->m_fOrigin` (the output `Frame`'s origin, + i.e. this becomes the STICK MOVEMENT DELTA for the frame). +4. Convert that offset to local space via `Position::globaltolocalvec`, then FORCE + `.z = 0` — sticky movement is horizontal-only (no auto-adjust of vertical offset; + presumably z is handled by normal physics/ground snap). +5. Compute `target_radius` (cached) + `CPhysicsObj::GetRadius(physics_obj)` (own radius) + feeding `Position::cylinder_distance_no_z(...)` minus a `0.3f` constant — a horizontal + cylinder-distance-to-target minus a 0.3-unit buffer, producing a "how far past the + desired follow-distance are we" scalar (`var_14_1`). +6. `AC1Legacy::Vector3::normalize_check_small(&arg2->m_fOrigin)` — normalizes the offset + direction in place; if the vector was too small to normalize (near-zero), the offset + is zeroed out entirely (no stick movement this tick — already at the target distance). +7. The x87 mush (`005554d5`–`005555f9`) computes a **clamped speed-scaled offset**: reads + `CMotionInterp::get_max_speed` from the owning object's `CMotionInterp` (or falls back + to a `MAX_VELOCITY` constant if no motion interpreter), clamps the per-tick offset + magnitude by that speed, applies it to `x`/`y` (still `z = 0`), then separately + computes a target HEADING via `Position::heading(edi_2, ebp_1)` (heading from self to + target position) minus the object's current heading (`Frame::get_heading`), applies an + epsilon-based snap-to-target-heading (small residual heading errors get zeroed; + large ones get consumed additively — likely turning the stuck object to face the + target it's following at a bounded turn rate, akin to a max-turn-rate clamp), and + finally writes the resulting heading via `Frame::set_heading(arg2, arg3)`. + +**Net semantic despite the mush**: `StickyManager::adjust_offset` computes, for THIS +tick's quantum, a bounded (speed-clamped, distance-buffered) horizontal position delta +plus a bounded heading delta that steers the sticking object toward its `target_id`'s +position, writing both into the `Frame*` accumulator (`arg2`) that +`PositionManager::adjust_offset` shares across all three sub-managers, which +`CPhysicsObj::UpdatePositionInternal` then combines into `this->m_position.frame` via +`Frame::combine`. This is a follow/leash steering behavior, not a hard position clamp — +it moves gradually per-tick toward the target, speed- and turn-rate-limited. + +### `StickyManager::UseTime` — 0x00555610 + +```c +00555610 void __fastcall StickyManager::UseTime(class StickyManager* this) + +00555610 { +00555616 if (this->target_id != 0) +00555616 { +00555618 long double x87_r7_1 = ((long double)Timer::cur_time); +0055561e long double temp0_1 = ((long double)this->sticky_timeout_time); +0055561e (x87_r7_1 - temp0_1); +0055561e +00555626 if ((*(uint8_t*)((char*)((((x87_r7_1 < temp0_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp0_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp0_1) ? 1 : 0) << 0xe) | 0)))))[1] & 0x41) == 0) +00555626 { +00555628 class CPhysicsObj* physics_obj = this->physics_obj; +0055562b this->target_id = 0; +00555631 this->initialized = 0; +00555638 CPhysicsObj::clear_target(physics_obj); +00555641 /* tailcall */ +00555641 return CPhysicsObj::interrupt_current_movement(this->physics_obj); +00555626 } +00555616 } +00555610 } +``` + +NOTE: same FCOM-condition-code mush pattern as `adjust_offset`, but decodable cleanly +this time — the whole block is `if (!(Timer::cur_time < sticky_timeout_time)) { unstick }`, +i.e. **`if (Timer::cur_time >= this->sticky_timeout_time) { UnStick-equivalent }`**. This +is a per-tick sticky-TIMEOUT check: `StickTo` (below) sets `sticky_timeout_time = +Timer::cur_time + 1.0f` at stick time, and every subsequent call to `UseTime` re-checks +whether that 1-second deadline has passed; if so it force-clears the stick (same 4-step +teardown as `UnStick`: clear `target_id`/`initialized`, `CPhysicsObj::clear_target`, +`CPhysicsObj::interrupt_current_movement`). Since `StickTo` is the ONLY writer of +`sticky_timeout_time` and it's always `+1.0f` from stick-time, and `initialized` is +separately set by `HandleUpdateTarget` on receiving a fresh target position — this reads +as **"if we haven't heard a target-position update within 1 second of sticking, give +up the stick."** The deadline is NOT refreshed by `HandleUpdateTarget` in the code we +pulled — worth double-checking against a wider window, but as extracted, the timeout is +a one-shot "1 second grace period to get initialized" rather than a rolling heartbeat. + +### `StickyManager::Destroy` — 0x00555650 + +```c +00555650 void __fastcall StickyManager::Destroy(class StickyManager* this) + +00555650 { +00555659 if (this->target_id != 0) +0055565e CPhysicsObj::clear_target(this->physics_obj); +0055565e +00555667 this->target_id = 0; +0055566d int32_t var_48 = 0x796910; +0055567d int32_t var_40 = 0x3f800000; +00555685 int32_t var_3c = 0; +0055568d int32_t var_38 = 0; +00555695 int32_t var_34 = 0; +0055569d int32_t var_c = 0; +005556a5 int32_t var_8 = 0; +005556ad int32_t var_4 = 0; +005556b5 Frame::cache(&var_40); +005556c6 this->target_position.objcell_id = 0; +005556c9 Frame::operator=(&this->target_position.frame, &var_40); +005556ce this->initialized = 0; +00555650 } +``` + +Note this does NOT call `interrupt_current_movement` (unlike `UnStick`/`UseTime`'s +timeout branch/`StickTo`'s re-stick branch/`HandleUpdateTarget`'s failure branch) — +`Destroy` just tears down state (clear target id, reset cached `target_position` to an +identity `Frame` via `Frame::cache`, clear `initialized`) without touching movement. It's +called from `SetPhysicsObject` (when re-parenting) and the destructor. + +### `StickyManager::SetPhysicsObject` — 0x005556e0 + +```c +005556e0 void __thiscall StickyManager::SetPhysicsObject(class StickyManager* this, class CPhysicsObj* arg2) + +005556e0 { +005556e8 if (this->physics_obj == 0) +005556e8 { +005556fe this->physics_obj = arg2; +00555702 return; +005556e8 } +005556e8 +005556ea StickyManager::Destroy(this); +005556f3 this->physics_obj = arg2; +005556e0 } +``` + +First-time set is a plain assignment; re-set (physics_obj already non-null) tears down +any existing stick state first via `Destroy`, then reassigns. + +### `StickyManager::StickTo` — 0x00555710 + +```c +00555710 void __thiscall StickyManager::StickTo(class StickyManager* this, uint32_t arg2, float arg3, float arg4) + +00555710 { +00555716 if (this->target_id != 0) +00555716 { +00555718 class CPhysicsObj* physics_obj = this->physics_obj; +0055571b this->target_id = 0; +00555721 this->initialized = 0; +00555728 CPhysicsObj::clear_target(physics_obj); +00555730 CPhysicsObj::interrupt_current_movement(this->physics_obj); +00555716 } +00555716 +00555749 this->target_radius = arg3; +0055574c class CPhysicsObj* physics_obj_1 = this->physics_obj; +0055574f this->target_id = arg2; +00555751 long double x87_r7_1 = (((long double)1f) + ((long double)Timer::cur_time)); +0055575a this->initialized = 0; +00555761 this->sticky_timeout_time = ((double)x87_r7_1); +00555771 CPhysicsObj::set_target(physics_obj_1, 0, arg2, 0.5f, ((double)((long double)0.5f))); +00555710 } +``` + +NOTE: `arg4` (the 4th parameter, `height`, per callers) is READ NOWHERE in this function +body as extracted — only `arg2` (target id) and `arg3` (radius) are used; +`target_radius = arg3` and the hardcoded `set_target(..., 0.5f, 0.5)` constants are used +instead of `arg4`. Either `height` genuinely goes unused by `StickTo` itself (it's only +consumed elsewhere, e.g. by `PositionManager::StickTo`'s caller-side height computation +in `CPhysicsObj::stick_to_object`, purely for logging/display), or BN dropped a store. +Flagging — worth a targeted look at whether `arg4` should feed the `set_target` call's +`0.5f` radius/height-tolerance constants. + +If already stuck to something, first tears down the existing stick (same 4-step sequence +as `UnStick`). Then: cache `target_radius`, set `target_id = arg2`, reset +`initialized = 0` (fresh stick — no cached target position yet, must wait for +`HandleUpdateTarget`), set `sticky_timeout_time = Timer::cur_time + 1.0` (the 1-second +grace window `UseTime` checks), and call `CPhysicsObj::set_target(physics_obj, 0, arg2, +0.5f, 0.5)` — registers with the OWN physics object's target-tracking system (context id +0, target = arg2, with `0.5f`/`0.5` as some pair of radius/height-delta-tolerance +constants) so that server/game updates about the target's position get routed back +through `HandleUpdateTarget`. + +### `StickyManager::HandleUpdateTarget` — 0x00555780 + +```c +00555780 void __thiscall StickyManager::HandleUpdateTarget(class StickyManager* this, class TargetInfo arg2) + +00555780 { +00555783 uint32_t target_id = this->target_id; +00555783 +00555789 if (arg2.object_id == target_id) +00555789 { +00555799 if (arg2.status == Ok_TargetStatus) +00555799 { +0055579b uint32_t objcell_id = arg2.target_position.objcell_id; +0055579f this->initialized = 1; +005557aa this->target_position.objcell_id = objcell_id; +005557ad Frame::operator=(&this->target_position.frame, &arg2.target_position.frame); +005557b3 return; +00555799 } +00555799 +005557b8 if (target_id != 0) +005557b8 { +005557ba class CPhysicsObj* physics_obj = this->physics_obj; +005557bd this->target_id = 0; +005557c3 this->initialized = 0; +005557ca CPhysicsObj::clear_target(physics_obj); +005557d2 CPhysicsObj::interrupt_current_movement(this->physics_obj); +005557b8 } +00555789 } +00555780 } +``` + +Only reacts if the incoming `TargetInfo.object_id` matches our current `target_id` +(stale/mismatched updates from a previously-stuck target are ignored). On +`Ok_TargetStatus`: cache the target's `objcell_id` + `Frame` into `this->target_position` +and set `initialized = 1` — this is what `adjust_offset` reads when the live +`CPhysicsObj::GetObjectA(target_id)` lookup fails, and what gates `UseTime`'s "haven't +heard back within 1s" cleanup path since `initialized` starts false. On any OTHER status +(target lost/out of range/error), tears down the stick with the same 4-step sequence. + +### `StickyManager::~StickyManager` — 0x005557e0 (dtor) + +```c +005557e0 void __fastcall StickyManager::~StickyManager(class StickyManager* this) + +005557e0 { +005557e3 StickyManager::Destroy(this); +005557e8 this->target_position.vtable = 0x79285c; +005557e0 } +``` + +NOTE: `this->target_position.vtable = 0x79285c` — `target_position` is a `Position` +struct which embeds a `Frame`; the `Frame` type apparently starts with (or BN is +interpreting an early field as) a vtable-looking pointer that gets reset to a static +value at destruction (likely resetting an embedded `Frame`'s internal vtable-like tag +back to the base `Frame` type's identity, undoing any polymorphic Frame subtype). Kept +verbatim per instructions. + +### `StickyManager::Create` — 0x00555800 (ctor/factory) + +```c +00555800 class StickyManager* StickyManager::Create(class CPhysicsObj* arg1) + +00555800 { +00555804 void* result = operator new(0x60); +00555804 +00555812 if (result == 0) +00555866 return 0; +00555866 +00555814 *(uint32_t*)result = 0; +00555816 *(uint32_t*)((char*)result + 4) = 0; +0055581c *(uint32_t*)((char*)result + 8) = 0x796910; +00555823 *(uint32_t*)((char*)result + 0xc) = 0; +00555826 *(uint32_t*)((char*)result + 0x10) = 0x3f800000; +0055582c *(uint32_t*)((char*)result + 0x14) = 0; +0055582f *(uint32_t*)((char*)result + 0x18) = 0; +00555832 *(uint32_t*)((char*)result + 0x1c) = 0; +00555835 *(uint32_t*)((char*)result + 0x44) = 0; +00555838 *(uint32_t*)((char*)result + 0x48) = 0; +0055583b *(uint32_t*)((char*)result + 0x4c) = 0; +0055583e Frame::cache(((char*)result + 0x10)); +00555847 *(uint32_t*)((char*)result + 0x50) = 0; +0055584a *(uint32_t*)((char*)result + 0x54) = 0; +0055585a *(uint32_t*)((char*)result + 0x50) = arg1; +00555861 return result; +00555800 } +``` + +Allocates 0x60 bytes, zero-fills `target_id` (off 0) / `target_radius` (off 4), sets +`target_position.objcell_id` (off 8) = `0x796910` — NOTE: this is almost certainly a +vtable pointer for the embedded `Frame`/`Position` substructure rather than a literal +objcell id (0x796910 recurs as a "vtable"-looking constant elsewhere in this file, e.g. +`StickyManager::~StickyManager`'s `target_position.vtable = 0x79285c` uses a DIFFERENT +constant for a similarly-named field, and `PositionManager::Destroy`/`MoveToObject_*` +functions stack-allocate local `Frame`s with the same `0x796910` / `0x3f800000` pair as +identity-transform sentinels). `Frame::cache(result + 0x10)` then initializes the +embedded `Frame` (identity transform: the `0x3f800000` = `1.0f` at offset 0x10 lines up +with a scale/quaternion-w identity component) at the `target_position.frame` sub-offset. +Finally `physics_obj` (off 0x50) = `arg1`, `initialized` (off 0x54, zeroed) stays 0, +`sticky_timeout_time` (off 0x58, implicitly zero from the earlier ops covering 0x44-0x4c) +stays 0. Net: constructs an unstuck, uninitialized `StickyManager` bound to `arg1`. + +--- + +## CPhysicsObj sticky/position seams + +### `CPhysicsObj::MakePositionManager` — 0x00510210 + +```c +00510210 void __fastcall CPhysicsObj::MakePositionManager(class CPhysicsObj* this) + +00510210 { +0051021b if (this->position_manager == 0) +00510226 this->position_manager = PositionManager::Create(this); +00510226 +00510233 if ((this->state & 1) == 0) +00510233 { +00510235 uint32_t transient_state = this->transient_state; +00510235 +0051023d if (transient_state >= 0) +0051023d { +0051024b this->update_time = (*(uint32_t*)Timer::cur_time); +00510251 *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); +0051023d } +0051023d +0051025c this->transient_state = (transient_state | 0x80); +00510233 } +00510210 } +``` + +Lazy factory: creates `this->position_manager` only if not already present (idempotent — +safe to call unconditionally, which every caller below does). The second half +(`state & 1`, `transient_state`, `update_time` stamping, `transient_state |= 0x80`) is +NOT specific to PositionManager — it's the same "mark object as needing an update tick / +stamp last-update-time" boilerplate that recurs verbatim in `MakeMovementManager`, +`MoveToObject_Internal`, `TurnToObject_Internal`, `MoveToObject`, `TurnToHeading` (all +seen in the extracted callers below) — i.e. lazily creating ANY manager also flags the +object as active/dirty for the next tick's `UpdateObjectInternal` sweep. `state & 1` is +presumably an "already in the active/dirty set" bit being checked before re-flagging. + +### `CPhysicsObj::get_position_manager` — 0x00512130 + +```c +00512130 class PositionManager* __fastcall CPhysicsObj::get_position_manager(class CPhysicsObj* this) + +00512130 { +00512133 CPhysicsObj::MakePositionManager(this); +0051213f return this->position_manager; +00512130 } +``` + +Accessor that guarantees lazy-creation before returning — callers never need their own +null check. + +### `CPhysicsObj::stick_to_object` — 0x005127e0 + +```c +005127e0 void __thiscall CPhysicsObj::stick_to_object(class CPhysicsObj* this, uint32_t arg2) + +005127e0 { +005127e0 class CPhysicsObj* this_1 = this; +005127e4 CPhysicsObj::MakePositionManager(this); +005127e9 class CObjectMaint* CPhysicsObj::obj_maint_1 = CPhysicsObj::obj_maint; +005127e9 +005127f1 if (CPhysicsObj::obj_maint_1 != 0) +005127f1 { +005127f8 class CPhysicsObj* parent_2 = CObjectMaint::GetObjectA(CPhysicsObj::obj_maint_1, arg2); +005127f8 +005127ff if (parent_2 != 0) +005127ff { +00512802 class CPhysicsObj* parent_1 = parent_2; +00512804 class CPhysicsObj* parent = parent_2->parent; +00512804 +00512809 if (parent != 0) +0051280b parent_1 = parent; +0051280b +0051280d class CPartArray* part_array = parent_1->part_array; +0051280d +00512812 if (part_array == 0) +0051281f arg2 = 0; +00512812 else +00512819 arg2 = ((float)CPartArray::GetHeight(part_array)); +00512819 +00512827 class CPartArray* part_array_1 = parent_1->part_array; +00512827 +0051282c if (part_array_1 == 0) +00512839 this_1 = nullptr; +0051282c else +00512833 this_1 = ((float)CPartArray::GetRadius(part_array_1)); +00512833 +00512855 PositionManager::StickTo(this->position_manager, parent_1->id, this_1, arg2); +005127ff } +005127f1 } +005127e0 } +``` + +`arg2` comes in as a target object id, resolved via the global `CObjectMaint` +(`CObjectMaint::GetObjectA`); if the resolved object has its OWN `parent` (e.g. it's a +sub-part of a multi-part object like a wielded item), the stick target is redirected to +the TOP-LEVEL parent instead (`parent_1 = parent`). Radius/height for the stick are +pulled from that top-level parent's `CPartArray` (`GetRadius`/`GetHeight`) — this is +where `StickTo`'s `arg3`(radius)/`arg4`(height) parameters actually originate: +geometry of the object being stuck to, not the sticking object itself. Finally calls +`PositionManager::StickTo(this->position_manager, parent_1->id, radius, height)` — note +it sticks to `parent_1->id` (the resolved top-level id), NOT the original `arg2` id +passed in. + +### `CPhysicsObj::unstick_from_object` — 0x0050eae0 + +```c +0050eae0 void __fastcall CPhysicsObj::unstick_from_object(class CPhysicsObj* this) + +0050eae0 { +0050eae0 class PositionManager* position_manager = this->position_manager; +0050eae0 +0050eae8 if (position_manager == 0) +0050eaef return; +0050eaef +0050eaea /* tailcall */ +0050eaea return PositionManager::UnStick(position_manager); +0050eae0 } +``` + +Thin wrapper: null-safe forward to `PositionManager::UnStick` (which itself forwards to +`StickyManager::UnStick`). Unlike `MakePositionManager`, this does NOT lazily create — +if there's no `position_manager` yet, there's nothing to unstick. + +--- + +## Callers — where retail invokes these in the per-tick physics chain + +### `CPhysicsObj::UpdatePositionInternal` (0x00512c30) — calls `PositionManager::adjust_offset` + +```c +00512c30 void __thiscall CPhysicsObj::UpdatePositionInternal(class CPhysicsObj* this, float arg2, class Frame* arg3) + +00512c30 { +00512c3c int32_t var_40 = 0x3f800000; +00512c44 int32_t var_3c = 0; +00512c4c int32_t var_38 = 0; +00512c54 int32_t var_34 = 0; +00512c5c float var_c = 0f; +00512c64 float var_8 = 0f; +00512c6c float var_4 = 0f; +00512c74 Frame::cache(&var_40); +00512c74 +00512c86 if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x40) == 0) +00512c86 { +00512c88 class CPartArray* part_array = this->part_array; +00512c88 +00512c8d if (part_array != 0) +00512c95 CPartArray::Update(part_array, arg2, &var_40); +00512c95 +00512ca1 if ((this->transient_state & 2) == 0) +00512ca1 { +00512cd5 float var_c_2 = ((float)(((long double)var_c) * ((long double)0f))); +00512ce3 float var_8_2 = ((float)(((long double)var_8) * ((long double)0f))); +00512cf1 float var_4_2 = ((float)(((long double)var_4) * ((long double)0f))); +00512ca1 } +00512ca1 else +00512ca1 { +00512ca3 long double x87_r7_1 = ((long double)this->m_scale); +00512caf float var_c_1 = ((float)(((long double)var_c) * x87_r7_1)); +00512cb9 float var_8_1 = ((float)(((long double)var_8) * x87_r7_1)); +00512cc3 float var_4_1 = ((float)(((long double)var_4) * x87_r7_1)); +00512ca1 } +00512c86 } +00512c86 +00512cf5 class PositionManager* position_manager = this->position_manager; +00512cf5 +00512cfd if (position_manager != 0) +00512cfd { +00512d0a float var_54; +00512d0a var_54 = ((double)((long double)arg2)); +00512d0e PositionManager::adjust_offset(position_manager, &var_40, var_54); +00512cfd } +00512cfd +00512d22 Frame::combine(arg3, &this->m_position.frame, &var_40); +00512d22 +00512d30 if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x40) == 0) +00512d36 CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3); +00512d36 +00512d3d CPhysicsObj::process_hooks(this); +00512c30 } +``` + +**This is the per-tick chokepoint.** `arg2` is the frame's elapsed-time quantum. A local +identity `Frame` (`var_40`, cached via `Frame::cache`) is built up: first +`CPartArray::Update(part_array, quantum, &var_40)` (animation-driven part-array motion +contributes to the frame delta), then — if a `position_manager` exists — +`PositionManager::adjust_offset(position_manager, &var_40, quantum)` ADDS the +sticky/interpolation/constraint contributions into the SAME `var_40` accumulator +(this is the call that fans out to `StickyManager::adjust_offset`, +`InterpolationManager::adjust_offset`, `ConstraintManager::adjust_offset` in sequence). +Finally `Frame::combine(arg3, &this->m_position.frame, &var_40)` composes the +accumulated delta frame onto the object's actual position, producing the output frame +`arg3` (this is the frame that gets fed into `UpdatePhysicsInternal` next). So sticky +steering literally competes/composes with animation-driven part-array movement in the +SAME per-tick delta-frame before physics/collision resolves it. + +### `CPhysicsObj::UpdateObjectInternal` (0x005156b0) — calls `PositionManager::UseTime`, and transitively `UpdatePositionInternal` + +Full function extracted (contains the `UpdatePositionInternal` call + the `UseTime` call +later in the same tick): + +```c +005156b0 void __thiscall CPhysicsObj::UpdateObjectInternal(class CPhysicsObj* this, float arg2) + +005156b0 { +005156b6 uint16_t transient_state = ((int16_t)this->transient_state); +005156b6 +005156bf if (transient_state >= 0) +005156bf { +005159b8 label_5159b8: +005159b8 class ParticleManager* particle_manager = this->particle_manager; +005159b8 +005159c0 if (particle_manager != 0) +005159c2 ParticleManager::UpdateParticles(particle_manager); +005159c2 +005159c7 class ScriptManager* script_manager = this->script_manager; +005159c7 +005159cc if (script_manager != 0) +005159d0 ScriptManager::UpdateScripts(script_manager); +005156bf } +005156bf else if (this->cell != 0) +005156cf { +005156d8 if ((*(uint8_t*)((char*)transient_state)[1] & 1) != 0) +005156de CPhysicsObj::set_ethereal(this, 0, 0); +005156de +005156e7 this->jumped_this_frame = 0; +005156ed int32_t var_48 = 0x796910; +005156f5 int32_t var_44_1 = 0; +005156f9 int32_t var_40 = 0x3f800000; +00515701 int32_t var_3c_1 = 0; +00515709 int32_t var_38_1 = 0; +00515711 int32_t var_34_1 = 0; +00515719 float x = 0f; +00515721 int32_t var_8_1 = 0; +00515729 int32_t var_4_1 = 0; +00515731 Frame::cache(&var_40); +00515745 uint32_t objcell_id = this->m_position.objcell_id; +00515749 long double st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &var_40); +0051574e class CPartArray* part_array = this->part_array; +00515753 uint32_t eax_1; +00515753 +00515753 if (part_array != 0) +00515755 eax_1 = CPartArray::GetNumSphere(part_array); +00515755 +0051575c int32_t __return; +0051575c +0051575c if ((part_array != 0 && eax_1 != 0)) +0051575c { +005157ec if (AC1Legacy::Vector3::operator==(&x, &this->m_position.frame.m_fOrigin) == 0) +005157ec { +00515846 uint32_t state = this->state; +00515846 +0051584f if ((*(uint8_t*)((char*)state)[1] & 1) != 0) +0051584f { +0051585b AC1Legacy::Vector3::operator-(&x, &__return, &this->m_position.frame.m_fOrigin); +00515864 Vector3::Normalize(&__return); +00515872 Frame::set_vector_heading(&var_40, &__return); +0051584f } +0051584f else if (((state & "activation type (%s) with '%s' b…") != 0 && AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0)) +0051587e { +00515898 int32_t var_74_5 = AC1Legacy::Vector3::get_heading(&this->m_velocityVector); +005158a0 Frame::set_heading(&var_40, ((float)st0_1)); +0051587e } +0051587e +005158b2 class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); +005158b2 +005158bb if (eax_10 == 0) +005158bb { +00515924 x = this->m_position.frame.m_fOrigin.x; +0051592c float z_2 = this->m_position.frame.m_fOrigin.z; +00515933 float y_2 = this->m_position.frame.m_fOrigin.y; +00515937 CPhysicsObj::set_frame(this, &var_40); +0051593c __return = 0; +00515948 this->cached_velocity.x = __return; +00515948 this->cached_velocity.y = 0f; +00515948 this->cached_velocity.z = 0f; +005158bb } +005158bb else +005158bb { +005158cb Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); +005158de void __return_1; +005158de int32_t* eax_12 = Vector3::operator/(&__return, &__return_1, arg2); +005158e5 float ecx_20 = eax_12[1]; +005158e8 __return = *(uint32_t*)eax_12; +005158ff this->cached_velocity.x = __return; +005158ff this->cached_velocity.y = ecx_20; +005158ff this->cached_velocity.z = eax_12[2]; +00515914 CPhysicsObj::SetPositionInternal(this, eax_10); +005158bb } +005157ec } +005157ec else +005157ec { +005157f7 x = this->m_position.frame.m_fOrigin.x; +00515802 float y_1 = this->m_position.frame.m_fOrigin.y; +00515806 float z_1 = this->m_position.frame.m_fOrigin.z; +0051580a CPhysicsObj::set_frame(this, &var_40); +0051580f __return = 0; +0051581b this->cached_velocity.x = __return; +0051581b this->cached_velocity.y = 0f; +0051581b this->cached_velocity.z = 0f; +005157ec } +0051575c } +0051575c else +0051575c { +00515764 if (this->movement_manager == 0) +00515764 { +00515766 uint32_t transient_state_1 = this->transient_state; +00515766 +0051576e if ((transient_state_1 & 2) != 0) +00515775 this->transient_state = (transient_state_1 & 0xffffff7f); +00515764 } +00515764 +00515789 x = this->m_position.frame.m_fOrigin.x; +00515794 float y = this->m_position.frame.m_fOrigin.y; +00515798 float z = this->m_position.frame.m_fOrigin.z; +0051579c CPhysicsObj::set_frame(this, &var_40); +005157a1 __return = 0; +005157ad this->cached_velocity.x = __return; +005157ad this->cached_velocity.y = 0f; +005157ad this->cached_velocity.z = 0f; +005157ec } +00515970 class DetectionManager* detection_manager = this->detection_manager; +00515970 +00515978 if (detection_manager != 0) +0051597a DetectionManager::CheckDetection(detection_manager); +0051597a +0051597f class TargetManager* target_manager = this->target_manager; +0051597f +00515987 if (target_manager != 0) +00515989 TargetManager::HandleTargetting(target_manager); +00515989 +0051598e class MovementManager* movement_manager = this->movement_manager; +0051598e +00515996 if (movement_manager != 0) +00515998 MovementManager::UseTime(movement_manager); +00515998 +0051599d class CPartArray* part_array_1 = this->part_array; +0051599d +005159a2 if (part_array_1 != 0) +005159a4 CPartArray::HandleMovement(part_array_1); +005159a4 +005159a9 class PositionManager* position_manager = this->position_manager; +005159a9 +005159b1 if (position_manager != 0) +005159b3 PositionManager::UseTime(position_manager); +005159b3 +00515753 goto label_5159b8; +005156cf } +005156b0 } +``` + +**Ordering within one tick (the `else if (this->cell != 0)` branch — the "object is in +the world" path):** +1. `CPhysicsObj::UpdatePositionInternal(this, arg2, &var_40)` — builds the delta frame + (part-array animation + `PositionManager::adjust_offset` sticky/interp/constraint + contributions), returns a heading-ish scalar in `st0_1`. +2. If the object has sphere-collision parts (`part_array->GetNumSphere() != 0`): compute + a facing/heading update, run `CPhysicsObj::transition(...)` (collision/movement + resolution against the delta frame), then either `set_frame` directly (transition + failed/no-op) or `SetPositionInternal` with the transition's resolved sphere-path + position (transition succeeded) — this is where the sticky-computed delta actually + gets validated against collision before being committed. +3. Else (no collision parts): just `set_frame` directly with the delta frame — no + collision check. +4. THEN: `DetectionManager::CheckDetection`, `TargetManager::HandleTargetting`, + `MovementManager::UseTime`, `CPartArray::HandleMovement`, and finally + **`PositionManager::UseTime(position_manager)`** — the sticky-timeout check runs + LAST in the tick, AFTER the position has already been moved/collision-resolved for + this frame. So a stick that times out this tick still got one more frame of + steered movement + collision resolution before being cleared. + +### `CPhysicsObj::HandleUpdateTarget` (0x00512bc0) — calls `PositionManager::HandleUpdateTarget` + +```c +00512bc0 void __thiscall CPhysicsObj::HandleUpdateTarget(class CPhysicsObj* this, class TargetInfo arg2) + +00512bc0 { +00512bc9 if (arg2.context_id == 0) +00512bc9 { +00512bd3 void var_d4; +00512bd3 +00512bd3 if (this->movement_manager != 0) +00512bd3 { +00512be5 TargetInfo::TargetInfo(&var_d4, &arg2); +00512bf0 MovementManager::HandleUpdateTarget(this->movement_manager, var_d4); +00512bd3 } +00512bd3 +00512bfd if (this->position_manager != 0) +00512bfd { +00512c0f TargetInfo::TargetInfo(&var_d4, &arg2); +00512c1a PositionManager::HandleUpdateTarget(this->position_manager, var_d4); +00512bfd } +00512bc9 } +00512bc0 } +``` + +Top-level entry point for target-position updates arriving from elsewhere (server +messages / target-tracking system), gated on `context_id == 0` (context 0 presumably +means "default/self" target tracking vs. some other numbered context). Fans the SAME +`TargetInfo` out to BOTH `MovementManager::HandleUpdateTarget` (move-to logic) AND +`PositionManager::HandleUpdateTarget` (sticky logic) if each manager exists. This is the +producer for the `StickyManager::HandleUpdateTarget` consumer described above. + +### `CPhysicsObj::exit_world` (0x00514e60) — calls `PositionManager::UnStick` + +```c +00514e60 void __fastcall CPhysicsObj::exit_world(class CPhysicsObj* this) + +00514e60 { +00514e63 class CPartArray* part_array = this->part_array; +00514e63 +00514e68 if (part_array != 0) +00514e6a CPartArray::HandleExitWorld(part_array); +00514e6a +00514e6f class MovementManager* movement_manager = this->movement_manager; +00514e6f +00514e77 if (movement_manager != 0) +00514e79 MovementManager::HandleExitWorld(movement_manager); +00514e79 +00514e7e class PositionManager* position_manager = this->position_manager; +00514e7e +00514e86 if (position_manager != 0) +00514e88 PositionManager::UnStick(position_manager); +00514e88 +00514e8d class TargetManager* target_manager = this->target_manager; +00514e8d +00514e95 if (target_manager != 0) +00514e95 { +00514e97 TargetManager::ClearTarget(target_manager); +00514ea4 TargetManager::NotifyVoyeurOfEvent(this->target_manager, ExitWorld_TargetStatus); +00514e95 } +00514e95 +00514ea9 class DetectionManager* detection_manager = this->detection_manager; +00514ea9 +00514eb1 if (detection_manager != 0) +00514eb5 DetectionManager::DestroyDetectionCylsphere(detection_manager, 0); +00514eb5 +00514ebe CPhysicsObj::report_collision_end(this, 1); +00514e60 } +``` + +Object leaving the world (despawn/logout): unstick unconditionally (only `UnStick`, not +full teardown — `position_manager` itself is kept alive). + +### `CPhysicsObj::teleport_hook` (0x00514ed0) — calls `PositionManager::UnStick` + `StopInterpolating` + `UnConstrain` + +```c +00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2) + +00514ed0 { +00514ed3 class MovementManager* movement_manager = this->movement_manager; +00514ed3 +00514edb if (movement_manager != 0) +00514edb { +00514edd int32_t var_8_1 = 0x3c; +00514edf uint32_t edx; +00514edf MovementManager::CancelMoveTo(movement_manager, edx); +00514edb } +00514edb +00514ee4 class PositionManager* position_manager = this->position_manager; +00514ee4 +00514eec if (position_manager != 0) +00514eee PositionManager::UnStick(position_manager); +00514eee +00514ef3 class PositionManager* position_manager_1 = this->position_manager; +00514ef3 +00514efb if (position_manager_1 != 0) +00514efd PositionManager::StopInterpolating(position_manager_1); +00514efd +00514f02 class PositionManager* position_manager_2 = this->position_manager; +00514f02 +00514f0a if (position_manager_2 != 0) +00514f0c PositionManager::UnConstrain(position_manager_2); +00514f0c +00514f11 class TargetManager* target_manager = this->target_manager; +00514f11 +00514f19 if (target_manager != 0) +00514f19 { +00514f1b TargetManager::ClearTarget(target_manager); +00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus); +00514f19 } +00514f19 +00514f31 CPhysicsObj::report_collision_end(this, 1); +00514ed0 } +``` + +Teleport is the ONE place all three PositionManager sub-behaviors get explicitly torn +down together (cancel any move-to, unstick, stop interpolating, unconstrain) — makes +sense: after a teleport, none of the three "gradually approach some reference" behaviors +should still be steering toward a pre-teleport reference frame. + +### `MovementManager::unpack_movement` (0x00524440) — calls `CPhysicsObj::unstick_from_object` + +Context excerpt (full function is long; showing the relevant unstick call site inside the +inbound-motion-unpacking case-0 branch): + +```c +00524440 int32_t __thiscall MovementManager::unpack_movement(class MovementManager* this, void** arg2, uint32_t arg3) + +00524440 { +0052444f if (this->motion_interpreter != 0) +0052444f { +00524455 class CPhysicsObj* physics_obj = this->physics_obj; +00524455 +0052445a if (physics_obj != 0) +0052445a { +00524460 CPhysicsObj::interrupt_current_movement(physics_obj); +00524468 CPhysicsObj::unstick_from_object(this->physics_obj); +... +00524551 case 0: +00524551 { +00524551 InterpretedMotionState::UnPack(&var_28, arg2, arg3); +... +0052457c MovementManager::move_to_interpreted_state(this, &var_28); +0052457c +00524583 if (ebx_3 != 0) +00524589 CPhysicsObj::stick_to_object(this->physics_obj, ebx_3); +00524589 +0052458e this->motion_interpreter->standing_longjump = (ebp_1 & 0x200); +... +``` + +Two things happen in `unpack_movement`: (1) unconditionally at the top of the whole +function, EVERY inbound motion packet interrupts current movement AND unsticks +(`unstick_from_object`) before any of the packet's specific motion state is applied — +i.e. any new motion command from the network clears a prior stick; (2) later, inside +`case 0` (one specific unpacked-motion sub-case), if the unpacked state included a +sticky-target object id (`ebx_3`, read conditionally from the packed stream when a state +flag bit is set), `stick_to_object` is called to establish a NEW stick to that id. This +is the network-driven "server told the client to stick this object to another object" +path (e.g. mounting, carrying, or similar attach behaviors). + +### `CMotionInterp::MotionDone` (0x00527ec0) — calls `CPhysicsObj::unstick_from_object` + +```c +00527ec0 void __fastcall CMotionInterp::MotionDone(class CMotionInterp* this, int32_t arg2) + +00527ec0 { +00527ec3 class CPhysicsObj* physics_obj = this->physics_obj; +00527ec3 +00527ec8 if (physics_obj != 0) +00527ec8 { +00527eca class LListData* head_ = this->pending_motions.head_; +00527eca +00527ed2 if (head_ != 0) +00527ed2 { +00527edb if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0) +00527edb { +00527edd CPhysicsObj::unstick_from_object(physics_obj); +00527ee5 InterpretedMotionState::RemoveAction(&this->interpreted_state); +00527eed RawMotionState::RemoveAction(&this->raw_state); +00527edb } +00527edb +00527ef2 class LListData* head__1 = this->pending_motions.head_; +00527ef2 +00527efa if (head__1 != 0) +00527efa { +00527efc class LListData* llist_next = head__1->llist_next; +00527f00 this->pending_motions.head_ = llist_next; +00527f00 +00527f06 if (llist_next == 0) +00527f08 this->pending_motions.tail_ = llist_next; +00527f08 +00527f0f head__1->llist_next = 0; +00527f15 operator delete(head__1); +00527efa } +00527ed2 } +00527ec8 } +00527ec0 } +``` + +And its sibling `CMotionInterp::HandleExitWorld` (0x00527f30) has the identical +unstick-on-flag-bit pattern (queue-head motion's flag `0x10000000` set → unstick) when +draining `pending_motions` on exit-world. The `0x10000000` bit on a pending motion's +flags field marks "this motion action implies/requires a stick, so completing or +force-flushing it must unstick." Consistent with `unpack_movement`'s pattern: sticks are +tied to the lifecycle of a specific motion action, not held independently. + +### `MoveToManager::PerformMovement` (0x0052a900) — calls `CPhysicsObj::unstick_from_object` + +```c +0052a900 uint32_t __thiscall MoveToManager::PerformMovement(class MoveToManager* this, class MovementStruct const* arg2) + +0052a900 { +0052a901 int32_t var_8 = 0x36; +0052a905 uint32_t edx; +0052a905 MoveToManager::CancelMoveTo(this, edx); +0052a910 CPhysicsObj::unstick_from_object(this->physics_obj); +0052a910 +0052a923 switch ((arg2->type - 6)) +0052a923 { +0052a940 case 0: +0052a940 { +0052a940 MoveToManager::MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params); +0052a940 break; +0052a955 case 1: +0052a955 { +0052a955 MoveToManager::MoveToPosition(this, &arg2->pos, arg2->params); +0052a955 break; +... +``` + +Every new `MoveToManager` movement command starts by cancelling any in-flight move-to AND +unsticking — same "new movement intent clears prior stick" pattern as +`unpack_movement`. + +### `MoveToManager::BeginNextNode` (0x00529cb0) — calls `PositionManager::StickTo` via `get_position_manager` + +```c +00529ccb return; +00529cbe } +00529cbe +00529ce5 head_ = *(uint8_t*)((char*)this->movement_params.__inner0 + 0); +00529ce5 +00529ced if (head_ < 0) +00529ced { +00529cef float sought_object_height = this->sought_object_height; +00529cf5 float sought_object_radius = this->sought_object_radius; +00529d00 uint32_t top_level_object_id = this->top_level_object_id; +00529d0c int32_t edx_3 = MoveToManager::CleanUp(this); +00529d11 class CPhysicsObj* physics_obj = this->physics_obj; +00529d11 +00529d19 if (physics_obj != 0) +00529d19 { +00529d1b int32_t var_14_1 = 0; +00529d1d CPhysicsObj::StopCompletely(physics_obj, edx_3); +00529d19 } +00529d19 +00529d3a PositionManager::StickTo(CPhysicsObj::get_position_manager(this->physics_obj), top_level_object_id, sought_object_radius, sought_object_height); +00529d44 return; +00529ced } +``` + +This is the OTHER stick-establishment path, distinct from `stick_to_object`: when a +`MoveToManager` move-to-object node completes and a "sticky" flag is set in +`movement_params` (the `head_ < 0` branch — a sign-bit check on a packed byte field, +likely a `CanStick`/`Sticky` movement-parameter bit), the mover: cleans up the move-to +state, stops movement completely (`CPhysicsObj::StopCompletely`), then sticks to +`top_level_object_id` (the object it was moving toward) using the CACHED +`sought_object_radius`/`sought_object_height` (computed earlier when the move-to node was +set up, not re-derived from `CPartArray` like `stick_to_object` does). This is the +"arrived at destination object, now hold position relative to it" transition — +e.g. finishing a move-to-object command that has a stick-on-arrival semantic. + +--- + +## Files referenced + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 352066–352644 + (PositionManager + StickyManager method bodies, contiguous block) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 276403–276426, 278204–278222, + 278344–278364, 280236–280241, 280559–280595, 280794–280866, 283079–283151, 283611–283757 + (CPhysicsObj seams + UpdatePositionInternal/UpdateObjectInternal per-tick chain) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 300563–300621 (MovementManager::unpack_movement) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 305238–305271 (CMotionInterp::MotionDone) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 307123–307161 (MoveToManager::BeginNextNode) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 307871–307878 (MoveToManager::PerformMovement) +- `docs/research/named-retail/acclient.h` lines 30952–30958 (struct PositionManager) +- `docs/research/named-retail/acclient.h` lines 31518–31526 (struct StickyManager) +- `docs/research/named-retail/acclient.h` lines 31529–31537 (struct ConstraintManager, context) diff --git a/docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md b/docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md new file mode 100644 index 00000000..4c461cd0 --- /dev/null +++ b/docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md @@ -0,0 +1,1256 @@ +# TargetManager (voyeur-subscription system) — retail decomp extract + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named). +Structs: `docs/research/named-retail/acclient.h`. + +## Struct definitions (acclient.h) + +### `enum TargetStatus` (acclient.h:7264) + +```c +enum TargetStatus +{ + Undef_TargetStatus = 0x0, + Ok_TargetStatus = 0x1, + ExitWorld_TargetStatus = 0x2, + Teleported_TargetStatus = 0x3, + Contained_TargetStatus = 0x4, + Parented_TargetStatus = 0x5, + TimedOut_TargetStatus = 0x6, + FORCE_TargetStatus_32_BIT = 0x7FFFFFFF, +}; +``` + +### `struct TargetManager` (acclient.h:31024, id 3484) + +```c +struct __cppobj TargetManager +{ + CPhysicsObj *physobj; + TargetInfo *target_info; + LongNIHash *voyeur_table; + long double last_update_time; +}; +``` + +### `struct TargetInfo` (acclient.h:31591) + +```c +struct __cppobj TargetInfo +{ + unsigned int context_id; + unsigned int object_id; + float radius; + long double quantum; + Position target_position; + Position interpolated_position; + AC1Legacy::Vector3 interpolated_heading; + AC1Legacy::Vector3 velocity; + TargetStatus status; + long double last_update_time; +}; +``` + +### `struct TargettedVoyeurInfo` (acclient.h:52807, id 5801, 8-byte aligned) + +```c +struct __cppobj __declspec(align(8)) TargettedVoyeurInfo +{ + unsigned int object_id; + long double quantum; + float radius; + Position last_sent_position; +}; +``` + +### `struct LongNIHash` (acclient.h:31606, id 3483) + +```c +struct __cppobj LongNIHash +{ + LongNIHashData **buckets; + int table_size; +}; +``` + +### `struct LongNIHashIter` (acclient.h:57648) + +```c +struct __cppobj LongNIHashIter +{ + LongNIHash *hash; + /* + bucketNo, curDat, fDone per the operator++ body below */ +}; +``` + +NOTE: field layout for `LongNIHashIter` beyond `hash` isn't in the struct +dump captured, but `operator++`'s pseudo-C shows `bucketNo`, `curDat`, +`fDone` members (see below). + +--- + +## Functions + +### `TargetManager::TargetManager` (ctor) — 0051a370 + +```c +0051a370 void __thiscall TargetManager::TargetManager(class TargetManager* this, class CPhysicsObj* arg2) + +0051a370 { +0051a376 this->physobj = arg2; +0051a37a this->target_info = nullptr; +0051a37d this->voyeur_table = nullptr; +0051a380 this->last_update_time = 0f; +0051a383 *(uint32_t*)((char*)this->last_update_time)[4] = 0; +0051a370 } +``` + +NOTE: `*(uint32_t*)((char*)this->last_update_time)[4] = 0` is BN's mangled +way of zeroing the high dword of the 80-bit `long double last_update_time` +field (two-dword store split across a `long double`). Same pattern recurs +throughout this file for every `long double` field/parameter assignment — +not a bug, just how BN represents x87 80-bit extended stores split into a +32-bit low/high pair. Flagging once here; not re-flagging every occurrence +below. + +### `TargetInfo::TargetInfo` (ctor, bonus — used pervasively by callers) — 0051a420 + +```c +0051a420 void __fastcall TargetInfo::TargetInfo(class TargetInfo* this) + +0051a420 { +0051a429 this->context_id = 0; +0051a42b this->object_id = 0; +0051a42e this->radius = 0f; +0051a431 this->quantum = 0f; +0051a434 *(uint32_t*)((char*)this->quantum)[4] = 0; +0051a437 this->target_position.vtable = 0x796910; +0051a43e this->target_position.objcell_id = 0; +0051a423 this->target_position.frame.qw = 0x3f800000; +0051a423 this->target_position.frame.qx = 0f; +0051a423 this->target_position.frame.qy = 0f; +0051a423 this->target_position.frame.qz = 0f; +0051a423 this->target_position.frame.m_fOrigin.x = 0; +0051a423 this->target_position.frame.m_fOrigin.y = 0f; +0051a423 this->target_position.frame.m_fOrigin.z = 0f; +0051a459 Frame::cache(&this->target_position.frame); +0051a461 this->interpolated_position.vtable = 0x796910; +0051a468 this->interpolated_position.objcell_id = 0; +0051a45e this->interpolated_position.frame.qw = 0x3f800000; +0051a45e this->interpolated_position.frame.qx = 0f; +0051a45e this->interpolated_position.frame.qy = 0f; +0051a45e this->interpolated_position.frame.qz = 0f; +0051a45e this->interpolated_position.frame.m_fOrigin.x = 0; +0051a45e this->interpolated_position.frame.m_fOrigin.y = 0f; +0051a45e this->interpolated_position.frame.m_fOrigin.z = 0f; +0051a483 Frame::cache(&this->interpolated_position.frame); +0051a488 this->status = Undef_TargetStatus; +0051a48e this->last_update_time = 0f; +0051a494 *(uint32_t*)((char*)this->last_update_time)[4] = 0; +0051a420 } +``` + +Default-constructs both `Position` members to identity quaternion at +origin, `status = Undef_TargetStatus`, everything else zeroed. +`velocity`/`interpolated_heading` are NOT explicitly zeroed here (they're +POD `AC1Legacy::Vector3` — likely left uninitialized by this particular +ctor overload, or zeroed by a base/member ctor not captured in this +pseudo-C region). + +### `TargetManager::SetTargetQuantum` — 0051a4a0 + +```c +0051a4a0 void __thiscall TargetManager::SetTargetQuantum(class TargetManager* this, double arg2) + +0051a4a0 { +0051a4a3 class TargetInfo* target_info = this->target_info; +0051a4a3 +0051a4a8 if (target_info != 0) +0051a4a8 { +0051a4b2 target_info->quantum = arg2; +0051a4b5 *(uint32_t*)((char*)target_info->quantum)[4] = *(uint32_t*)((char*)arg2)[4]; +0051a4bf class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(this->target_info->object_id); +0051a4bf +0051a4c9 if (eax_1 != 0) +0051a4c9 { +0051a4cb class TargetInfo* target_info_1 = this->target_info; +0051a4e6 CPhysicsObj::add_voyeur(eax_1, this->physobj->id, ((float)((long double)target_info_1->radius)), ((float)((long double)target_info_1->quantum))); +0051a4c9 } +0051a4a8 } +0051a4a0 } +``` + +Only acts if we currently HAVE a target (`target_info != 0`). Updates our +own `quantum` (the resend interval), then re-registers ourselves as a +voyeur of the target object with the new quantum — i.e. quantum changes +propagate by re-adding the voyeur subscription (not a separate "update +quantum" RPC on the remote side). + +### `TargetManager::SendVoyeurUpdate` — 0051a4f0 + +```c +0051a4f0 void __thiscall TargetManager::SendVoyeurUpdate(class TargetManager* this, class TargettedVoyeurInfo* arg2, class Position const* arg3, enum TargetStatus arg4) + +0051a4f0 { +0051a503 int32_t* esi = arg2; +0051a514 esi[6] = arg3->objcell_id; +0051a517 Frame::operator=(&esi[7], &arg3->frame); +0051a520 int32_t var_d0; +0051a520 TargetInfo::TargetInfo(&var_d0); +0051a525 class CPhysicsObj* physobj = this->physobj; +0051a527 int32_t eax_1 = esi[2]; +0051a52a int32_t edx = esi[4]; +0051a52d var_d0 = 0; +0051a538 uint32_t id = physobj->id; +0051a53f int32_t var_c0 = eax_1; +0051a543 int32_t var_bc = esi[3]; +0051a547 int32_t var_c8 = edx; +0051a556 uint32_t objcell_id = physobj->m_position.objcell_id; +0051a55a void var_b0; +0051a55a Frame::operator=(&var_b0, &physobj->m_position.frame); +0051a562 uint32_t objcell_id_1 = arg3->objcell_id; +0051a571 void var_68; +0051a571 Frame::operator=(&var_68, &arg3->frame); +0051a57d void __return; +0051a57d class AC1Legacy::Vector3* eax_3 = CPhysicsObj::get_velocity(physobj, &__return); +0051a586 float x = eax_3->x; +0051a597 float y = eax_3->y; +0051a5a2 float z = eax_3->z; +0051a5a9 enum TargetStatus var_10 = arg4; +0051a5b0 class CPhysicsObj* eax_5 = CPhysicsObj::GetObjectA(*(uint32_t*)esi); +0051a5b0 +0051a5be if (eax_5 != 0) +0051a5c7 CPhysicsObj::receive_target_update(eax_5, &var_d0); +0051a4f0 } +``` + +Mechanically: (1) stashes `arg3` (the caller-computed interpolated +position) into the voyeur record's `last_sent_position` field (`esi[6]` += objcell_id, `esi[7..]` = frame — matches `TargettedVoyeurInfo::last_sent_position` +being the 3rd/4th field after `object_id`/`quantum`/`radius`); (2) builds +a fresh `TargetInfo` on the stack (`var_d0`) populated with: our own +`context_id`? (actually `esi[2]`/`esi[3]`/`esi[4]` = voyeur's own +`quantum`/`radius` fields per struct layout — field-name binding here is +approximate since BN shows raw offsets, not member names), our physobj's +`id` as `object_id`, our physobj's CURRENT position (not the interpolated +`arg3`!) as `target_position`, `arg3`'s position as `interpolated_position`, +our CURRENT velocity, and `arg4` as `status`; (3) looks up the voyeur's +`object_id` (`*(uint32_t*)esi`, i.e. `esi[0]`) via `CPhysicsObj::GetObjectA` +and if resolved, tail-calls `CPhysicsObj::receive_target_update` on THAT +object — delivering the update directly into the voyeur's own +`TargetManager::ReceiveUpdate`. + +NOTE (field-offset ambiguity): the exact mapping of `esi[2]`/`esi[3]`/`esi[4]` +into `var_c0`/`var_bc`/`var_c8` (populating fields of the constructed +`TargetInfo`) is inferred from struct order, not verified field-by-field — +BN shows raw stack offsets (`var_c0`, `var_bc`, `var_c8`) copied from raw +struct offsets (`esi[2]`, `esi[3]`, `esi[4]`) with no field names attached. +Cross-check against `TargettedVoyeurInfo` layout (`object_id`@0, `quantum`@4 +(long double, 12 bytes on this compiler?), `radius`@0x10, `last_sent_position`@0x14) +before porting exact field semantics. + +### `TargetManager::GetInterpolatedPosition` — 0051a5e0 + +```c +0051a5e0 void __thiscall TargetManager::GetInterpolatedPosition(class TargetManager* this, double arg2, class Position* arg3) + +0051a5e0 { +0051a5e4 class Position* esi = arg3; +0051a5eb class CPhysicsObj* physobj = this->physobj; +0051a5f6 esi->objcell_id = physobj->m_position.objcell_id; +0051a5fd Frame::operator=(&esi->frame, &physobj->m_position.frame); +0051a608 arg3 = ((float)((long double)arg2)); +0051a611 void __return; +0051a611 class AC1Legacy::Vector3* eax_2 = CPhysicsObj::get_velocity(this->physobj, &__return); +0051a634 esi->frame.m_fOrigin.x = ((float)((((long double)arg3) * ((long double)eax_2->x)) + ((long double)esi->frame.m_fOrigin.x))); +0051a63a esi->frame.m_fOrigin.y = ((float)((((long double)arg3) * ((long double)eax_2->y)) + ((long double)esi->frame.m_fOrigin.y))); +0051a644 esi->frame.m_fOrigin.z = ((float)(((long double)((float)(((long double)arg3) * ((long double)eax_2->z)))) + ((long double)esi->frame.m_fOrigin.z))); +0051a5e0 } +``` + +Straightforward dead-reckoning extrapolation: `out = physobj.position`, +then `out.origin += arg2 (a time-delta / quantum, as double) * velocity` +per-axis. This is our own physobj's position extrapolated forward by +`arg2` seconds using our own current velocity — used to predict where WE +will be by the time a voyeur update actually lands, so the voyeur update +carries a "where I'll be at quantum-from-now" position rather than +"where I am right now." + +### `TargetManager::CheckAndUpdateVoyeur` — 0051a650 + +```c +0051a650 void __thiscall TargetManager::CheckAndUpdateVoyeur(class TargetManager* this, class TargettedVoyeurInfo* arg2) + +0051a650 { +0051a65b int32_t var_48 = 0x796910; +0051a663 int32_t var_44 = 0; +0051a66b int32_t var_40 = 0x3f800000; +0051a673 int32_t var_3c = 0; +0051a67b int32_t var_38 = 0; +0051a683 int32_t var_34 = 0; +0051a68b int32_t var_c = 0; +0051a693 int32_t var_8 = 0; +0051a69b int32_t var_4 = 0; +0051a6a3 Frame::cache(&var_40); +0051a6b7 int32_t var_58 = *(uint32_t*)((char*)arg2->quantum)[4]; +0051a6bb TargetManager::GetInterpolatedPosition(this, arg2->quantum, &var_48); +0051a6c8 long double st0 = Position::distance(&var_48, &arg2->last_sent_position); +0051a6cd long double temp1 = ((long double)arg2->radius); +0051a6cd (st0 - temp1); +0051a6cd +0051a6d5 if ((*(uint8_t*)((char*)((((st0 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(st0, temp1))) ? 1 : 0) << 0xa) | ((((st0 == temp1) ? 1 : 0) << 0xe) | 0)))))[1] & 0x41) == 0) +0051a6e1 TargetManager::SendVoyeurUpdate(this, arg2, &var_48, Ok_TargetStatus); +0051a650 } +``` + +NOTE (x87 FCMP mush): the trailing `if (...)` block is a garbled x87 +`FCOMI`/`fnstsw`+`sahf`-style comparison of `st0` (the computed distance) +against `temp1` (`arg2->radius`), reconstructed by BN into a synthetic +FLAGS byte then masked with `0x41` (ZF|CF). The condition as literally +written computes `(distance < radius) OR unordered OR (distance == radius)` +into bits 8/0xa/0xe of a byte, then tests bits `0x41` = `0b0100_0001` +(bit0 + bit6) of the HIGH byte of that packed value — which does not +line up cleanly with the bits set (8/0xa/0xe are all ABOVE bit 7, so the +high byte holds bits 8,10,14 shifted down by 8 → i.e. bit0=orig bit8=`st0* voyeur_table = this->voyeur_table; +0051a6f6 +0051a6fb if (voyeur_table != 0) +0051a6fb { +0051a703 int32_t* var_10; +0051a703 LongNIHashIter::LongNIHashIter(&var_10, voyeur_table); +0051a708 int32_t i_1; +0051a708 int32_t i = i_1; +0051a708 +0051a70e if (i == 0) +0051a70e { +0051a711 void** j_1; +0051a711 void** j = j_1; +0051a716 int32_t var_c; +0051a716 int32_t edi_1 = var_c; +0051a716 +0051a762 do +0051a762 { +0051a722 class TargettedVoyeurInfo* eax; +0051a722 +0051a722 if (j == 0) +0051a729 eax = nullptr; +0051a722 else +0051a724 eax = j[1]; +0051a724 +0051a72d if (i == 0) +0051a72d { +0051a733 int32_t* ecx_1; +0051a733 +0051a733 for (j = *(uint32_t*)j; j == 0; j = *(uint32_t*)(*(uint32_t*)ecx_1 + (edi_1 << 2))) +0051a733 { +0051a735 ecx_1 = var_10; +0051a73c edi_1 += 1; +0051a73c +0051a73f if (edi_1 >= ecx_1[1]) +0051a73f { +0051a748 i = 1; +0051a748 break; +0051a73f } +0051a733 } +0051a72d } +0051a72d +0051a75b TargetManager::SendVoyeurUpdate(this, eax, &this->physobj->m_position, arg2); +0051a762 } while (i == 0); +0051a70e } +0051a6fb } +0051a6f0 } +``` + +Iterates every entry in `voyeur_table` (all subscribers watching US) and +calls `SendVoyeurUpdate` for each, using our CURRENT (non-interpolated) +position and the caller-supplied `arg2` status (e.g. `ExitWorld_TargetStatus`, +`Teleported_TargetStatus`). This is the "broadcast a status event to +everyone watching me" path, distinct from the per-tick distance-gated +`CheckAndUpdateVoyeur` path. + +NOTE (BN mislabel): the ctor call `LongNIHashIter::LongNIHashIter(&var_10, voyeur_table)` +is BN reusing the `LongNIHashIter` template +instantiation's mangled name for what is actually iterating +`this->voyeur_table`, a `LongNIHash*`. The real +type is `LongNIHashIter` (confirmed: the struct +exists at acclient.h:57648 and `TargetManager::HandleTargetting` below +uses the identical pattern with the same mislabeled ctor name). Treat +every `LongNIHashIter` symbol in this file's +TargetManager functions as actually `LongNIHashIter`. + +### `TargetManager::ClearTarget` — 0051a7e0 + +```c +0051a7e0 void __fastcall TargetManager::ClearTarget(class TargetManager* this) + +0051a7e0 { +0051a7e3 class TargetInfo* target_info = this->target_info; +0051a7e3 +0051a7e8 if (target_info != 0) +0051a7e8 { +0051a7ee class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(target_info->object_id); +0051a7ee +0051a7f8 if (eax_1 != 0) +0051a802 CPhysicsObj::remove_voyeur(eax_1, this->physobj->id); +0051a802 +0051a807 class TargetInfo* target_info_1 = this->target_info; +0051a807 +0051a80c if (target_info_1 != 0) +0051a80c { +0051a814 target_info_1->interpolated_position.vtable = 0x79285c; +0051a817 target_info_1->target_position.vtable = 0x79285c; +0051a81a operator delete(target_info_1); +0051a80c } +0051a80c +0051a822 this->target_info = nullptr; +0051a7e8 } +0051a7e0 } +``` + +If we currently have a target: resolve the target object, if resolved +tell IT to `remove_voyeur(our_id)` (unsubscribe us from their voyeur +table), then free our local `TargetInfo` and null the pointer. Mirror +image of `SetTarget`'s subscribe path. + +### `TargetManager::AddVoyeur` — 0051a830 + +```c +0051a830 void __thiscall TargetManager::AddVoyeur(class TargetManager* this, uint32_t arg2, float arg3, double arg4) + +0051a830 { +0051a838 class LongNIHash* voyeur_table = this->voyeur_table; +0051a838 +0051a840 if (voyeur_table == 0) +0051a840 { +0051a88f void* eax_5 = operator new(8); +0051a899 class LongNIHash* eax_6; +0051a899 +0051a899 if (eax_5 == 0) +0051a8a6 eax_6 = nullptr; +0051a899 else +0051a89f eax_6 = LongNIHash::LongNIHash(eax_5, 4); +0051a89f +0051a8a8 this->voyeur_table = eax_6; +0051a840 } +0051a840 else +0051a840 { +0051a850 void** edx_3 = voyeur_table->buckets[(COMBINE(0, ((arg2 >> 0x10) ^ arg2)) % voyeur_table->table_size)]; +0051a850 +0051a855 if (edx_3 != 0) +0051a855 { +0051a85a while (edx_3[2] != arg2) +0051a85a { +0051a860 edx_3 = *(uint32_t*)edx_3; +0051a860 +0051a864 if (edx_3 == 0) +0051a864 goto label_51a8b3; +0051a85a } +0051a85a +0051a86b void* edx_4 = edx_3[1]; +0051a86b +0051a870 if (edx_4 != 0) +0051a870 { +0051a87b *(uint32_t*)((char*)edx_4 + 0x10) = arg3; +0051a883 *(uint32_t*)((char*)edx_4 + 8) = arg4; +0051a886 *(uint32_t*)((char*)edx_4 + 0xc) = *(uint32_t*)((char*)arg4)[4]; +0051a88a return; +0051a870 } +0051a855 } +0051a840 } +0051a840 +0051a8b3 label_51a8b3: +0051a8b3 void* esi = operator new(0x60); +0051a8b3 +0051a8ba if (esi == 0) +0051a8f3 esi = nullptr; +0051a8ba else +0051a8ba { +0051a8bc *(uint32_t*)esi = 0; +0051a8be *(uint32_t*)((char*)esi + 8) = 0; +0051a8c1 *(uint32_t*)((char*)esi + 0xc) = 0; +0051a8c4 *(uint32_t*)((char*)esi + 0x10) = 0; +0051a8ca *(uint32_t*)((char*)esi + 0x14) = 0x796910; +0051a8d1 *(uint32_t*)((char*)esi + 0x18) = 0; +0051a8d4 *(uint32_t*)((char*)esi + 0x1c) = 0x3f800000; +0051a8da *(uint32_t*)((char*)esi + 0x20) = 0; +0051a8dd *(uint32_t*)((char*)esi + 0x24) = 0; +0051a8e0 *(uint32_t*)((char*)esi + 0x28) = 0; +0051a8e3 *(uint32_t*)((char*)esi + 0x50) = 0; +0051a8e6 *(uint32_t*)((char*)esi + 0x54) = 0; +0051a8e9 *(uint32_t*)((char*)esi + 0x58) = 0; +0051a8ec Frame::cache(((char*)esi + 0x1c)); +0051a8ba } +0051a8ba +0051a902 *(uint32_t*)esi = arg2; +0051a904 *(uint32_t*)((char*)esi + 0x10) = arg3; +0051a907 *(uint32_t*)((char*)esi + 8) = arg4; +0051a90a *(uint32_t*)((char*)esi + 0xc) = *(uint32_t*)((char*)arg4)[4]; +0051a911 LongNIHash::add(this->voyeur_table, esi, arg2); +0051a922 TargetManager::SendVoyeurUpdate(this, esi, &this->physobj->m_position, Ok_TargetStatus); +0051a830 } +``` + +`arg2` = voyeur's object id, `arg3` = radius (float), `arg4` = quantum +(double). Lazily creates `voyeur_table` (4 buckets) on first use. +Hand-rolled hash-bucket lookup: if an existing `TargettedVoyeurInfo` for +`arg2` is found, just updates its `radius`/`quantum` in place and +returns early (no immediate send). Otherwise allocates a fresh +`TargettedVoyeurInfo` (0x60 bytes), zero-inits it (including +`last_sent_position` via `Frame::cache`), sets `object_id`/`radius`/`quantum`, +inserts into the hash, then immediately calls `SendVoyeurUpdate` with our +CURRENT position and `Ok_TargetStatus` — i.e. brand-new voyeurs get an +immediate snapshot rather than waiting for the next `HandleTargetting` tick. + +NOTE (BN mislabel): `LongNIHash::LongNIHash` +and `LongNIHash::add` are BN reusing the `DetectionInfo` +hash template instantiation's mangled symbol for what is actually +`LongNIHash`'s ctor/add — same class of mislabel as +above (`voyeur_table` is declared `LongNIHash*`). + +### `TargetManager::ReceiveUpdate` — 0051a930 + +```c +0051a930 void __thiscall TargetManager::ReceiveUpdate(class TargetManager* this, class TargetInfo const* arg2) + +0051a930 { +0051a936 class TargetInfo* target_info = this->target_info; +0051a936 +0051a93c if (target_info != 0) +0051a93c { +0051a946 uint32_t object_id = arg2->object_id; +0051a946 +0051a94c if (object_id == target_info->object_id) +0051a94c { +0051a952 target_info->object_id = object_id; +0051a955 this->target_info->radius = arg2->radius; +0051a961 class TargetInfo* target_info_2 = this->target_info; +0051a964 target_info_2->quantum = arg2->quantum; +0051a96a *(uint32_t*)((char*)target_info_2->quantum)[4] = *(uint32_t*)((char*)arg2->quantum)[4]; +0051a973 class Position* eax_3 = &this->target_info->target_position; +0051a97d eax_3->objcell_id = arg2->target_position.objcell_id; +0051a980 Frame::operator=(&eax_3->frame, &arg2->target_position.frame); +0051a98b class Position* eax_5 = &this->target_info->interpolated_position; +0051a995 eax_5->objcell_id = arg2->interpolated_position.objcell_id; +0051a998 Frame::operator=(&eax_5->frame, &arg2->interpolated_position.frame); +0051a9a0 class AC1Legacy::Vector3* eax_7 = &this->target_info->velocity; +0051a9ad eax_7->x = arg2->velocity.x; +0051a9b2 eax_7->y = arg2->velocity.y; +0051a9b8 eax_7->z = arg2->velocity.z; +0051a9bb this->target_info->status = arg2->status; +0051a9cf class TargetInfo* target_info_3 = this->target_info; +0051a9d8 target_info_3->last_update_time = (*(uint32_t*)Timer::cur_time); +0051a9de *(uint32_t*)((char*)target_info_3->last_update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); +0051a9f5 int32_t __return; +0051a9f5 class AC1Legacy::Vector3* eax_10 = Position::get_offset(&this->physobj->m_position, &__return, &this->target_info->interpolated_position); +0051a9ff class AC1Legacy::Vector3* ecx_13 = &this->target_info->interpolated_heading; +0051aa05 ecx_13->x = eax_10->x; +0051aa0a ecx_13->y = eax_10->y; +0051aa10 ecx_13->z = eax_10->z; +0051aa10 +0051aa23 if (AC1Legacy::Vector3::normalize_check_small(&this->target_info->interpolated_heading) != 0) +0051aa23 { +0051aa25 class TargetInfo* target_info_1 = this->target_info; +0051aa28 __return = 0; +0051aa34 target_info_1->interpolated_heading.x = __return; +0051aa34 target_info_1->interpolated_heading.y = 0f; +0051aa34 target_info_1->interpolated_heading.z = 1f; +0051aa23 } +0051aa23 +0051aa66 void var_e4; +0051aa66 TargetInfo::TargetInfo(&var_e4, this->target_info); +0051aa6d CPhysicsObj::HandleUpdateTarget(this->physobj, var_e4); +0051aa6d +0051aa79 if (arg2->status == ExitWorld_TargetStatus) +0051aa7d TargetManager::ClearTarget(this); +0051a94c } +0051a93c } +0051a930 } +``` + +This is the "I am a voyeur and just got an update FROM the thing I'm +watching" handler — called via `CPhysicsObj::receive_target_update` from +`SendVoyeurUpdate`'s tail-call on the SENDER side. Only processes if we +still have an active `target_info` AND the incoming `object_id` matches +what we're tracking (stale/mismatched updates dropped silently). Copies +`radius`/`quantum`/`target_position`/`interpolated_position`/`velocity`/`status` +wholesale from the wire payload, stamps `last_update_time = Timer::cur_time`, +then recomputes `interpolated_heading` as the normalized offset from OUR +position to the target's `interpolated_position` (falls back to +`(0,0,1)` — i.e. forward/+Z — if the offset vector is too small to +normalize, via `normalize_check_small`). Finally fans the whole +`TargetInfo` snapshot out to `CPhysicsObj::HandleUpdateTarget` (which +forwards to `MovementManager::HandleUpdateTarget` + +`PositionManager::HandleUpdateTarget` — see below), and if the target's +status says `ExitWorld_TargetStatus`, immediately clears our own +subscription (target left the world → give up watching it). + +### `TargetManager::HandleTargetting` — 0051aa90 + +```c +0051aa90 void __fastcall TargetManager::HandleTargetting(class TargetManager* this) + +0051aa90 { +0051aa9d long double x87_r7 = (((long double)PhysicsTimer::curr_time) - ((long double)this->last_update_time)); +0051aaa0 long double temp0 = ((long double)0.5); +0051aaa0 (x87_r7 - temp0); +0051aaa6 int32_t eax; +0051aaa6 eax = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0)))); +0051aaa8 bool p = /* bool p = unimplemented {test ah, 0x5} */; +0051aaa8 +0051aaab if (p) +0051aaab { +0051aab1 class TargetInfo* target_info = this->target_info; +0051aab1 +0051aac0 if ((target_info != 0 && target_info->status == Undef_TargetStatus)) +0051aac0 { +0051aac8 long double x87_r7_2 = (((long double)10.0) + ((long double)target_info->last_update_time)); +0051aace long double temp1_1 = ((long double)Timer::cur_time); +0051aace (x87_r7_2 - temp1_1); +0051aad4 enum TargetStatus eax_1; +0051aad4 eax_1 = ((((x87_r7_2 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp1_1) ? 1 : 0) << 0xe) | 0)))); +0051aad6 bool p_1 = /* bool p_1 = unimplemented {test ah, 0x5} */; +0051aad6 +0051aad9 if (!(p_1)) +0051aad9 { +0051aadb target_info->status = TimedOut_TargetStatus; +0051aaf1 void var_e8; +0051aaf1 TargetInfo::TargetInfo(&var_e8, this->target_info); +0051aaf8 CPhysicsObj::HandleUpdateTarget(this->physobj, var_e8); +0051aad9 } +0051aac0 } +0051aac0 +0051aafd class LongNIHash* voyeur_table = this->voyeur_table; +0051aafd +0051ab02 if (voyeur_table != 0) +0051ab02 { +0051ab09 void var_10; +0051ab09 int32_t edx_1 = LongNIHashIter::LongNIHashIter(&var_10, voyeur_table); +0051ab14 int32_t i; +0051ab14 +0051ab14 while (i == 0) +0051ab14 { +0051ab1c void* var_8; +0051ab1c class TargettedVoyeurInfo* esi_1; +0051ab1c +0051ab1c if (var_8 == 0) +0051ab23 esi_1 = nullptr; +0051ab1c else +0051ab1e esi_1 = *(uint32_t*)((char*)var_8 + 4); +0051ab1e +0051ab25 int32_t var_1c_2 = 0; +0051ab2b LongNIHashIter::operator++(&var_10, edx_1); +0051ab33 edx_1 = TargetManager::CheckAndUpdateVoyeur(this, esi_1); +0051ab14 } +0051ab02 } +0051ab02 +0051ab4c this->last_update_time = (*(uint32_t*)PhysicsTimer::curr_time); +0051ab4f *(uint32_t*)((char*)this->last_update_time)[4] = *(int32_t*)((char*)PhysicsTimer::curr_time + 4); +0051aaab } +0051aa90 } +``` + +**This is the per-tick driver entry point** — called unconditionally +once per physics tick from `CPhysicsObj::UpdateObjectInternal` (see +Callers below) whenever `this->target_manager != 0`. There is **no +`TargetManager::UseTime` function** — `HandleTargetting` IS the tick +driver, gated by its own internal quantum check rather than an outer +`UseTime` wrapper (unlike `MovementManager::UseTime` / `PositionManager::UseTime` +/ `StickyManager::UseTime` which sit as siblings in the same call chain). + +NOTE (x87 FCMP mush, two occurrences): both `p` and `p_1` are +`unimplemented {test ah, 0x5}` — BN couldn't lower the FLAGS test after +the synthetic FCOMI byte pack. Bit pattern is IDENTICAL shape to the one +decoded in `CheckAndUpdateVoyeur` above: `test ah, 0x5` tests bits +`0b101` = bit0+bit2 of the high byte, i.e. (in the same bit-index scheme +as before) orig-bit-8 (`<`) and orig-bit-0xa (unordered). So: +`p = (x87_r7 < temp0) OR unordered(x87_r7, temp0)`, i.e. +**`p = !(PhysicsTimer::curr_time - last_update_time >= 0.5)`** ... wait, +sign check: `x87_r7 = curr_time - last_update_time`, compared `< 0.5`. +`p` true means `elapsed < 0.5` (or unordered) → guard body runs when `p` +is true. Net: **the whole per-tick body (voyeur re-check + timeout-status +loop) only runs when `elapsed_since_last_update < 0.5s`... ** that reads +backwards for a "tick every N seconds" gate (normally you'd expect the +body to run when elapsed >= threshold, then reset the timer). **FLAG FOR +LEAD**: re-derive this comparison by hand against x86 FCOMI/SAHF +semantics before porting — my bit-algebra above may have the sense +inverted (in `CheckAndUpdateVoyeur` the same `0x41`-style mask decoded +to `<=`, but here it's a bare `test ah,0x5` i.e. `0x05` mask = bit0+bit2, +different mask than `CheckAndUpdateVoyeur`'s `0x41` = bit0+bit6 — these +are NOT the same test and I do not have confident hand-verified parity +between them). The inner 10-second timeout check (`p_1`, same `0x5` mask +pattern on `(10.0 + last_update_time) vs Timer::cur_time`, guarded by +`!p_1`) reads as "target status still Undef 10s after last update → +promote to TimedOut" which is directionally sane (timeout-if-stale), so +by symmetry `p` at the top is very likely the equivalent "run this tick's +logic once quantum-time has elapsed" gate — but get x86 semantics +verified rather than trusting this narrative. + +NOTE (BN mislabel): same `LongNIHashIter::LongNIHashIter` +mislabel as in `NotifyVoyeurOfEvent` — actually `LongNIHashIter`. +The subsequent `LongNIHashIter::operator++` call +(correctly named this time) confirms the iterator's real element type. + +Per-voyeur, the tick body calls `CheckAndUpdateVoyeur` (the +distance-threshold gated sender) for every entry, then stamps +`this->last_update_time = PhysicsTimer::curr_time` at the end (used for +next tick's `p` gate). + +### `TargetManager::SetTarget` — 0051ac30 + +```c +0051ac30 void __thiscall TargetManager::SetTarget(class TargetManager* this, uint32_t arg2, uint32_t arg3, float arg4, double arg5) + +0051ac30 { +0051ac39 class TargetInfo* target_info = this->target_info; +0051ac39 +0051ac3f if (target_info != 0) +0051ac3f { +0051ac45 class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(target_info->object_id); +0051ac45 +0051ac4f if (eax_1 != 0) +0051ac59 CPhysicsObj::remove_voyeur(eax_1, this->physobj->id); +0051ac59 +0051ac5e class TargetInfo* target_info_1 = this->target_info; +0051ac5e +0051ac63 if (target_info_1 != 0) +0051ac63 { +0051ac6b target_info_1->interpolated_position.vtable = 0x79285c; +0051ac6e target_info_1->target_position.vtable = 0x79285c; +0051ac71 operator delete(target_info_1); +0051ac63 } +0051ac63 +0051ac79 this->target_info = nullptr; +0051ac3f } +0051ac3f +0051ac89 if (arg3 == 0) +0051ac89 { +0051ad3d uint32_t var_d0; +0051ad3d TargetInfo::TargetInfo(&var_d0); +0051ad59 var_d0 = arg2; +0051ad60 int32_t var_cc_1 = 0; +0051ad6b int32_t var_10_1 = 6; +0051ad76 void var_1a8; +0051ad76 TargetInfo::TargetInfo(&var_1a8, &var_d0); +0051ad7d CPhysicsObj::HandleUpdateTarget(this->physobj, var_1a8); +0051ad7d return; +0051ac89 } +0051ac89 +0051ac94 void* eax_2 = operator new(0xd0); +0051ac9e uint32_t* eax_3; +0051ac9e +0051ac9e if (eax_2 == 0) +0051aca9 eax_3 = nullptr; +0051ac9e else +0051aca2 eax_3 = TargetInfo::TargetInfo(eax_2); +0051aca2 +0051acb2 this->target_info = eax_3; +0051acb5 *(uint32_t*)eax_3 = arg2; +0051acb7 this->target_info->object_id = arg3; +0051acc4 this->target_info->radius = arg4; +0051acca class TargetInfo* target_info_3 = this->target_info; +0051acdb target_info_3->quantum = arg5; +0051acde *(uint32_t*)((char*)target_info_3->quantum)[4] = *(uint32_t*)((char*)arg5)[4]; +0051ace6 class TargetInfo* target_info_4 = this->target_info; +0051acef target_info_4->last_update_time = (*(uint32_t*)Timer::cur_time); +0051acf5 *(uint32_t*)((char*)target_info_4->last_update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); +0051ad02 class CPhysicsObj* eax_8 = CPhysicsObj::GetObjectA(this->target_info->object_id); +0051ad02 +0051ad0c if (eax_8 != 0) +0051ad0c { +0051ad0e class TargetInfo* target_info_2 = this->target_info; +0051ad29 CPhysicsObj::add_voyeur(eax_8, this->physobj->id, ((float)((long double)target_info_2->radius)), ((float)((long double)target_info_2->quantum))); +0051ad0c } +0051ac30 } +``` + +`arg2` = context_id, `arg3` = new target object_id, `arg4` = radius, +`arg5` = quantum. First unconditionally tears down any EXISTING target +(unsubscribe voyeur from old target, free old `TargetInfo`) — same body +as `ClearTarget` inlined. Then: **if `arg3 == 0`** (clearing to "no +target"), synthesizes a `TargetInfo` with `context_id = arg2`, +`status = 6` (`TimedOut_TargetStatus`, NOTE: value 6 is written directly +as a raw int at `var_10_1 = 6` rather than the named enum constant — same +value as `TimedOut_TargetStatus`, so this "clear" path reports itself as +a timeout-status update to `HandleUpdateTarget`) and returns early +WITHOUT allocating `this->target_info` (stays null). Otherwise +(`arg3 != 0`): allocates a fresh heap `TargetInfo` (0xd0 bytes), +populates `context_id`/`object_id`/`radius`/`quantum`/`last_update_time += Timer::cur_time`, resolves the target object, and if found calls +`add_voyeur` on it to subscribe. Note this does NOT immediately fetch/ +apply the target's current position — that arrives asynchronously via +the target's own `SendVoyeurUpdate` → `receive_target_update` → +`ReceiveUpdate` round-trip. + +### `TargetManager::RemoveVoyeur` — 0051ad90 + +```c +0051ad90 int32_t __thiscall TargetManager::RemoveVoyeur(class TargetManager* this, uint32_t arg2) + +0051ad90 { +0051ad90 class LongNIHash* voyeur_table = this->voyeur_table; +0051ad90 +0051ad95 if (voyeur_table != 0) +0051ad95 { +0051ad9c class DetectionCylsphere* eax_2 = LongNIHash::remove(voyeur_table, arg2); +0051ad9c +0051ada3 if (eax_2 != 0) +0051ada3 { +0051ada6 eax_2->detection_type = 0x79285c; +0051adad operator delete(eax_2); +0051adba return 1; +0051ada3 } +0051ad95 } +0051ad95 +0051adbf return 0; +0051ad90 } +``` + +Removes voyeur `arg2` from `voyeur_table` and frees the +`TargettedVoyeurInfo` record; returns 1 if one was found/removed, 0 +otherwise. + +NOTE (BN mislabel): `LongNIHash::remove` — same +mislabel pattern, actually operating on `LongNIHash` +(confirmed by the `this` parameter type `LongNIHash*`). +Also `eax_2->detection_type = 0x79285c` is writing a vtable-pointer-looking +constant into a field named by BN as `detection_type` — this is almost +certainly the vtable-poison pattern used elsewhere in this file right +before `operator delete` (see `ClearTarget`'s `->vtable = 0x79285c` and +`SetTarget`'s identical pattern) applied to what should be +`TargettedVoyeurInfo`'s embedded `Position last_sent_position`'s vtable +slot, mislabeled as `detection_type` because BN is treating the freed +object as a `DetectionCylsphere*` per the mislabel above. + +--- + +## `TargetManager::~TargetManager` (dtor, bonus) — 0051abd0 + +```c +0051abd0 void __fastcall TargetManager::~TargetManager(class TargetManager* this) + +0051abd0 { +0051abd3 class TargetInfo* target_info = this->target_info; +0051abd3 +0051abd8 if (target_info != 0) +0051abd8 { +0051abe0 target_info->interpolated_position.vtable = 0x79285c; +0051abe3 target_info->target_position.vtable = 0x79285c; +0051abe6 operator delete(target_info); +0051abee this->target_info = nullptr; +0051abd8 } +0051abd8 +0051abf5 class LongNIHash* voyeur_table = this->voyeur_table; +0051abf5 +0051abfa if (voyeur_table != 0) +0051abfa { +0051abfc LongNIHash::destroy_contents(voyeur_table); +0051ac01 class LongNIHash* voyeur_table_1 = this->voyeur_table; +0051ac01 +0051ac06 if (voyeur_table_1 != 0) +0051ac06 { +0051ac0a LongNIHash::flush(voyeur_table_1); +0051ac12 operator delete[](voyeur_table_1->buckets); +0051ac18 voyeur_table_1->buckets = 0; +0051ac1e operator delete(voyeur_table_1); +0051ac06 } +0051abfa } +0051abd0 } +``` + +Frees `target_info` (if any), then destroys+flushes+frees the entire +`voyeur_table` hash (all subscriber records). Does NOT notify anyone +(no `NotifyVoyeurOfEvent` call) — pure teardown, unlike `exit_world` +which explicitly notifies before the `TargetManager` itself is destroyed +elsewhere (line 281877, `CPhysicsObj`'s own dtor/cleanup path). + +NOTE (BN mislabel): `LongNIHash::flush` at 0051ac0a +— actually `LongNIHash::flush` (`voyeur_table_1` is +typed `LongNIHash*`). + +--- + +## `CPhysicsObj`-level seams (no dedicated `MakeTargetManager`) + +Grep for `MakeTargetManager` returned nothing — **retail does not have a +factory function by that name.** Instead, `TargetManager` construction is +**lazily inlined** at the two call sites that first need it: +`CPhysicsObj::set_target` (becoming a voyeur OF something) and +`CPhysicsObj::add_voyeur` (gaining a voyeur watching us). Both follow the +same `if (this->target_manager == 0) { alloc + placement-new +TargetManager(this) }` pattern. + +### `CPhysicsObj::set_target` — 0050ed30 + +```c +0050ed30 void __thiscall CPhysicsObj::set_target(class CPhysicsObj* this, uint32_t arg2, uint32_t arg3, float arg4, double arg5) + +0050ed30 { +0050ed3b if (this->target_manager == 0) +0050ed3b { +0050ed3f void* eax_1 = operator new(0x18); +0050ed49 class TargetManager* eax_2; +0050ed49 +0050ed49 if (eax_1 == 0) +0050ed55 eax_2 = nullptr; +0050ed49 else +0050ed4e eax_2 = TargetManager::TargetManager(eax_1, this); +0050ed4e +0050ed57 this->target_manager = eax_2; +0050ed3b } +0050ed3b +0050ed69 int32_t var_8_2 = *(uint32_t*)((char*)arg5)[4]; +0050ed7c TargetManager::SetTarget(this->target_manager, arg2, arg3, arg4, arg5); +0050ed30 } +``` + +Lazy-construct (0x18 = 24 bytes, matches `TargetManager`'s 4 fields: +ptr+ptr+ptr+long double(?) — actually `physobj`+`target_info`+`voyeur_table` += 12 bytes + `last_update_time` long double = likely 8-12 bytes padded to +24), then forward to `TargetManager::SetTarget`. + +### `CPhysicsObj::clear_target` — 0050ed90 + +```c +0050ed90 void __fastcall CPhysicsObj::clear_target(class CPhysicsObj* this) + +0050ed90 { +0050ed90 class TargetManager* target_manager = this->target_manager; +0050ed90 +0050ed98 if (target_manager == 0) +0050ed9f return; +0050ed9f +0050ed9a /* tailcall */ +0050ed9a return TargetManager::ClearTarget(target_manager); +0050ed90 } +``` + +No-op if no `target_manager` exists yet (does NOT lazily construct just +to clear). Tail-calls `TargetManager::ClearTarget` otherwise. + +### `CPhysicsObj::set_target_quantum` — 0050eda0 + +```c +0050eda0 void __thiscall CPhysicsObj::set_target_quantum(class CPhysicsObj* this, double arg2) + +0050eda0 { +0050eda0 class TargetManager* target_manager = this->target_manager; +0050eda0 +0050eda8 if (target_manager != 0) +0050eda8 { +0050edb2 int32_t var_4_1 = *(uint32_t*)((char*)arg2)[4]; +0050edb4 TargetManager::SetTargetQuantum(target_manager, arg2); +0050eda8 } +0050eda0 } +``` + +### `CPhysicsObj::get_target_quantum` — 0050edc0 + +```c +0050edc0 class TargetManager* __fastcall CPhysicsObj::get_target_quantum(class CPhysicsObj const* this) + +0050edc0 { +0050edc0 class TargetManager* result = this->target_manager; +0050edc0 +0050edc8 if (result != 0) +0050edc8 { +0050edca result = result->target_info; +0050edca +0050edcf if (result != 0) +0050edd1 result->last_update_time; +0050edc8 } +0050edc8 +0050eddb return result; +0050edc0 } +``` + +NOTE: despite the name `get_target_quantum`, the return-type/body as +decompiled reads `target_info->last_update_time` in an expression-statement +with the result DISCARDED, then returns `result` which by that point +holds `this->target_manager->target_info` (a pointer!), not a `quantum` +double. This strongly looks like **BN mis-decompiling a `long double` +return through EAX/x87** — the real function almost certainly returns +`target_info->quantum` (a `long double`, returned via `st0`, which BN's +pseudo-C sometimes fails to model and instead shows the last pointer left +in a GPR). The caller at `MoveToManager::UseTime` (0052a07e, +`fsubr st0, [esp+8]` immediately after the call) treats the return value +as an x87 float on the FPU stack, confirming this reads a `long double` +via `st0`, NOT a pointer. **FLAG FOR LEAD: this function's decompiled +body/return type is unreliable; trust the CALLER's x87 usage (treats +return as `long double quantum`) over the shown C.** + +### `CPhysicsObj::receive_target_update` — 0050ede0 + +```c +0050ede0 void __thiscall CPhysicsObj::receive_target_update(class CPhysicsObj* this, class TargetInfo const* arg2) + +0050ede0 { +0050ede0 class TargetManager* target_manager = this->target_manager; +0050ede0 +0050ede8 if (target_manager == 0) +0050edef return; +0050edef +0050edea /* tailcall */ +0050edea return TargetManager::ReceiveUpdate(target_manager, arg2); +0050ede0 } +``` + +No-op if no manager. This is the entry point `SendVoyeurUpdate` tail-calls +into on the recipient object. + +### `CPhysicsObj::add_voyeur` — 0050ee00 + +```c +0050ee00 void __thiscall CPhysicsObj::add_voyeur(class CPhysicsObj* this, uint32_t arg2, float arg3, float arg4) + +0050ee00 { +0050ee00 int32_t __saved_esi_1; +0050ee00 int32_t __saved_esi = __saved_esi_1; +0050ee00 +0050ee0b if (this->target_manager == 0) +0050ee0b { +0050ee0d int64_t var_c; +0050ee0d *(uint32_t*)((char*)var_c)[4] = 0x18; +0050ee0f int32_t eax_1 = operator new(); +0050ee19 class TargetManager* eax_2; +0050ee19 +0050ee19 if (eax_1 == 0) +0050ee25 eax_2 = nullptr; +0050ee19 else +0050ee19 { +0050ee1b *(uint32_t*)((char*)var_c)[4] = this; +0050ee1e eax_2 = TargetManager::TargetManager(eax_1); +0050ee19 } +0050ee19 +0050ee27 this->target_manager = eax_2; +0050ee0b } +0050ee0b +0050ee47 TargetManager::AddVoyeur(this->target_manager, arg2, arg3, ((double)((long double)arg4))); +0050ee00 } +``` + +Same lazy-construct pattern as `set_target`, then forward to +`TargetManager::AddVoyeur`. `arg2` = voyeur object id, `arg3` = radius, +`arg4` = quantum (float, widened to double for the call). + +### `CPhysicsObj::remove_voyeur` — 0050ee50 + +```c +0050ee50 int32_t __thiscall CPhysicsObj::remove_voyeur(class CPhysicsObj* this, uint32_t arg2) + +0050ee50 { +0050ee50 class TargetManager* target_manager = this->target_manager; +0050ee50 +0050ee58 if (target_manager == 0) +0050ee61 return 0; +0050ee61 +0050ee5a /* tailcall */ +0050ee5a return TargetManager::RemoveVoyeur(target_manager, arg2); +0050ee50 } +``` + +--- + +## Callers (per-tick driver + `SetTarget` producers) + +### Per-tick driver: `CPhysicsObj::UpdateObjectInternal` — 005156b0 (excerpt) + +```c +005159a2 if (part_array_1 != 0) +005159a4 CPartArray::HandleMovement(part_array_1); +005159a4 +005159a9 class PositionManager* position_manager = this->position_manager; +005159a9 +005159b1 if (position_manager != 0) +005159b3 PositionManager::UseTime(position_manager); +``` + +and immediately preceding (full excerpt, lines 283730-283753): + +```c +00515970 class DetectionManager* detection_manager = this->detection_manager; +00515970 +00515978 if (detection_manager != 0) +0051597a DetectionManager::CheckDetection(detection_manager); +0051597a +0051597f class TargetManager* target_manager = this->target_manager; +0051597f +00515987 if (target_manager != 0) +00515989 TargetManager::HandleTargetting(target_manager); +00515989 +0051598e class MovementManager* movement_manager = this->movement_manager; +0051598e +00515996 if (movement_manager != 0) +00515998 MovementManager::UseTime(movement_manager); +00515998 +0051599d class CPartArray* part_array_1 = this->part_array; +0051599d +005159a2 if (part_array_1 != 0) +005159a4 CPartArray::HandleMovement(part_array_1); +005159a4 +005159a9 class PositionManager* position_manager = this->position_manager; +005159a9 +005159b1 if (position_manager != 0) +005159b3 PositionManager::UseTime(position_manager); +``` + +**Confirms the per-tick fan-out order inside `CPhysicsObj::UpdateObjectInternal` +(the retail per-object physics-tick function, called every physics tick +per live `CPhysicsObj`):** +1. `DetectionManager::CheckDetection` +2. `TargetManager::HandleTargetting` ← the voyeur-subscription tick (no separate `UseTime`) +3. `MovementManager::UseTime` +4. `CPartArray::HandleMovement` +5. `PositionManager::UseTime` + +This ordering matters for R5's facade design: TargetManager ticks BEFORE +MovementManager and PositionManager each frame, so any target-driven +`HandleUpdateTarget` callback into those two managers (see next section) +happens with THIS tick's fresh distance-check data, ahead of movement/position +processing the same tick. + +### `CPhysicsObj::HandleUpdateTarget` — 00512bc0 (fan-out target) + +```c +00512bc0 void __thiscall CPhysicsObj::HandleUpdateTarget(class CPhysicsObj* this, class TargetInfo arg2) + +00512bc0 { +00512bc9 if (arg2.context_id == 0) +00512bc9 { +00512bd3 void var_d4; +00512bd3 +00512bd3 if (this->movement_manager != 0) +00512bd3 { +00512be5 TargetInfo::TargetInfo(&var_d4, &arg2); +00512bf0 MovementManager::HandleUpdateTarget(this->movement_manager, var_d4); +00512bd3 } +00512bd3 +00512bfd if (this->position_manager != 0) +00512bfd { +00512c0f TargetInfo::TargetInfo(&var_d4, &arg2); +00512c1a PositionManager::HandleUpdateTarget(this->position_manager, var_d4); +00512bfd } +00512bc9 } +00512bc0 } +``` + +Only fans out when `arg2.context_id == 0` (context_id nonzero presumably +reserved for a different consumer not wired here, e.g. quest/AI scripted +targeting — not confirmed in this extract). Copy-constructs a fresh +`TargetInfo` per recipient and forwards to **both** +`MovementManager::HandleUpdateTarget` and `PositionManager::HandleUpdateTarget` +unconditionally (both get the callback if both managers exist — not an +either/or). `TargetManager::ReceiveUpdate`, `TargetManager::HandleTargetting` +(timeout path), and `TargetManager::SetTarget` (the `arg3==0` clear path) +are the three call sites feeding this fan-out. + +### `SetTarget` producers + +**`MoveToManager::MoveToObject`** — 00529680 (excerpt, the `set_target` call): + +```c +00529791 if (arg3 == physics_obj_2->id) +00529791 { +00529795 edx = MoveToManager::CleanUp(this); +00529795 goto label_52979a; +00529791 } +00529791 +005297b8 int32_t var_50_4 = 0; +005297c4 CPhysicsObj::set_target(physics_obj_2, 0, this->top_level_object_id, 0.5f, 0f); +``` + +**`MoveToManager::TurnToObject`** — 005297d0 (excerpt): + +```c +00529900 int32_t __saved_edi_2 = 0; +0052990c this->initialized = 0; +00529916 CPhysicsObj::set_target(physics_obj_1, 0, arg3, 0.5f, 0f); +``` + +Both `MoveToManager` entry points that establish "move to / turn to a +specific object" call `CPhysicsObj::set_target(physobj, context_id=0, +target_object_id, radius=0.5, quantum=0.0)` — a **zero quantum**, +meaning (per `HandleTargetting`'s gate) the tick-driven resend is +effectively "as fast as the 0.5s-ish quantum-check tick allows" rather +than throttled further; radius is a fixed 0.5 (game units — i.e. "notify +me if the target moves more than half a unit from what I last knew"). + +**`MoveToManager::CleanUp`** — clears the target when movement ends +(excerpt, line 306731-306732): + +```c +0052962c if ((this->top_level_object_id != 0 && this->movement_type != Invalid)) +00529634 CPhysicsObj::clear_target(this->physics_obj); +``` + +**`MoveToManager::UseTime`** — adaptive quantum re-tuning (heavily +x87-mangled excerpt, lines 307376-307434): computes a candidate quantum +from `Position::distance(starting_position, physics_obj->m_position)` and +`get_velocity`-derived speed via a sqrt-based formula (`fsqrt` visible at +0052a057), compares the delta between the new candidate and the current +`CPhysicsObj::get_target_quantum()` value against a `1.0` threshold and +`0.1` epsilon guard, and if the change is significant enough calls +`CPhysicsObj::set_target_quantum(physics_obj, var_88_3)`. **NOTE: this +whole block is FCMP/x87-mangled past reliable hand-decoding** — several +lines are literal `/* unimplemented {fld/fmul/fsqrt/...} */` placeholders +BN could not lower into pseudo-C at all. Treat as "adaptive quantum +retuning happens somewhere in `MoveToManager::UseTime` based on distance +traveled since move-start and current speed" — do NOT port the exact +formula from this extract without a fresh, careful manual disassembly +pass; the pseudo-C here is not trustworthy enough to line-port. + +### `StickyManager` — a second, independent `TargetManager` consumer + +Not explicitly requested but discovered while tracing `clear_target` +callers — **directly relevant to R5's PositionManager/Sticky scope**: + +**`StickyManager::StickTo`** — 00555710 (excerpt): tears down any +existing target then calls +`CPhysicsObj::set_target(physics_obj_1, 0, arg2, 0.5f, 0.5)` — same +radius (0.5) as `MoveToManager` but a **nonzero 0.5s quantum** this time +(throttled resend, unlike MoveToManager's 0.0). + +**`StickyManager::UseTime`** — 00555610: on a `Timer::cur_time >= +sticky_timeout_time` gate (mangled FCMP, same `0x41`-mask shape as +`CheckAndUpdateVoyeur` → reads as `>=`), clears `target_id` and calls +`CPhysicsObj::clear_target` + `CPhysicsObj::interrupt_current_movement`. + +**`StickyManager::Destroy`** — 00555650: unconditional `clear_target` if +`target_id != 0` during teardown. + +**`StickyManager::HandleUpdateTarget`** — 00555780: the `TargetInfo` +consumer callback fanned out from `CPhysicsObj::HandleUpdateTarget`. +Ignores updates whose `object_id` doesn't match `this->target_id`. On a +match: if `status == Ok_TargetStatus`, copies `target_position` into its +own tracked position and marks `initialized = 1`; otherwise (any other +status, including timeout/exit/teleport) tears itself down +(`clear_target` + `interrupt_current_movement`) exactly like `UseTime`'s +timeout path. + +This confirms **two independent per-`CPhysicsObj` consumers of the same +`TargetManager`/voyeur machinery**: `MoveToManager` (move/turn-to-object, +quantum=0, immediate-as-tick-allows resend) and `StickyManager` +(quantum=0.5s throttled resend, timeout-driven unstick). Both receive +their updates through the identical `CPhysicsObj::HandleUpdateTarget` → +`{MovementManager,PositionManager}::HandleUpdateTarget` fan-out — i.e. +`StickyManager::HandleUpdateTarget` is presumably reached VIA +`PositionManager::HandleUpdateTarget` (StickyManager is a PositionManager +sub-component per the project's existing R5 handoff doc), not directly +off `CPhysicsObj`. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 95c6bd9f..a417a0b9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -548,8 +548,8 @@ public sealed class GameWindow : IDisposable /// queue catch-up REPLACES anim when active; anim stands when queue /// is idle. /// - public AcDream.Core.Physics.PositionManager Position { get; } = - new AcDream.Core.Physics.PositionManager(); + public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } = + new AcDream.Core.Physics.RemoteMotionCombiner(); /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 7555fd92..c74f846d 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -457,7 +457,7 @@ public sealed class AnimationSequencer /// , then (on a /// successful InterpretedCommand) the locomotion velocity synthesis /// (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). /// Omega is deliberately NOT synthesized here: remote rotation is the /// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6); diff --git a/src/AcDream.Core/Physics/Motion/ConstraintManager.cs b/src/AcDream.Core/Physics/Motion/ConstraintManager.cs new file mode 100644 index 00000000..72e8ecbf --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/ConstraintManager.cs @@ -0,0 +1,120 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R5 — verbatim port of retail's ConstraintManager (acclient.h:31529, +/// struct #3467; decomp 0x00556090-0x005562xx, +/// r5-constraintmanager-decomp.md). The server-position rubber-band +/// leash: 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 +/// CMotionInterp::jump_is_allowed (block jump while +/// ). +/// +/// Arming is UNPORTED in acdream (R5). Retail arms the leash ONLY +/// from SmartBox::HandleReceivedPosition (on every inbound server +/// position packet) with two constants from +/// CPhysicsObj::GetStart/MaxConstraintDistance whose values BN elided +/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation +/// is not SmartBox, so nothing calls — the leash +/// stays disarmed and stays false, matching +/// register TS-35's current stub behavior. The class is ported for structural +/// completeness of ; the leash-arming port + the +/// two unknown constants are a deferred issue (port-plan §Constraint scope). +/// +public sealed class ConstraintManager +{ + private readonly IPhysicsObjHost _host; + + /// +0x04 retail is_constrained. + public bool IsConstrained { get; private set; } + + /// +0x08 retail constraint_pos_offset — recomputed every + /// 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. + public float ConstraintPosOffset { get; private set; } + + /// +0x0c retail constraint_pos — the leash anchor. Stored + /// by , never read by + /// (retail + ACE — write-only in this class). + public Position ConstraintPos { get; private set; } + + /// +0x48 retail constraint_distance_start — the near edge + /// of the brake band. + public float ConstraintDistanceStart { get; private set; } + + /// +0x4c retail constraint_distance_max — the far edge + /// (full clamp). + public float ConstraintDistanceMax { get; private set; } + + public ConstraintManager(IPhysicsObjHost host) + => _host = host ?? throw new ArgumentNullException(nameof(host)); + + /// + /// Retail ConstraintManager::ConstrainTo (0x00556240). Pin the leash + /// anchor and band; initialize to the + /// CURRENT distance from anchor to the mover (the leash starts already + /// extended to wherever the object is, not at zero). + /// + 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); + } + + /// Retail ConstraintManager::UnConstrain (0x005560c0) — + /// clears the constrained flag only (leaves the band/anchor/offset stale + /// until the next ). + public void UnConstrain() => IsConstrained = false; + + /// + /// Retail ConstraintManager::IsFullyConstrained (0x005560d0): + /// constraint_distance_max * 0.9 < constraint_pos_offset — the + /// object counts as fully constrained once it has strained past 90 % of the + /// max leash. Read by jump_is_allowed to block jumps. Always false + /// while the leash is disarmed (acdream never arms it — see class note). + /// + public bool IsFullyConstrained() + => ConstraintDistanceMax * 0.9f < ConstraintPosOffset; + + /// + /// Retail ConstraintManager::adjust_offset (0x00556180). The last + /// stage of 's chain — clamps the + /// ALREADY-composed per-tick (interp + sticky) + /// while grounded, then records its length for the next tick. No-op unless + /// . See port-plan §2b. + /// + 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(); + } +} diff --git a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs new file mode 100644 index 00000000..85b4eb88 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs @@ -0,0 +1,100 @@ +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R5 seam — the acdream stand-in for retail's CPhysicsObj as seen BY +/// its owned managers. Retail's StickyManager / ConstraintManager +/// / TargetManager each hold a raw physics_obj pointer and call +/// back through it (position/velocity/radius accessors, target-tracking +/// registration, the HandleUpdateTarget fan-out) and — for the voyeur +/// system — resolve OTHER physics objects via CObjectMaint::GetObjectA +/// and drive their add_voyeur / receive_target_update / +/// remove_voyeur entry points. This interface is that back-pointer. +/// +/// The App layer implements one host per entity (a remote +/// RemoteMotion or the local player), wiring the accessors to the live +/// and the / +/// / it owns. +/// is backed by the App's live entity table +/// (_entitiesByServerGuid), giving the voyeur round-trip its +/// cross-entity delivery path. +/// +public interface IPhysicsObjHost +{ + /// Retail physics_obj->id — this object's guid. + uint Id { get; } + + /// Retail physics_obj->m_position — world-space cell + + /// frame (acdream seams carry WORLD space; see the MoveToManager binding + /// note). + Position Position { get; } + + /// Retail CPhysicsObj::get_velocity. + Vector3 Velocity { get; } + + /// Retail CPhysicsObj::GetRadius — the mover's cylinder + /// radius. + float Radius { get; } + + /// Retail physics_obj->transient_state & 1 — the + /// CONTACT bit (ConstraintManager's grounded gate). + bool InContact { get; } + + /// Retail CPhysicsObj::get_minterp()->get_max_speed() — + /// the mover's max locomotion speed, or null if it has no motion + /// interpreter yet (StickyManager falls back to a 15.0 constant). + float? MinterpMaxSpeed { get; } + + /// Retail Timer::cur_time — the wall/game clock (seconds). + /// Drives the sticky 1 s timeout and target 10 s staleness deadlines. + double CurTime { get; } + + /// Retail PhysicsTimer::curr_time — the physics-tick clock + /// (seconds). Drives TargetManager::HandleTargetting's 0.5 s + /// throttle. Retail uses a DIFFERENT clock here than ; + /// acdream may bind both to the same source. + double PhysicsTimerTime { get; } + + /// Retail CObjectMaint::GetObjectA(id) — resolve another + /// physics object by guid, or null if not currently known/visible. + /// The cross-entity seam for the voyeur round-trip and sticky live-target + /// resolve. + IPhysicsObjHost? GetObjectA(uint id); + + /// Retail CPhysicsObj::HandleUpdateTarget — fans a + /// to this host's + /// (move-to steering) AND (sticky follow). + /// Called from and the timeout + /// path. + void HandleUpdateTarget(TargetInfo info); + + /// Retail CPhysicsObj::interrupt_current_movement → + /// MovementManager::CancelMoveTo(0x36). + void InterruptCurrentMovement(); + + /// Retail CPhysicsObj::set_target(ctx, objId, radius, + /// quantum) (lazily creating + /// the TargetManager). Called by StickyManager::StickTo and + /// MoveToManager's object-move entry points. + void SetTarget(uint contextId, uint objectId, float radius, double quantum); + + /// Retail CPhysicsObj::clear_target → + /// . + void ClearTarget(); + + /// Retail CPhysicsObj::receive_target_update → + /// . The inbound side a SENDER's + /// SendVoyeurUpdate tail-calls on the watcher. + void ReceiveTargetUpdate(TargetInfo info); + + /// Retail CPhysicsObj::add_voyeur(id, radius, quantum) → + /// (lazily creating the + /// TargetManager). Called on the TARGET when a watcher subscribes. + void AddVoyeur(uint watcherId, float radius, double quantum); + + /// Retail CPhysicsObj::remove_voyeur(id) → + /// . Called on the TARGET when a + /// watcher unsubscribes. + void RemoveVoyeur(uint watcherId); +} diff --git a/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs b/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs new file mode 100644 index 00000000..8386e5a2 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs @@ -0,0 +1,40 @@ +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +/// +/// Mutable stand-in for retail's Frame when it is used as the per-tick +/// delta accumulator that PositionManager::adjust_offset and its +/// three sub-managers (Interpolation / Sticky / Constraint) write into +/// (retail arg2, e.g. StickyManager::adjust_offset 0x00555430, +/// ConstraintManager::adjust_offset 0x00556180). acdream's +/// is an immutable record — retail's per-tick math +/// mutates m_fOrigin 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. +/// +/// = retail m_fOrigin (the accumulated +/// position delta, in the mover's LOCAL frame after +/// Position::globaltolocalvec). carries the +/// heading retail's Frame::set_heading writes; read/write it as a +/// compass heading via / +/// (P5 convention, degrees). +/// +public sealed class MotionDeltaFrame +{ + /// Retail m_fOrigin — the accumulated per-tick position + /// delta (mover-local frame). + public Vector3 Origin; + + /// Retail Frame rotation — carries the + /// Frame::set_heading output. + public Quaternion Orientation = Quaternion.Identity; + + /// Retail Frame::get_heading (P5 compass degrees). + public float GetHeading() => MoveToMath.GetHeading(Orientation); + + /// Retail Frame::set_heading(headingDeg) — pure + /// yaw-about-Z setter (P5 compass degrees). + public void SetHeading(float headingDeg) => + Orientation = MoveToMath.SetHeading(Orientation, headingDeg); +} diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs index c788d105..ac943c80 100644 --- a/src/AcDream.Core/Physics/Motion/MoveToManager.cs +++ b/src/AcDream.Core/Physics/Motion/MoveToManager.cs @@ -1558,10 +1558,21 @@ public sealed class MoveToManager /// /// Retail TargetInfo (acclient.h:31591, struct #3482) — the callback -/// payload consumes. The P4 -/// TargetTracker adapter (App-side, R4-V4 scope) is the ONLY producer; -/// itself has no target-tracking machinery of its -/// own (TargetManager bodies are R5 — decomp §9f, do-not-invent list). +/// payload consumes AND the wire +/// record the R5 voyeur system exchanges between +/// hosts (SendVoyeurUpdatereceive_target_update → +/// ). +/// +/// R5 EXTENDED this from the R4 4-field callback shape to the full retail +/// 10-field struct. The extra fields (, +/// , , +/// , , +/// ) default to zero, so the existing 4-argument +/// new TargetInfo(id, status, tp, ip) call sites (MoveToManager tests, +/// the AP-79 adapter pre-V2) still compile unchanged. +/// only reads +/// //; +/// the extra fields are consumed by the voyeur system. /// /// Retail object_id — matched against /// ; a mismatch is silently @@ -1572,11 +1583,30 @@ public sealed class MoveToManager /// Retail interpolated_position — /// the smoothed tracking point /// steers toward. +/// Retail context_id — the tracking context +/// (0 = the movement context; CPhysicsObj::HandleUpdateTarget only fans +/// out context 0). +/// Retail radius — the voyeur's send-on-move +/// threshold (game units). +/// Retail quantum — the dead-reckoning lookahead +/// horizon (seconds) for GetInterpolatedPosition. +/// Retail interpolated_heading — +/// normalized self→target direction (falls back to +Z when degenerate). +/// Retail velocity — the target's velocity at +/// send time. +/// Retail last_update_time — receipt +/// timestamp (drives the 10 s staleness timeout). public readonly record struct TargetInfo( uint ObjectId, TargetStatus Status, 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); /// /// Retail TargetStatus (acclient.h:7264). Only vs diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs index 67ed12c9..e7365edb 100644 --- a/src/AcDream.Core/Physics/Motion/MoveToMath.cs +++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs @@ -230,6 +230,58 @@ public static class MoveToMath return edgeDist > 0f ? edgeDist : 0f; } + /// + /// Retail Position::cylinder_distance_no_z — the signed + /// horizontal (X/Y) edge-to-edge distance between two cylinders: + /// centerDist − ownRadius − targetRadius. Unlike + /// (the arrival-gate variant, which CLAMPS at + /// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE + /// value. StickyManager::adjust_offset (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). + /// + 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; + } + + /// + /// Retail AC1Legacy::Vector3::normalize_check_small — normalize + /// in place, returning true if the vector was + /// too small to normalize (near-zero) and leaving it unchanged in that + /// case. Consumed by StickyManager::adjust_offset (don't chase + /// jitter when already at the target) and + /// (interpolated-heading + /// fallback). A public shared twin of the private helper in + /// ParticleSystem; same 1e-8 near-zero length guard. + /// + /// true = too small (left unchanged); false = + /// normalized. + public static bool NormalizeCheckSmall(ref Vector3 v) + { + float length = v.Length(); + if (length < 1e-8f) + return true; + v /= length; + return false; + } + + /// + /// Retail Position::globaltolocalvec — rotate a WORLD-space vector + /// into a frame's LOCAL coordinates by the inverse of the frame's + /// orientation. Consumed by StickyManager::adjust_offset + /// (0x00555430) to express the self→target offset in the mover's own frame + /// before flattening Z and steering. Pure rotation (no translation) — + /// is a direction/offset, not a point. + /// + public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec) + => Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation)); + /// /// Landblock-local wire origin → world space (verbatim relocation from /// the deleted RemoteMoveToDriver.OriginToWorld, R4-V4): MoveTo / diff --git a/src/AcDream.Core/Physics/Motion/PositionManager.cs b/src/AcDream.Core/Physics/Motion/PositionManager.cs new file mode 100644 index 00000000..0ed02f04 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/PositionManager.cs @@ -0,0 +1,102 @@ +using System; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R5 — port of retail's PositionManager facade (acclient.h:30952, +/// struct #3468; decomp 0x00555160-0x005553d0, +/// r5-positionmanager-sticky-decomp.md). A thin fan-out over three +/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's +/// (retail CPhysicsObj::position_manager, +/// lazily created). +/// +/// Interpolation note: retail's adjust_offset chains +/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in +/// (the R5-renamed remote-motion combiner, +/// formerly the misnamed Physics.PositionManager) and is NOT chained +/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39; +/// Constraint is structural — see ). Folding the +/// combiner in as the interp stage is a wiring-slice cleanup. +/// +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)); + + /// Exposed for wiring/tests — the lazily-created sub-managers + /// (null until first use). + public StickyManager? Sticky => _sticky; + public ConstraintManager? Constraint => _constraint; + + /// Retail PositionManager::StickTo (0x00555230) — lazily + /// create the and begin following + /// . + public void StickTo(uint objectId, float radius, float height) + { + _sticky ??= new StickyManager(_host); + _sticky.StickTo(objectId, radius, height); + } + + /// Retail PositionManager::UnStick (0x005551e0) — forward + /// to the sticky sub-manager if it exists. + public void UnStick() => _sticky?.UnStick(); + + /// Retail PositionManager::GetStickyObjectID + /// (0x00555270). + public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u; + + /// Retail PositionManager::ConstrainTo (0x00555280) — + /// lazily create the and arm the leash. + /// (Unused in acdream — no arming call site; see + /// .) + public void ConstrainTo(Position anchor, float startDistance, float maxDistance) + { + _constraint ??= new ConstraintManager(_host); + _constraint.ConstrainTo(anchor, startDistance, maxDistance); + } + + /// Retail PositionManager::UnConstrain + /// (0x005552b0). + public void UnConstrain() => _constraint?.UnConstrain(); + + /// Retail PositionManager::IsFullyConstrained + /// (0x005552c0) — false when no constraint sub-manager exists. + public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false; + + /// + /// Retail PositionManager::HandleUpdateTarget (0x005553d0) — only + /// the sticky sub-manager cares about live target positions (interpolation + /// and constraint don't). Fanned out from + /// CPhysicsObj::HandleUpdateTarget. + /// + public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info); + + /// + /// Retail PositionManager::adjust_offset (0x00555190) — chains the + /// sub-managers' contributions into the SAME + /// 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. + /// + public void AdjustOffset(MotionDeltaFrame offset, double quantum) + { + _sticky?.AdjustOffset(offset, quantum); + _constraint?.AdjustOffset(offset, quantum); + } + + /// + /// Retail PositionManager::UseTime (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). + /// + public void UseTime() => _sticky?.UseTime(); +} diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs new file mode 100644 index 00000000..e46ead5e --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -0,0 +1,235 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R5 — verbatim port of retail's StickyManager (acclient.h:31518, +/// struct #3466; decomp block 0x00555400-0x00555866, +/// r5-positionmanager-sticky-decomp.md). Makes the owning object +/// FOLLOW a target object at a bounded gap: each tick +/// 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 () +/// drops the stick if no target-position update arrives. +/// +/// Owned by (lazily created on first +/// ). Establishes its target-tracking +/// subscription through the owning 's +/// set_target (→ ); receives the target's +/// live position back through , fanned out from +/// CPhysicsObj::HandleUpdateTarget. +/// +/// The dense x87 back half of retail's adjust_offset is decoded +/// against ACE's StickyManager.cs (the two constants +/// =0.3 and =1.0 are ACE's, +/// verified against the retail mush structure — see the port-plan §2a). +/// +public sealed class StickyManager +{ + /// Retail StickyRadius const (ACE: 0.3f) — the desired + /// follow gap subtracted from the cylinder distance. + public const float StickyRadius = 0.3f; + + /// Retail StickyTime const (ACE: 1.0f) — the one-shot grace + /// window: if no target update refreshes the stick within this many + /// seconds of , drops it. + public const float StickyTime = 1.0f; + + /// Retail get_max_speed multiplier for the follow speed + /// (ACE: ×5 — the follower catches up faster than a normal walk/run). + private const float FollowSpeedFactor = 5.0f; + + /// Retail fallback follow speed when no motion interpreter exists + /// (ACE: 15.0f, i.e. the MAX_VELOCITY constant the mush loads). + private const float FallbackFollowSpeed = 15.0f; + + private readonly IPhysicsObjHost _host; + + /// +0x00 retail target_id — the object we are stuck to + /// (0 = not stuck). + public uint TargetId { get; private set; } + + /// +0x04 retail target_radius — the target's cylinder + /// radius (from CPartArray::GetRadius of the stuck-to object). + public float TargetRadius { get; private set; } + + /// +0x08 retail target_position — last-known target + /// position (from ), used when the live + /// GetObjectA resolve fails. + public Position TargetPosition { get; private set; } + + /// +0x54 retail initialized — false until the first + /// Ok target update arrives (gates and + /// the timeout). + public bool Initialized { get; private set; } + + /// +0x58 retail sticky_timeout_time — the wall-clock + /// deadline set once at time (now + 1 s). + public double StickyTimeoutTime { get; private set; } + + public StickyManager(IPhysicsObjHost host) + => _host = host ?? throw new ArgumentNullException(nameof(host)); + + /// + /// Retail StickyManager::UnStick (0x00555400). No-op if not stuck; + /// otherwise the standard 4-step teardown: clear + + /// , tell the host to clear_target (drop + /// the voyeur subscription), then interrupt_current_movement. + /// + public void UnStick() + { + if (TargetId == 0) + return; + + TargetId = 0; + Initialized = false; + _host.ClearTarget(); + _host.InterruptCurrentMovement(); + } + + /// + /// Retail StickyManager::StickTo (0x00555710). Begin following + /// . If already stuck, tears the old stick down + /// first (same 4-step sequence as ). Sets the 1 s + /// timeout deadline and registers as a voyeur of the target via the host's + /// set_target(context=0, objectId, radius=0.5, quantum=0.5) — the + /// live target position then arrives asynchronously through + /// . + /// + /// Retail arg2 — target object id. + /// Retail arg3 — the target's cylinder + /// radius (feeds 's distance math). + /// Retail arg4 — accepted for call-shape + /// parity but UNUSED in the body (matches retail + ACE; the height feeds + /// the caller-side geometry only). + 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); + } + + /// + /// Retail StickyManager::UseTime (0x00555610). The 1 s watchdog: if + /// Timer::cur_time >= sticky_timeout_time, force-unstick (same + /// 4-step teardown). The deadline is set once in and + /// NOT refreshed by (retail + ACE) — a + /// stick survives at most 1 s of wall-clock unless re-issued. + /// + public void UseTime() + { + if (TargetId == 0) + return; + + if (_host.CurTime >= StickyTimeoutTime) + { + TargetId = 0; + Initialized = false; + _host.ClearTarget(); + _host.InterruptCurrentMovement(); + } + } + + /// + /// Retail StickyManager::HandleUpdateTarget (0x00555780). The + /// target-position callback (fanned out from + /// CPhysicsObj::HandleUpdateTarget). + /// Ignores updates whose doesn't match + /// our . On : cache the + /// target position and mark . On any other status + /// (lost/exit/teleport): tear the stick down (4-step). + /// + 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(); + } + } + + /// + /// Retail StickyManager::adjust_offset (0x00555430). Writes this + /// tick's follow steering into the shared + /// 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. + /// + /// The per-tick delta frame + /// ('s shared accumulator). + /// Elapsed time this tick, seconds. + 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); + } +} diff --git a/src/AcDream.Core/Physics/Motion/TargetManager.cs b/src/AcDream.Core/Physics/Motion/TargetManager.cs new file mode 100644 index 00000000..1567f979 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/TargetManager.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R5 — port of retail's TargetManager (acclient.h:31024, struct #3484; +/// decomp 0x0051a370-0x0051ad90, r5-targetmanager-decomp.md). A +/// peer-to-peer voyeur subscription system with two roles per object: +/// +/// +/// Watcher (): +/// registers this object as a voyeur ON a target; the target's per-tick +/// pushes position updates back here via +/// , which fans them to the owning host's +/// MoveToManager + PositionManager (sticky) through +/// . +/// Watched (): other objects' +/// subscribe to THIS object; each tick +/// sends a +/// dead-reckoned update to any subscriber the object has drifted past the +/// subscriber's radius from. +/// +/// +/// This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow +/// polling the entity table). It is a faithful superset: the same +/// move-to tracking (distance > radius → HandleUpdateTarget(Ok)) plus the +/// correct sticky, 10 s timeout, and exit/teleport event handling. +/// +/// Owned 1:1 by an (retail +/// CPhysicsObj::target_manager, lazily created on first +/// set_target/add_voyeur). The two throttle constants +/// (=0.5, =10) are +/// ACE's, verified against the retail x87 mush — port-plan §2d. +/// +public sealed class TargetManager +{ + /// Retail HandleTargetting per-tick throttle (ACE: 0.5s) — + /// the voyeur sweep runs at most this often. + public const double ThrottleSeconds = 0.5; + + /// Retail target-info staleness timeout (ACE: 10.0s) — an + /// Undefined-status target with no update for this long is marked + /// TimedOut. + public const double StalenessSeconds = 10.0; + + private readonly IPhysicsObjHost _host; + + private TargetInfo? _targetInfo; // retail target_info (watcher role) + private Dictionary? _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)); + + /// The current watched-target info, or null when tracking + /// nothing. + public TargetInfo? TargetInfo => _targetInfo; + + /// The subscriber table (null until the first + /// ). + public IReadOnlyDictionary? VoyeurTable => _voyeurTable; + + /// Retail get_target_quantum — the current target's + /// quantum, 0 when tracking nothing. + public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0; + + // ── watcher role ─────────────────────────────────────────────────────── + + /// + /// Retail TargetManager::SetTarget (0x0051ac30). Tear down any + /// existing target, then: if is 0, synthesize a + /// TimedOut clear-update to the host and leave + /// null; otherwise create a fresh (status + /// Undefined) and subscribe as a voyeur ON the target + /// (target.add_voyeur(self.id, radius, quantum)). The target's live + /// position arrives asynchronously via . + /// + 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); + } + + /// + /// Retail TargetManager::SetTargetQuantum (0x0051a4a0). Update the + /// current target's resend interval and re-register the voyeur subscription + /// on the target with the new quantum. + /// + 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); + } + + /// + /// Retail TargetManager::ClearTarget (0x0051a7e0). Unsubscribe from + /// the current target's voyeur table and drop . + /// + public void ClearTarget() + { + if (_targetInfo is not { } ti) + return; + + var target = _host.GetObjectA(ti.ObjectId); + target?.RemoveVoyeur(_host.Id); + _targetInfo = null; + } + + /// + /// Retail TargetManager::ReceiveUpdate (0x0051a930). The inbound + /// handler when a target we watch sends us its position (via + /// SendVoyeurUpdatereceive_target_update). 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. + /// + 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 ─────────────────────────────────────────────────────── + + /// + /// Retail TargetManager::AddVoyeur (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 (Ok). + /// + public void AddVoyeur(uint watcherId, float radius, double quantum) + { + _voyeurTable ??= new Dictionary(); + + 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); + } + + /// Retail TargetManager::RemoveVoyeur (0x0051ad90). + public bool RemoveVoyeur(uint watcherId) + => _voyeurTable?.Remove(watcherId) ?? false; + + /// + /// Retail TargetManager::HandleTargetting (0x0051aa90). THE per-tick + /// driver (no separate UseTime): self-throttled to + /// , promotes a stale target to TimedOut after + /// , then sweeps every subscriber through + /// . + /// + 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; + } + + /// + /// Retail TargetManager::CheckAndUpdateVoyeur (0x0051a650). Push an + /// update to only if this object's dead-reckoned + /// position has drifted more than the voyeur's radius since the last send. + /// + 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); + } + + /// + /// Retail TargetManager::GetInterpolatedPosition (0x0051a5e0). + /// Dead-reckon this object's position forward by + /// seconds using its current velocity. + /// + 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); + } + + /// + /// Retail TargetManager::SendVoyeurUpdate (0x0051a4f0). Record the + /// sent position on the voyeur, build a carrying + /// this object's CURRENT authoritative position + the extrapolated + /// + velocity + status, and deliver it to the + /// subscriber's (via its host). + /// + 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); + } + + /// + /// Retail TargetManager::NotifyVoyeurOfEvent (0x0051a6f0). Broadcast + /// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber + /// with this object's CURRENT position — no distance gate. + /// + public void NotifyVoyeurOfEvent(TargetStatus status) + { + if (_voyeurTable == null) + return; + + foreach (var voyeur in _voyeurTable.Values.ToList()) + SendVoyeurUpdate(voyeur, _host.Position, status); + } +} diff --git a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs new file mode 100644 index 00000000..10631681 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs @@ -0,0 +1,36 @@ +namespace AcDream.Core.Physics.Motion; + +/// +/// R5 — port of retail's TargettedVoyeurInfo (acclient.h:52807, +/// struct #5801). One entry in a 's voyeur table: +/// a subscriber watching THIS object, the send-on-move +/// threshold it registered, its dead-reckoning , and the +/// already delivered to it (the delta baseline +/// CheckAndUpdateVoyeur compares against). Mutable class (retail heap +/// record updated in place by AddVoyeur/SendVoyeurUpdate). +/// +public sealed class TargettedVoyeurInfo +{ + /// +0x00 retail object_id — the subscriber's guid. + public uint ObjectId { get; } + + /// +0x04 retail quantum — the subscriber's dead-reckoning + /// lookahead horizon (seconds). + public double Quantum { get; set; } + + /// +0x10 retail radius — the send-on-move threshold: an + /// update is pushed only when the tracked object drifts more than this from + /// . + public float Radius { get; set; } + + /// +0x14 retail last_sent_position — the position last + /// delivered to this subscriber (updated by SendVoyeurUpdate). + public Position LastSentPosition { get; set; } + + public TargettedVoyeurInfo(uint objectId, float radius, double quantum) + { + ObjectId = objectId; + Radius = radius; + Quantum = quantum; + } +} diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/RemoteMotionCombiner.cs similarity index 91% rename from src/AcDream.Core/Physics/PositionManager.cs rename to src/AcDream.Core/Physics/RemoteMotionCombiner.cs index e3ff2aec..eaf86fe1 100644 --- a/src/AcDream.Core/Physics/PositionManager.cs +++ b/src/AcDream.Core/Physics/RemoteMotionCombiner.cs @@ -17,8 +17,16 @@ namespace AcDream.Core.Physics; /// active locomotion cycle). We rotate that by the body's orientation /// to get a world-space delta, then add the InterpolationManager's /// world-space correction. +/// +/// Renamed R5 (was PositionManager): this class is only the +/// InterpolationManager-composition portion of retail's +/// PositionManager::adjust_offset — NOT the retail PositionManager +/// facade. The faithful facade (Sticky/Constraint, owned per entity) is +/// . The name was freed to remove the +/// ambiguity that broke every file importing both +/// AcDream.Core.Physics and AcDream.Core.Physics.Motion. /// -public sealed class PositionManager +public sealed class RemoteMotionCombiner { /// /// Compute the per-frame world-space delta to add to body.Position. diff --git a/tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs new file mode 100644 index 00000000..2e844c60 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs @@ -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; + +/// +/// R5 conformance — (retail 0x00556090-…). +/// The leash-band taper + the 90 % IsFullyConstrained jump gate (port-plan §2b/§2c). +/// +public sealed class ConstraintManagerTests +{ + private static (R5Host host, ConstraintManager cm) Setup() + { + var world = new Dictionary(); + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs new file mode 100644 index 00000000..5b9312ee --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs @@ -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; + +/// +/// R5 conformance — facade (retail +/// 0x00555160-0x005553d0). Lazy sub-manager creation + fan-out. (Test class is +/// suffixed "Facade" to read distinctly from the renamed +/// RemoteMotionCombiner combiner tests.) +/// +public sealed class PositionManagerFacadeTests +{ + private static (R5Host self, R5Host target, PositionManager pm) Setup() + { + var world = new Dictionary(); + 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 + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs new file mode 100644 index 00000000..55701c73 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs @@ -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; + +/// +/// R5 conformance harness — a scriptable fake +/// backed by a shared so resolves +/// OTHER hosts (the cross-entity seam the voyeur round-trip needs). Each host +/// lazily owns a and a +/// (the two R5 managers under test), and records every +/// the managers fan out so tests can assert on +/// delivery. +/// +/// Position/velocity/radius/contact/max-speed and both clocks are mutable +/// fields tests drive directly (retail's CPhysicsObj accessors). The +/// set_target/clear_target/add_voyeur/remove_voyeur/ +/// receive_target_update seams forward to the owned +/// exactly as retail's CPhysicsObj does. +/// +internal sealed class R5Host : IPhysicsObjHost +{ + public readonly Dictionary World; + + public R5Host(uint id, Dictionary 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; } + + /// Set to false to simulate a target that isn't currently + /// resolvable (out of streaming view) — returns + /// null for it even while it's in . + 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 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; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs new file mode 100644 index 00000000..7e5e31f7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs @@ -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; + +/// +/// R5 conformance — (retail 0x00555400-0x00555866). +/// Lifecycle (StickTo/UnStick/timeout/HandleUpdateTarget) + the decoded +/// adjust_offset steering math (port-plan §2a). +/// +public sealed class StickyManagerTests +{ + private static (R5Host self, R5Host target, StickyManager sticky) Setup( + uint targetId = 20u, float targetRadius = 0.5f) + { + var world = new Dictionary(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs new file mode 100644 index 00000000..0fd2130b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs @@ -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; + +/// +/// R5 conformance — 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. +/// +public sealed class TargetManagerTests +{ + private static (R5Host self, R5Host target, Dictionary world) TwoHosts() + { + var world = new Dictionary(); + 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(); + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs similarity index 97% rename from tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs rename to tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs index 2920d65c..e6d4300f 100644 --- a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs @@ -6,18 +6,19 @@ using Xunit; 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). // Pure-function combiner: animation root motion (seqVel × dt, rotated by // body orientation) + InterpolationManager.AdjustOffset correction. // ───────────────────────────────────────────────────────────────────────────── -public sealed class PositionManagerTests +public sealed class RemoteMotionCombinerTests { // ── helpers ─────────────────────────────────────────────────────────────── - private static PositionManager Make() => new(); + private static RemoteMotionCombiner Make() => new(); private static InterpolationManager EmptyInterp() => new();