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>
18 KiB
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):
DetectionManager::CheckDetectionTargetManager::HandleTargetting← voyeur tick (no separate UseTime)MovementManager::UseTime→MoveToManager::UseTimeonlyCPartArray::HandleMovementPositionManager::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 : () → Vector3getRadius : () → float,getHeight : () → float(from setup cylsphere — finally needed here; today MoveToManager passes 0 — see the P4 note)getMinterpMaxSpeed : () → float?(null if no interp) — StickyManager speedgetContact : () → bool(transient Contact bit) — ConstraintManager gategetObjectA : 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+ extendedTargetInfo.- 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
getObjectAwiring 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 testgreen. Commit. No behavior change (nothing wired).
R5-V2 — Wire TargetManager per-entity; retire AP-79
- Add
TargetManagertoRemoteMotion+ a player-side owner; construct beside the MoveToManager binds. Repoint MoveToManager's_setTarget/_clearTarget/quantum seams atTargetManager.SetTarget/ClearTarget/quantum. getObjectAbacked by_entitiesByServerGuid(returns a handle to the target's TargetManager). Per-tick: callTargetManager.HandleTargetting()inTickRemoteMoveTo+ the player block, BEFOREMoveToManager.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
PositionManagerto each entity. BindMoveToManager.StickTo → PositionManager.StickTo,MoveToManager.Unstick → PositionManager.UnStick. - Integrate
PositionManager.adjust_offsetinto 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+MoveTointo aMovementManagerowner (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.MoveToInterpretedStateaction 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
whereto 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)
GetStart/MaxConstraintDistancex87 return constants — unknown (constraint arming, deferred). File as issue.HandleTargetting's 0.5s/10s quanta +StickyTime1.0s /StickyRadius0.3f / sticky speed×5+15.0ffallback — ACE values; verify against named-retail.- mt-0 header flags: confirm
0x1/0x2(wire) == decomp0x100/0x200shift (recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword). TargetInfopass-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).