acdream/docs/research/2026-07-03-r5-managers/r5-port-plan.md
Erik 3d89446d98 feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)
The retail movement-manager family the R4 MoveToManager port left as
do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's
PositionManager facade + StickyManager + ConstraintManager + the
TargetManager voyeur system, with full conformance tests. NO wiring yet
— purely additive, no behavior change. Wiring (retiring TS-39 sticky +
AP-79 target adapter) is R5-V2/V3.

New Core classes (src/AcDream.Core/Physics/Motion/):
- StickyManager (0x00555400): follow-a-target steering. adjust_offset's
  dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0,
  follow speed ×5 / fallback 15) — speed-clamped signed-distance steer +
  bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown.
- ConstraintManager (0x00556090): the server-position rubber-band leash.
  90% IsFullyConstrained jump gate + grounded linear brake taper.
  Structural only — acdream never ARMS it (retail arms from
  SmartBox::HandleReceivedPosition, which acdream lacks, with two x87
  constants BN elided). IsFullyConstrained stays false = TS-35 behavior;
  leash-arming + the unknown constants are a deferred issue.
- PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out.
- TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer
  voyeur subscription system (0.5 s throttle, 10 s staleness,
  send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A
  faithful superset of the AP-79 adapter — SetTarget subscribes ON the
  target; the target's HandleTargetting pushes updates back.
- IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/
  radius/contact/GetObjectA + target-tracking fan-out) the App wires per
  entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator.

Supporting:
- TargetInfo extended to the full retail 10-field struct (additive
  defaults keep the R4 4-arg call sites compiling).
- MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall,
  GlobalToLocalVec.
- Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote
  anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion
  Combiner, freeing the name and removing the ambiguity that breaks every
  file importing both Physics + Physics.Motion (GameWindow will in V2/V3).

Tests: 42 new conformance cases (Sticky/Constraint/Position facade +
TargetManager incl. the full cross-entity voyeur round-trip). Full suite
4006 green (+2 skipped), no regressions.

Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:34:49 +02:00

338 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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