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

18 KiB
Raw Blame History

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::UseTimeMoveToManager::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 StickToObjectstick_to_object(guid)PositionManager.StickTo; 0x2 StandingLongJumpMotionInterpreter.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).