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