# 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).