acdream/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.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

32 KiB

ConstraintManager — retail decomp extract

Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR build, PDB-named). Struct source: docs/research/named-retail/acclient.h.

Address correction: the task listed CPhysicsObj::IsFullyConstrained at 0x0050f730. The actual address in the corpus is 0x0050ec60. Verified by grepping the definition line (276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)) and cross-checked against its caller in CMotionInterp::jump_is_allowed at 0x005282fd.


struct ConstraintManager (acclient.h, comment /* 3467 */)

/* 3467 */
struct __cppobj ConstraintManager
{
  CPhysicsObj *physics_obj;
  int is_constrained;
  float constraint_pos_offset;
  Position constraint_pos;
  float constraint_distance_start;
  float constraint_distance_max;
};

struct PositionManager (acclient.h, comment /* 3468 */) — the owning object

/* 3468 */
struct __cppobj PositionManager
{
  InterpolationManager *interpolation_manager;
  StickyManager *sticky_manager;
  ConstraintManager *constraint_manager;
  CPhysicsObj *physics_obj;
};

Note the field order in PositionManager (interpolation_manager, sticky_manager, constraint_manager, physics_obj) matches the PositionManager::Create allocation writes to offsets 0x0, 0x4, 0x8, 0xc respectively (see below) and the PositionManager::Destroy teardown order (interpolation_managersticky_managerconstraint_manager).

ConstraintManager field layout maps onto ConstraintManager::Create's raw offset writes: physics_obj=0x0, is_constrained=0x4, constraint_pos_offset=0x8 — wait, per the decompiled writes below the offsets are actually 0x0/0x4/0x8/0xc (vtable field of Position constraint_pos at 0xc)/0x10.../0x48 is constraint_distance_start, 0x4c is constraint_distance_max. The compiler emits a Position (which itself embeds a Frame with its own vtable-looking sentinel field — see NOTE in ~ConstraintManager below) between constraint_pos_offset and constraint_distance_start, consistent with the struct's declared member order.


ConstraintManager::SetPhysicsObject — 0x00556090

00556090  void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2)

00556090  {
00556096      if (this->physics_obj == 0)
00556096      {
005560ad          this->physics_obj = arg2;
005560af          return;
00556096      }
00556096      
00556098      this->physics_obj = 0;
0055609a      this->is_constrained = 0;
0055609d      this->constraint_pos_offset = 0f;
005560a4      this->physics_obj = arg2;
00556090  }

ConstraintManager::UnConstrain — 0x005560c0

005560c0  void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this)

005560c0  {
005560c0      this->is_constrained = 0;
005560c0  }

ConstraintManager::IsFullyConstrained — 0x005560d0

005560d0  int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this)

005560d0  {
005560d0      long double x87_r7 = ((long double)this->constraint_pos_offset);
005560d6      long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002));
005560dc      (x87_r6_1 - x87_r7);
005560de      int32_t eax;
005560de      eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0))));
005560e0      bool p = /* bool p = unimplemented  {test ah, 0x5} */;
005560e0      
005560e3      if (p)
005560ed          return 0;
005560ed      
005560ea      return 1;
005560d0  }

NOTE (garbled bitfield mush / x87 flags mush): the eax = (...) line is Binary Ninja's attempt to render the x87 FCOMI/FSTSW+SAHF-style compare-and-test-flags sequence as bit-packed pseudocode. It is computing constraint_distance_max * 0.9 <=> constraint_pos_offset and then test ah, 0x5 checks the ZF/CF-equivalent bits packed into ah after fnstsw ax. The semantic read: p is true when (constraint_distance_max * 0.9) < constraint_pos_offset OR the compare was unordered (NaN) — i.e. test ah,5 tests bits 0 (C0/"below") and 2 (C3/"equal") of the FPU status word as loaded into AH, the classic x87 jbe-equivalent pattern. So: IsFullyConstrained returns false (0) if constraint_pos_offset >= 0.9 * constraint_distance_max (or unordered), else returns true (1). In plain terms: the object counts as "fully constrained" while it is still within 90% of the max leash distance; once it has drifted past 90% of that distance it is no longer "fully" constrained (this is the gate CMotionInterp::jump_is_allowed reads to block jump attempts while straining at the very end of a constraint leash).

ConstraintManager::~ConstraintManager — 0x005560f0

005560f0  void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this)

005560f0  {
005560f2      this->is_constrained = 0;
005560f5      this->constraint_pos_offset = 0f;
005560f8      this->physics_obj = 0;
005560fa      this->constraint_pos.vtable = 0x79285c;
005560f0  }

NOTE: this->constraint_pos.vtable = 0x79285cconstraint_pos is a Position field (struct member, not a pointer), so this is Binary Ninja's rendering of the embedded Frame's vtable-pointer slot being reset to its static vtable address as part of the Position/Frame subobject's implicit destructor inlining. Not a real "vtable swap"; just the compiler zeroing/resetting the embedded Frame's identity field during teardown.

ConstraintManager::Create — 0x00556110 (factory)

00556110  class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1)

00556110  {
00556114      void* result = operator new(0x5c);
00556114      
00556122      if (result == 0)
00556177          return 0;
00556177      
00556124      *(uint32_t*)result = 0;
00556126      *(uint32_t*)((char*)result + 4) = 0;
00556129      *(uint32_t*)((char*)result + 8) = 0;
0055612f      *(uint32_t*)((char*)result + 0xc) = 0x796910;
00556136      *(uint32_t*)((char*)result + 0x10) = 0;
00556139      *(uint32_t*)((char*)result + 0x14) = 0x3f800000;
0055613f      *(uint32_t*)((char*)result + 0x18) = 0;
00556142      *(uint32_t*)((char*)result + 0x1c) = 0;
00556145      *(uint32_t*)((char*)result + 0x20) = 0;
00556148      *(uint32_t*)((char*)result + 0x48) = 0;
0055614b      *(uint32_t*)((char*)result + 0x4c) = 0;
0055614e      *(uint32_t*)((char*)result + 0x50) = 0;
00556151      Frame::cache(((char*)result + 0x14));
00556156      *(uint32_t*)((char*)result + 0x54) = 0;
00556159      *(uint32_t*)((char*)result + 0x58) = 0;
00556159      
0055615e      if (*(uint32_t*)result != 0)
0055615e      {
00556160          *(uint32_t*)((char*)result + 4) = 0;
00556163          *(uint32_t*)((char*)result + 8) = 0;
00556166          *(uint32_t*)result = 0;
0055615e      }
0055615e      
0055616c      *(uint32_t*)result = arg1;
00556172      return result;
00556110  }

NOTE: allocation is 0x5c (92) bytes — sizeof(ConstraintManager). Field-offset mapping against the struct decl: +0x0=physics_obj, +0x4=is_constrained, +0x8=constraint_pos_offset, +0xc..0x50=constraint_pos (embedded Position, whose own objcell_id/frame subfields explain the 0x796910 vtable-like constant at +0xc and the Frame::cache call seeding the rotation quaternion identity w=1.0 i.e. 0x3f800000 at +0x14), +0x48=constraint_distance_start, +0x4c=constraint_distance_max. The trailing +0x54/+0x58 zeroing is past the declared struct fields in the header excerpt we have — likely padding/alignment or a field the header comment block truncated; not load-bearing for the port (all consumed fields — physics_obj, is_constrained, constraint_pos_offset, constraint_pos, constraint_distance_start, constraint_distance_max — are accounted for).

The odd if (*(uint32_t*)result != 0) { zero everything } right after construction reads as dead/defensive code from an inlined check that can't actually trigger here (the fields were all just zeroed above) — flagging as NOTE, not altering.

ConstraintManager::adjust_offset — 0x00556180

00556180  void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3)

00556180  {
00556186      class CPhysicsObj* physics_obj = this->physics_obj;
00556186      
0055618a      if (physics_obj != 0)
0055618a      {
00556190          int32_t is_constrained = this->is_constrained;
00556190          
00556195          if (is_constrained != 0)
00556195          {
005561a7              if ((physics_obj->transient_state & 1) != 0)
005561a7              {
005561a9                  long double x87_r7_1 = ((long double)this->constraint_pos_offset);
005561ac                  long double temp1_1 = ((long double)this->constraint_distance_max);
005561ac                  (x87_r7_1 - temp1_1);
005561af                  physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0))));
005561af                  
005561b4                  if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0)
005561b4                  {
005561e7                      long double x87_r7_2 = ((long double)this->constraint_pos_offset);
005561ea                      long double temp2_1 = ((long double)this->constraint_distance_start);
005561ea                      (x87_r7_2 - temp2_1);
005561ed                      physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0))));
005561ef                      bool p_1 = /* bool p_1 = unimplemented  {test ah, 0x41} */;
005561ef                      
005561f2                      if (p_1)
005561f2                      {
005561f7                          int32_t is_constrained_1 = is_constrained;
00556209                          Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start)))));
005561f2                      }
005561b4                  }
005561b4                  else
005561b4                  {
005561c2                      arg2->m_fOrigin.x = 0;
005561c2                      arg2->m_fOrigin.y = 0f;
005561c2                      arg2->m_fOrigin.z = 0f;
005561b4                  }
005561a7              }
005561a7              
0055620e              arg2->m_fOrigin;
00556211              arg2->m_fOrigin;
00556233              this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset)));
00556195          }
0055618a      }
00556180  }

NOTE (garbled bitfield mush + BN variable-reuse artifact): physics_obj gets reused as a scratch int32 to hold the packed x87 comparison-flags value at 005561af and again at 005561ed — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the CPhysicsObj* pointer. The this->physics_obj local captured at 00556186 is what's actually read at 005561a7 (physics_obj->transient_state) and 005561b4 (*(uint8_t*)((char*)physics_obj)[1] & 1 — this is checking a byte of transient_state again, offset +1, i.e. a second flag byte inside the same bitfield/word). Do not port the reused physics_obj int32 as if it becomes a different physics object; it's the same pointer, just overwritten as dead-value scratch space by the decompiler's register allocator view.

bool p_1 = /* unimplemented {test ah, 0x41} */ is the same x87-flags-in-AH pattern as IsFullyConstrained above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on encoding) — the classic jbe-vs-jae variant. Given the surrounding compare (constraint_pos_offset < constraint_distance_start or equal), and that the guarded body computes a lerp fraction (constraint_distance_max - constraint_pos_offset) / (constraint_distance_max - constraint_distance_start) applied to arg2->m_fOrigin via *=, the semantic read is: when the object has NOT yet reached (or has just reached) constraint_distance_start, scale the frame's offset delta by how far through the start→max leash band the object currently sits (a ramp/taper multiplier — presumably smoothly reducing how much of the requested frame delta gets applied as the leash tightens). test ah,0x41 semantically reads as "less-than-or-unordered-or-equal" (p_1 true when NOT clearly greater), so the ramp only applies while still inside the band; once past constraint_distance_start typical port intent should skip the scale (leave arg2->m_fOrigin alone) — consistent with the else branch two levels up which zeroes m_fOrigin outright when transient_state's second flag bit is clear.

Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check if the port's leash-taper feel diverges from retail), the function's shape is:

  1. No-op if no physics_obj or not is_constrained.
  2. If transient_state & 1 (some "active"/"in contact" style flag):
    • If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta arg2->m_fOrigin by a lerp fraction based on (max - pos_offset) / (max - start), gated by a comparison of pos_offset vs constraint_distance_start.
    • Else: zero the incoming frame delta entirely (fully clamp movement).
  3. Unconditionally (after the above), accumulate: constraint_pos_offset += arg2->m_fOrigin.x (note: only the .x component is added — arg2->m_fOrigin is read twice at 0055620e/00556211 with no visible effect, likely a debug/no-op dead read from the decompiler, or hints there's a per-component variant BN collapsed; only the final .x-add survived as an observable store).

ConstraintManager::ConstrainTo — 0x00556240

00556240  void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4)

00556240  {
00556248      this->is_constrained = 1;
00556259      this->constraint_pos.objcell_id = arg2->objcell_id;
0055625c      Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
00556271      this->constraint_distance_start = arg3;
00556274      this->constraint_distance_max = arg4;
0055627c      this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position));
00556240  }

Straightforward: pins the leash anchor (constraint_pos = copy of arg2's cell+frame), sets is_constrained = true, sets the start/max leash-band radii from arg3/arg4, and initializes constraint_pos_offset to the CURRENT distance from the anchor to the physics object's live position (Position::distance(arg2, &physics_obj->m_position)) — i.e. the leash starts already "extended" to wherever the object presently is relative to the constraint anchor, not to zero.


PositionManager-level seams (the actual public API — ConstraintManager is

lazily-created and private underneath)

ConstraintManager is never touched directly from outside PositionManager. PositionManager lazily creates it on first ConstrainTo call and forwards through it.

00555190  void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3)
00555190  {
00555191      int32_t ebx = arg3;
0055519d      class InterpolationManager* interpolation_manager = this->interpolation_manager;
005551a2      int32_t edi = *(uint32_t*)((char*)arg3)[4];
005551a2      
005551a6      if (interpolation_manager != 0)
005551a6      {
005551a8          int32_t var_14_1 = edi;
005551ab          InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx);
005551a6      }
005551a6      
005551b0      class StickyManager* sticky_manager = this->sticky_manager;
005551b0      
005551b5      if (sticky_manager != 0)
005551b5      {
005551b7          int32_t var_14_2 = edi;
005551ba          StickyManager::adjust_offset(sticky_manager, arg2, ebx);
005551b5      }
005551b5      
005551bf      class ConstraintManager* constraint_manager = this->constraint_manager;
005551bf      
005551c4      if (constraint_manager != 0)
005551c4      {
005551c6          int32_t var_14_3 = edi;
005551c9          ConstraintManager::adjust_offset(constraint_manager, arg2, ebx);
005551c4      }
00555190  }

Chains ALL THREE sub-managers' adjust_offset in a fixed order: InterpolationManagerStickyManagerConstraintManager, each optional (only called if that sub-manager exists). This is the per-frame(?) offset-adjustment dispatcher PositionManager uses to let interpolation/sticky/constraint all have a say in shaping a Frame delta before it's applied.

00555280  void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4)
00555280  {
00555288      if (this->constraint_manager == 0)
00555296          this->constraint_manager = ConstraintManager::Create(this->physics_obj);
00555296      
00555299      class ConstraintManager* constraint_manager = this->constraint_manager;
00555299      
0055529f      if (constraint_manager == 0)
005552a6          return;
005552a6      
005552a1      /* tailcall */
005552a1      return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4);
00555280  }

005552b0  void __fastcall PositionManager::UnConstrain(class PositionManager* this)
005552b0  {
005552b0      class ConstraintManager* constraint_manager = this->constraint_manager;
005552b0      
005552b5      if (constraint_manager == 0)
005552bc          return;
005552bc      
005552b7      /* tailcall */
005552b7      return ConstraintManager::UnConstrain(constraint_manager);
005552b0  }

005552c0  int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this)
005552c0  {
005552c0      class ConstraintManager* constraint_manager = this->constraint_manager;
005552c0      
005552c5      if (constraint_manager == 0)
005552ce          return 0;
005552ce      
005552c7      /* tailcall */
005552c7      return ConstraintManager::IsFullyConstrained(constraint_manager);
005552c0  }

PositionManager::Create (0x005552d0) wires a freshly-allocated PositionManager's physics_obj back-pointer into any already-non-null sub-managers (only relevant for copy/re-init paths since a fresh PositionManager starts with all-null sub-managers):

005552d0  class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)
005552d0  {
005552d3      void* result = operator new(0x10);
...
0055531d      class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8);
0055531d      
00555322      if (ecx_2 != 0)
00555325          ConstraintManager::SetPhysicsObject(ecx_2, arg1);
00555325      
0055532e      return result;
005552d0  }

PositionManager::Destroy (0x00555340) tears down and deletes all three sub-managers, ConstraintManager last:

00555340  void __fastcall PositionManager::Destroy(class PositionManager* this)
00555340  {
...
00555377      class ConstraintManager* constraint_manager = this->constraint_manager;
0055537c      this->sticky_manager = nullptr;
0055537c      
00555383      if (constraint_manager != 0)
00555383      {
00555387          ConstraintManager::~ConstraintManager(constraint_manager);
0055538d          operator delete(constraint_manager);
00555383      }
00555383      
00555396      this->constraint_manager = nullptr;
00555340  }

CPhysicsObj-level seams (public API callers actually use)

0050ec10  void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this)
0050ec10  {
0050ec16      if (this == CPhysicsObj::player_object)
0050ec16      {
0050ec18          this->m_position;
0050ec2d          return;
0050ec16      }
0050ec16      
0050ec35      this->m_position;
0050ec10  }

0050ebc0  void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this)
0050ebc0  {
0050ebc6      if (this == CPhysicsObj::player_object)
0050ebc6      {
0050ebc8          this->m_position;
0050ebdd          return;
0050ebc6      }
0050ebc6      
0050ebe5      this->m_position;
0050ebc0  }

NOTE (BN decompilation artifact / x87 return-value elision): both functions have void signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're called as ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2) immediately followed by (float)st0_6 casts at the call sites — an x87 FPU return value living in st0 that Binary Ninja failed to attach to the declared return type). Body-wise all we get is this->m_position; as a bare expression on both the player-branch and fallthrough paths — BN elided the actual field read/constant selection (this is likely this->m_position.something or a per-type constant lookup that got collapsed to a dead-looking statement). This function needs a live cdb read of st0 after the call, or a Ghidra re-decompile with a corrected float-return signature, to recover the actual values. Given the call-site pattern (constraining the player and other movers to a "home" position after teleport/movement-timeout in SmartBox::HandleReceivedPosition), the two functions almost certainly return small fixed-radius constants (a "start easing" radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case (hence the this == CPhysicsObj::player_object branch in both). Do not guess the literal values — flag as an open research item before porting numeric constants.

00510520  void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4)
00510520  {
00510523      CPhysicsObj::MakePositionManager(this);
00510528      class PositionManager* position_manager = this->position_manager;
00510528      
00510531      if (position_manager == 0)
00510538          return;
00510538      
00510533      /* tailcall */
00510533      return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4);
00510520  }

CPhysicsObj::ConstrainTo lazily ensures a PositionManager exists (MakePositionManager) then forwards. This is the entry point external code calls.

0050ec60  int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this)
0050ec60  {
0050ec60      class PositionManager* position_manager = this->position_manager;
0050ec60      
0050ec68      if (position_manager == 0)
0050ec71          return 0;
0050ec71      
0050ec6a      /* tailcall */
0050ec6a      return PositionManager::IsFullyConstrained(position_manager);
0050ec60  }

(Address correction noted at top of doc: this is 0x0050ec60, not the task-supplied 0x0050f730.)

There is no separate CPhysicsObj::UnConstrain — callers go straight to PositionManager::UnConstrain(this->position_manager) (see caller list below); only ConstrainTo and IsFullyConstrained got a CPhysicsObj-level convenience wrapper.


CALLERS — when does retail actually constrain an object?

1. SmartBox::HandleReceivedPosition (0x00453fd0) — THE constrain call site

All three live CPhysicsObj::ConstrainTo calls in the entire corpus are inside this one function, at three different branches of its position-update-reconciliation logic:

Branch A — non-player mover, after a successful move/teleport resolve (0x00454254):

0045414d      if (arg2 != this->player)
0045414d      {
00454254          if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0)
00454254          {
00454258              int32_t ecx_26;
00454258              ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045425d              int32_t var_68_14 = ecx_26;
00454263              ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2);
00454268              int32_t var_6c_9 = ecx_28;
00454272              CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6));
00454254          }
00454254          
00454254          return;
0045414d      }

For a non-player object (arg2), once MoveOrTeleport succeeds, it is constrained to its own current position (&arg2->m_position as the anchor) with start/max radii from GetStartConstraintDistance/GetMaxConstraintDistance.

Branch B — player, on a fresh TELEPORT timestamp event (0x0045415f):

0045415f      if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0)
0045415f      {
00454168          SmartBox::TeleportPlayer(this, &var_48);
0045416f          ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045417a          ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2);
0045418a          CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1));
0045418f          class CPhysicsObj* player_2 = this->player;
0045419c          int32_t var_54 = 0;  // zero vector
004541b4          CPhysicsObj::set_velocity(player_2, &var_54, 1);
004541c0          return;
0045415f      }

On a server teleport of the local player, SmartBox::TeleportPlayer snaps position, then the player is constrained to the newly-received server position (&var_48, the decoded incoming Position), and velocity is zeroed.

Branch C — player, fallthrough / non-teleport received-position path (0x004541c9):

004541c9      ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player);
004541d8      ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player);
004541ec      CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4));
004541f1      class CommandInterpreter* cmdinterp_1 = this->cmdinterp;
0045420a      if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0))
0045420a      {
...           CPhysicsObj::InterpolateTo(arg2, &var_48, ...);

Every OTHER received server position update for the local player (not a teleport-flagged event) ALSO constrains the player to the received position (&var_48), and then — depending on UsePositionFromServer/autonomy settings — may additionally kick off InterpolateTo. So the leash gets re-anchored on essentially every server position correction, whether or not interpolation is used to visually smooth toward it.

Summary for Branch A/B/C: retail constrains an object to a Position (self or server-received) with a start/max leash-band pair every time SmartBox processes an inbound position update for that object — this is the "rubber-band" leash mechanism that keeps the client's locally-simulated position from drifting too far from the server-authoritative position between updates. It's re-armed (re-ConstrainTo'd) on every inbound position packet, not set once.

2. CPhysicsObj::teleport_hook (0x00514ed0) — THE unconstrain call site

00514ed0  void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
00514ed0  {
00514ed3      class MovementManager* movement_manager = this->movement_manager;
00514edb      if (movement_manager != 0)
00514edb          MovementManager::CancelMoveTo(movement_manager, edx);
00514ee4      class PositionManager* position_manager = this->position_manager;
00514eec      if (position_manager != 0)
00514eee          PositionManager::UnStick(position_manager);
00514ef3      class PositionManager* position_manager_1 = this->position_manager;
00514efb      if (position_manager_1 != 0)
00514efd          PositionManager::StopInterpolating(position_manager_1);
00514f02      class PositionManager* position_manager_2 = this->position_manager;
00514f0a      if (position_manager_2 != 0)
00514f0c          PositionManager::UnConstrain(position_manager_2);
00514f11      class TargetManager* target_manager = this->target_manager;
00514f19      if (target_manager != 0)
00514f19      {
00514f1b          TargetManager::ClearTarget(target_manager);
00514f28          TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus);
00514f19      }
00514f31      CPhysicsObj::report_collision_end(this, 1);
00514ed0  }

The ONLY UnConstrain call in the corpus. teleport_hook is a general "this object just got relocated in a way that invalidates all continuity state" cleanup: it cancels any active MoveTo, un-sticks (StickyManager), stops interpolation, un-constrains, clears target tracking, and ends collision reporting. So the leash is torn down whenever an object teleports (any teleport, not just the player's) — makes sense, since a teleport by definition means "the position just legitimately jumped," so the anti-drift leash from the PREVIOUS anchor must be dropped rather than fight the teleport.

3. CMotionInterp::jump_is_allowed (0x005282b0) — THE read call site

005282b0  uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3)
005282b0  {
005282b8      if (this->physics_obj != 0)
005282b8      {
...
005282fd          if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
00528305              return 0x47;
00528305          
00528308          class LListData* head_ = this->pending_motions.head_;
...

jump_is_allowed reads IsFullyConstrained and, if true, immediately returns error code 0x47 (rejecting the jump attempt) before even checking pending motions / jump-charge state. This is the ONLY read-site of IsFullyConstrained in the corpus. Ties back to the mechanical read of ConstraintManager::IsFullyConstrained above: while the object is still within 90% of its max leash distance from the constraint anchor, it counts as "fully constrained" and jumping is blocked outright — i.e. you cannot jump while the client's simulated position is being actively rubber-banded back toward a server-received position inside the tight leash band. Only once you've drifted past 90% of the leash (or the leash has been dropped via UnConstrain/teleport) does the jump-blocking gate open.


CPhysicsObj-level "constrain" seam grep (exhaustive)

Full result of grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained(" across the corpus — every call site, no filtering:

93007   CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...)              [SmartBox::HandleReceivedPosition, Branch A]
93024   CPhysicsObj::ConstrainTo(arg2, &var_48, ...)                        [SmartBox::HandleReceivedPosition, Branch B]
93041   CPhysicsObj::ConstrainTo(this->player, &var_48, ...)                [SmartBox::HandleReceivedPosition, Branch C]
276520  CPhysicsObj::IsFullyConstrained (definition)
276529    -> PositionManager::IsFullyConstrained (tailcall)
278353  CPhysicsObj::ConstrainTo (definition)
278363    -> PositionManager::ConstrainTo (tailcall)
283140  PositionManager::UnConstrain(position_manager_2)                    [CPhysicsObj::teleport_hook]
305524  CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0             [CMotionInterp::jump_is_allowed]
352186  PositionManager::ConstrainTo (definition)
352198    -> ConstraintManager::ConstrainTo (tailcall)
352203  PositionManager::UnConstrain (definition)
352212    -> ConstraintManager::UnConstrain (tailcall)
352217  PositionManager::IsFullyConstrained (definition)
352226    -> ConstraintManager::IsFullyConstrained (tailcall)
353405  ConstraintManager::UnConstrain (definition)
353413  ConstraintManager::IsFullyConstrained (definition)
353528  ConstraintManager::ConstrainTo (definition)

No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain mechanism is used EXCLUSIVELY by:

  • SmartBox::HandleReceivedPosition to arm/re-arm the leash on inbound position updates (3 branches: self-anchor for remote movers, server-anchor for player teleport, server- anchor for player non-teleport updates), and
  • CPhysicsObj::teleport_hook to disarm it on any teleport, and
  • CMotionInterp::jump_is_allowed to read it as a jump-blocking gate.

This is a narrow, special-purpose "server position rubber-band leash," NOT a general physics constraint/joint system.