acdream/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-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

70 KiB
Raw Blame History

Retail decomp extract: PositionManager + StickyManager + CPhysicsObj sticky seams

Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Binary Ninja pseudo-C, Sept 2013 EoR build, PDB-named). Struct defs from docs/research/named-retail/acclient.h. All function bodies below are VERBATIM (unedited) except for the surrounding-context excerpts marked as such.


Struct definitions (acclient.h)

struct PositionManager (acclient.h:30952, type id 3468)

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

struct StickyManager (acclient.h:31518, type id 3466)

/* 3466 */
struct __cppobj StickyManager
{
  unsigned int target_id;
  float target_radius;
  Position target_position;
  CPhysicsObj *physics_obj;
  int initialized;
  long double sticky_timeout_time;
};

struct ConstraintManager (acclient.h:31529, type id 3467, for context — sibling manager)

/* 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;
};

NOTE: PositionManager is a small 4-pointer facade/dispatcher struct (0x10 bytes — matches PositionManager::Create's operator new(0x10)). It owns three independently lazily-created sub-managers: InterpolationManager (network position interpolation), StickyManager (parent-object stick/follow), ConstraintManager (radius-bounded tether). StickyManager itself is 0x60 bytes (StickyManager::Create's operator new(0x60)) and holds a single sticky target: id, radius, cached target position (a full Position, which embeds a Frame), back-pointer to owner CPhysicsObj, an initialized bool gating whether target_position is valid yet, and an x87 long double timeout timestamp (sticky_timeout_time) compared against Timer::cur_time.


PositionManager methods

PositionManager::UseTime — 0x00555160

00555160  void __fastcall PositionManager::UseTime(class PositionManager* this)

00555160  {
00555163      class InterpolationManager* interpolation_manager = this->interpolation_manager;
00555163      
00555167      if (interpolation_manager != 0)
00555169          InterpolationManager::UseTime(interpolation_manager);
00555169      
0055516e      class ConstraintManager* constraint_manager = this->constraint_manager;
0055516e      
00555173      if (constraint_manager != 0)
00555175          IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(constraint_manager);
00555175      
0055517a      class StickyManager* sticky_manager = this->sticky_manager;
0055517a      
00555180      if (sticky_manager == 0)
00555187          return;
00555187      
00555182      /* tailcall */
00555182      return StickyManager::UseTime(sticky_manager);
00555160  }

NOTE: the constraint_manager != 0 branch calls IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(constraint_manager) — this is almost certainly a Binary Ninja mis-decompilation / bad-symbol-resolution artifact (the destructor of an unrelated template ID class being called on a ConstraintManager* makes no structural sense). The real call is very likely ConstraintManager::UseTime (mirroring the InterpolationManager::UseTime / StickyManager::UseTime pattern on either side of it) — flagging for the lead rather than silently correcting.

PositionManager::adjust_offset — 0x00555190

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  }

NOTE: the int32_t edi = *(uint32_t*)((char*)arg3)[4]; line is BN garbling arg3 (a double passed as quantum/elapsed-time) — this looks like a decompiler artifact from the double being passed partly in a register pair; the actual semantic is simply "pass arg3 (the quantum) through to all three sub-manager adjust_offset calls unchanged." Each sub-manager gets the SAME Frame* (arg2, an in/out accumulator) and the SAME quantum. This mirrors UseTime's dispatch pattern: PositionManager is a pure fan-out facade over its three sub-managers, called once per tick.

PositionManager::UnStick — 0x005551e0

005551e0  void __fastcall PositionManager::UnStick(class PositionManager* this)

005551e0  {
005551e0      class StickyManager* sticky_manager = this->sticky_manager;
005551e0      
005551e5      if (sticky_manager == 0)
005551ec          return;
005551ec      
005551e7      /* tailcall */
005551e7      return StickyManager::UnStick(sticky_manager);
005551e0  }

PositionManager::InterpolateTo — 0x005551f0 (context — sibling method)

005551f0  void __thiscall PositionManager::InterpolateTo(class PositionManager* this, class Position const* arg2, int32_t arg3)

005551f0  {
005551f6      if (this->interpolation_manager == 0)
00555204          this->interpolation_manager = InterpolationManager::Create(this->physics_obj);
00555204      
00555212      InterpolationManager::InterpolateTo(this->interpolation_manager, arg2, arg3);
005551f0  }

PositionManager::StopInterpolating — 0x00555220 (context)

00555220  void __fastcall PositionManager::StopInterpolating(class PositionManager* this)

00555220  {
00555220      class InterpolationManager* interpolation_manager = this->interpolation_manager;
00555220      
00555224      if (interpolation_manager == 0)
0055522b          return;
0055522b      
00555226      /* tailcall */
00555226      return InterpolationManager::StopInterpolating(interpolation_manager);
00555220  }

PositionManager::StickTo — 0x00555230

00555230  void __thiscall PositionManager::StickTo(class PositionManager* this, uint32_t arg2, float arg3, float arg4)

00555230  {
00555238      if (this->sticky_manager == 0)
00555246          this->sticky_manager = StickyManager::Create(this->physics_obj);
00555246      
0055525b      StickyManager::StickTo(this->sticky_manager, arg2, arg3, arg4);
00555230  }

Lazy-creates the StickyManager on first stick, then delegates. arg2 = target object id, arg3 = radius, arg4 = height (per callers below).

PositionManager::GetStickyObjectID — 0x00555270 (context)

00555270  uint32_t __fastcall PositionManager::GetStickyObjectID(class PositionManager const* this)

00555270  {
00555270      class StickyManager* sticky_manager = this->sticky_manager;
00555270      
00555275      if (sticky_manager == 0)
0055527e          return 0;
0055527e      
00555277      /* tailcall */
00555277      return CommandList::GetHead(sticky_manager);
00555270  }

NOTE: CommandList::GetHead(sticky_manager) is an obviously wrong callee name for a tailcall that's supposed to read sticky_manager->target_id (a plain uint32_t at offset 0 of StickyManager). This looks like a BN symbol-resolution mixup (the real function returns this->sticky_manager->target_id directly — likely inlined/no separate symbol, and BN picked a spurious nearby symbol for the tailcall target). Flagging, not correcting.

PositionManager::ConstrainTo — 0x00555280 (context — sibling method)

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  }

PositionManager::UnConstrain — 0x005552b0 (context)

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  }

PositionManager::IsFullyConstrained — 0x005552c0

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 (ctor/factory)

005552d0  class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)

005552d0  {
005552d3      void* result = operator new(0x10);
005552d3      
005552df      if (result == 0)
00555332          return 0;
00555332      
005552e1      *(uint32_t*)result = 0;
005552e7      *(uint32_t*)((char*)result + 4) = 0;
005552ee      *(uint32_t*)((char*)result + 8) = 0;
005552f5      *(uint32_t*)((char*)result + 0xc) = 0;
005552fc      class QuickWindow* ecx = *(uint32_t*)result;
00555305      *(uint32_t*)((char*)result + 0xc) = arg1;
00555305      
00555308      if (ecx != 0)
0055530b          QuickWindow::SetWindowID(ecx, arg1);
0055530b      
00555310      class StickyManager* ecx_1 = *(uint32_t*)((char*)result + 4);
00555310      
00555315      if (ecx_1 != 0)
00555318          StickyManager::SetPhysicsObject(ecx_1, arg1);
00555318      
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  }

NOTE: QuickWindow* ecx = *(uint32_t*)result; / QuickWindow::SetWindowID(ecx, arg1) is another BN symbol-mixup artifact — ecx here is really the freshly-zeroed interpolation_manager field (offset 0, which was just set to 0 two lines above), so this branch is structurally dead at construction time (the null-check always fails right after operator new zeroes the block) and the "QuickWindow::SetWindowID" callee name is spurious. Read this constructor as: operator new(0x10) → zero all 4 fields (interpolation_manager, sticky_manager, constraint_manager @ offsets 0/4/8) → set physics_obj (offset 0xc) = arg1 → then three no-op "if member != 0, call member::SetPhysicsObject(arg1)" guards that are all unreachable immediately after the zero-fill (they'd only fire if a sub-manager pointer were already non-null, which can't happen on a freshly allocated struct). This is very likely how the retail source literally reads even though the guards are dead code at this call site — the same "if (member) member->SetPhysicsObject(this)" idiom recurs in StickyManager::Create.

PositionManager::Destroy — 0x00555340 (dtor helper)

00555340  void __fastcall PositionManager::Destroy(class PositionManager* this)

00555340  {
00555344      class InterpolationManager* interpolation_manager = this->interpolation_manager;
00555344      
00555348      if (interpolation_manager != 0)
00555348      {
0055534c          InterpolationManager::~InterpolationManager(interpolation_manager);
00555352          operator delete(interpolation_manager);
00555348      }
00555348      
0055535a      class StickyManager* sticky_manager = this->sticky_manager;
0055535f      this->interpolation_manager = 0;
0055535f      
00555365      if (sticky_manager != 0)
00555365      {
00555369          StickyManager::~StickyManager(sticky_manager);
0055536f          operator delete(sticky_manager);
00555365      }
00555365      
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  }

PositionManager::~PositionManager — 0x005553a0 (dtor, tailcalls Destroy)

005553a0  void __fastcall PositionManager::~PositionManager(class PositionManager* this)

005553a0  {
005553a0      /* tailcall */
005553a0      return PositionManager::Destroy(this);
005553a0  }

PositionManager::IsInterpolating — 0x005553b0 (context)

005553b0  int32_t __fastcall PositionManager::IsInterpolating(class PositionManager const* this)

005553b0  {
005553b0      class InterpolationManager* interpolation_manager = this->interpolation_manager;
005553b0      
005553b4      if (interpolation_manager == 0)
005553c4          return 0;
005553c4      
005553bc      int32_t result;
005553bc      result = interpolation_manager->position_queue.head_ != 0;
005553c1      return result;
005553b0  }

PositionManager::HandleUpdateTarget — 0x005553d0

005553d0  void __fastcall PositionManager::HandleUpdateTarget(class PositionManager* this, class TargetInfo arg2)

005553d0  {
005553d8      if (this->sticky_manager != 0)
005553d8      {
005553ea          void var_d4;
005553ea          TargetInfo::TargetInfo(&var_d4, &arg2);
005553f2          StickyManager::HandleUpdateTarget(this->sticky_manager, var_d4);
005553d8      }
005553d0  }

Only forwards to StickyManager (the InterpolationManager / ConstraintManager siblings don't care about target-info updates — only sticky-follow does, since it needs the live position of the object it's stuck to).


StickyManager methods

StickyManager::UnStick — 0x00555400

00555400  void __fastcall StickyManager::UnStick(class StickyManager* this)

00555400  {
00555406      if (this->target_id == 0)
00555427          return;
00555427      
00555408      class CPhysicsObj* physics_obj = this->physics_obj;
0055540b      this->target_id = 0;
00555411      this->initialized = 0;
00555418      CPhysicsObj::clear_target(physics_obj);
00555421      /* tailcall */
00555421      return CPhysicsObj::interrupt_current_movement(this->physics_obj);
00555400  }

No-op if not currently stuck. Otherwise: clear target_id + initialized, tell the owning CPhysicsObj to clear its target-tracking registration (CPhysicsObj::clear_target), then interrupt whatever movement is in flight (CPhysicsObj::interrupt_current_movement) — this is the standard "unstick invalidates in-progress movement" pattern repeated in every unstick path below (UseTime's timeout branch, StickTo's re-stick branch, HandleUpdateTarget's failure branch all do the exact same 4-line sequence).

StickyManager::adjust_offset — 0x00555430

00555430  void __thiscall StickyManager::adjust_offset(class StickyManager* this, class Frame* arg2, double arg3)

00555430  {
00555436      uint32_t target_id = this->target_id;
00555436      
00555445      if ((target_id != 0 && this->initialized != 0))
00555445      {
00555459          class Position* edi_2 = &this->physics_obj->m_position;
0055545c          class CPhysicsObj* eax = CPhysicsObj::GetObjectA(target_id);
00555466          class Position* ebp_1 = &eax->m_position;
00555466          
00555469          if (eax == 0)
0055546b              ebp_1 = &this->target_position;
0055546b          
00555476          int32_t __return;
00555476          class AC1Legacy::Vector3* eax_1 = Position::get_offset(edi_2, &__return, ebp_1);
00555456          arg2->m_fOrigin.x = eax_1->x;
00555456          arg2->m_fOrigin.y = eax_1->y;
00555456          arg2->m_fOrigin.z = eax_1->z;
00555495          class AC1Legacy::Vector3* eax_3 = Position::globaltolocalvec(edi_2, &__return, &arg2->m_fOrigin);
00555456          arg2->m_fOrigin.x = eax_3->x;
00555456          arg2->m_fOrigin.y = eax_3->y;
00555456          arg2->m_fOrigin.z = eax_3->z;
00555456          arg2->m_fOrigin.z = 0f;
005554b3          float target_radius = this->target_radius;
005554c1          int32_t var_34_1 = CPhysicsObj::GetRadius(this->physics_obj);
005554d5          long double x87_r0;
005554d5          float var_14_1 = ((float)(Position::cylinder_distance_no_z(((float)x87_r0), edi_2, target_radius, ebp_1) - ((long double)0.300000012f)));
005554d5          int16_t top_1 = 1;
005554d5          
005554e0          if (AC1Legacy::Vector3::normalize_check_small(&arg2->m_fOrigin) != 0)
005554e0          {
005554e2              __return = 0;
00555456              arg2->m_fOrigin.x = __return;
00555456              arg2->m_fOrigin.y = 0f;
00555456              arg2->m_fOrigin.z = 0f;
005554e0          }
005554e0          
00555522          class CMotionInterp* eax_7;
00555522          long double st0_2;
00555522          
00555522          if (CPhysicsObj::get_minterp(this->physics_obj) == 0)
0055553b              top_1 = 0;
00555522          else
0055552e              eax_7 = CMotionInterp::get_max_speed(CPhysicsObj::get_minterp(this->physics_obj));
0055553f          long double temp1_1 = ((long double)0.000199999995f);
0055553f          (/* unimplemented  {fcom st0, dword [&F_EPSILON]} f- temp1_1 */ - temp1_1);
0055553f          bool c0_1 = /* bool c0_1 = unimplemented  {fcom st0, dword [&F_EPSILON]} f< temp1_1 */ < temp1_1;
0055553f          bool c2_1 = (FCMP_UO(/* bool c2_1 = is_unordered.t(unimplemented  {fcom st0, dword [&F_EPSILON]}, temp1_1) */, temp1_1));
0055553f          bool c3_1 = /* bool c3_1 = unimplemented  {fcom st0, dword [&F_EPSILON]} f== temp1_1 */ == temp1_1;
00555545          eax_7 = ((((c0_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_1) ? 1 : 0) << 0xa) | ((((c3_1) ? 1 : 0) << 0xe) | ((top_1 & 7) << 0xb)))));
00555547          bool p_1 = /* bool p_1 = unimplemented  {test ah, 0x5} */;
00555547          
0055554a          if (!(p_1))
0055554a          {
0055554c              /* unimplemented  {fstp st0, st0} */;
0055554c              /* unimplemented  {fstp st0, st0} */;
0055554e              /* unimplemented  {fld st0, dword [&MAX_VELOCITY]} */;
0055554a          }
0055554a          
00555554          /* unimplemented  {fmul st0, qword [esp+0x2c]} */;
00555558          /* unimplemented  {fld st0, dword [esp+0x10]} */;
0055555c          /* unimplemented  {fabs } */;
0055555e          /* unimplemented  {fld st0, st1} */;
00555560          (/* unimplemented  {fcompp } f- unimplemented  {fcompp } */ - /* unimplemented  {fcompp } f- unimplemented  {fcompp } */);
00555560          bool c0_2 = /* bool c0_2 = unimplemented  {fcompp } f< unimplemented  {fcompp } */ < /* bool c0_2 = unimplemented  {fcompp } f< unimplemented  {fcompp } */;
00555560          bool c2_2 = (FCMP_UO(/* bool c2_2 = is_unordered.t(unimplemented  {fcompp }, unimplemented  {fcompp }) */, /* bool c2_2 = is_unordered.t(unimplemented  {fcompp }, unimplemented  {fcompp }) */));
00555560          bool c3_2 = /* bool c3_2 = unimplemented  {fcompp } f== unimplemented  {fcompp } */ == /* bool c3_2 = unimplemented  {fcompp } f== unimplemented  {fcompp } */;
00555560          /* unimplemented  {fcompp } */;
00555560          /* unimplemented  {fcompp } */;
00555562          eax_7 = ((((c0_2) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_2) ? 1 : 0) << 0xa) | ((((c3_2) ? 1 : 0) << 0xe) | ((top_1 & 7) << 0xb)))));
00555564          bool p_2 = /* bool p_2 = unimplemented  {test ah, 0x5} */;
00555567          int16_t top_9;
00555567          
00555567          if (p_2)
00555567          {
00555579              /* unimplemented  {fstp st0, st0} */;
00555579              /* unimplemented  {fstp st0, st0} */;
0055557b              /* unimplemented  {fld st0, dword [esp+0x10]} */;
0055557f              /* unimplemented  {fmul st0, dword [esi]} */;
00555456              arg2->m_fOrigin.x = ((float)/* arg2->m_fOrigin.x = fconvert.s(unimplemented  {fstp dword [esi], st0}) */);
00555581              /* unimplemented  {fstp dword [esi], st0} */;
00555583              /* unimplemented  {fld st0, dword [esp+0x10]} */;
00555587              /* unimplemented  {fmul st0, dword [esi+0x4]} */;
00555456              arg2->m_fOrigin.y = ((float)/* arg2->m_fOrigin.y = fconvert.s(unimplemented  {fstp dword [esi+0x4], st0}) */);
0055558a              /* unimplemented  {fstp dword [esi+0x4], st0} */;
0055558d              top_9 = top_1;
0055558d              /* unimplemented  {fld st0, dword [esp+0x10]} */;
00555567          }
00555567          else
00555567          {
00555569              /* unimplemented  {fld st0, st0} */;
0055556b              /* unimplemented  {fmul st0, dword [esi]} */;
00555456              arg2->m_fOrigin.x = ((float)/* arg2->m_fOrigin.x = fconvert.s(unimplemented  {fstp dword [esi], st0}) */);
0055556d              /* unimplemented  {fstp dword [esi], st0} */;
0055556f              /* unimplemented  {fld st0, st0} */;
00555571              /* unimplemented  {fmul st0, dword [esi+0x4]} */;
00555456              arg2->m_fOrigin.y = ((float)/* arg2->m_fOrigin.y = fconvert.s(unimplemented  {fstp dword [esi+0x4], st0}) */);
00555574              /* unimplemented  {fstp dword [esi+0x4], st0} */;
00555574              top_9 = top_1;
00555567          }
00555567          
00555591          /* unimplemented  {fmul st0, dword [esi+0x8]} */;
00555456          arg2->m_fOrigin.z = ((float)/* arg2->m_fOrigin.z = fconvert.s(unimplemented  {fstp dword [esi+0x8], st0}) */);
00555597          /* unimplemented  {fstp dword [esi+0x8], st0} */;
0055559a          Position::heading(edi_2, ebp_1);
005555a2          arg3 = ((float)/* arg3.d = fconvert.s(unimplemented  {fstp dword [esp+0x2c], st0}) */);
005555a2          /* unimplemented  {fstp dword [esp+0x2c], st0} */;
005555a6          Frame::get_heading(&edi_2->frame);
005555a6          /* unimplemented  {call Frame::get_heading} */;
005555ab          /* unimplemented  {fsubr st0, dword [esp+0x2c]} */;
005555b1          arg3 = ((float)/* arg3.d = fconvert.s(unimplemented  {fst dword [esp+0x24], st0}) */);
005555b6          /* unimplemented  {fabs } */;
005555b8          /* unimplemented  {fld st0, dword [&F_EPSILON]} */;
005555be          (/* unimplemented  {fcompp } f- unimplemented  {fcompp } */ - /* unimplemented  {fcompp } f- unimplemented  {fcompp } */);
005555be          bool c0_3 = /* bool c0_3 = unimplemented  {fcompp } f< unimplemented  {fcompp } */ < /* bool c0_3 = unimplemented  {fcompp } f< unimplemented  {fcompp } */;
005555be          bool c2_3 = (FCMP_UO(/* bool c2_3 = is_unordered.t(unimplemented  {fcompp }, unimplemented  {fcompp }) */, /* bool c2_3 = is_unordered.t(unimplemented  {fcompp }, unimplemented  {fcompp }) */));
005555be          bool c3_3 = /* bool c3_3 = unimplemented  {fcompp } f== unimplemented  {fcompp } */ == /* bool c3_3 = unimplemented  {fcompp } f== unimplemented  {fcompp } */;
005555be          /* unimplemented  {fcompp } */;
005555be          /* unimplemented  {fcompp } */;
005555c5          if ((*(uint8_t*)((char*)((((c0_3) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_3) ? 1 : 0) << 0xa) | ((((c3_3) ? 1 : 0) << 0xe) | (((top_9 + 3) & 7) << 0xb))))))[1] & 0x41) == 0)
005555c7              arg3 = 0;
005555c7          
005555cf          /* unimplemented  {fld st0, dword [&F_EPSILON]} */;
005555d5          /* unimplemented  {fchs } */;
005555d7          long double temp2_1 = ((long double)arg3);
005555d7          (/* unimplemented  {fcomp st0, dword [esp+0x20]} f- temp2_1 */ - temp2_1);
005555d7          bool c0_4 = /* bool c0_4 = unimplemented  {fcomp st0, dword [esp+0x20]} f< temp2_1 */ < temp2_1;
005555d7          bool c2_4 = (FCMP_UO(/* bool c2_4 = is_unordered.t(unimplemented  {fcomp st0, dword [esp+0x20]}, temp2_1) */, temp2_1));
005555d7          bool c3_4 = /* bool c3_4 = unimplemented  {fcomp st0, dword [esp+0x20]} f== temp2_1 */ == temp2_1;
005555d7          /* unimplemented  {fcomp st0, dword [esp+0x20]} */;
005555d7          
005555e0          if ((*(uint8_t*)((char*)((((c0_4) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_4) ? 1 : 0) << 0xa) | ((((c3_4) ? 1 : 0) << 0xe) | (((top_9 + 3) & 7) << 0xb))))))[1] & 0x41) == 0)
005555e0          {
005555e2              /* unimplemented  {fld st0, dword [esp+0x20]} */;
005555e6              /* unimplemented  {fadd dword [&data_79bc60]} */;
005555ec              arg3 = ((float)/* arg3.d = fconvert.s(unimplemented  {fstp dword [esp+0x20], st0}) */);
005555ec              /* unimplemented  {fstp dword [esp+0x20], st0} */;
005555e0          }
005555e0          
005555f9          Frame::set_heading(arg2, arg3);
00555445      }
00555430  }

NOTE (heavy x87 mush, flagging for lead decode — do not treat as porting-ready without decode): the whole back half of this function (from 005554d5 on) is a dense block of /* unimplemented */ x87 FPU stack operations (fcom/fcomp/fcompp/fabs/fchs/fld/fstp) that BN could not lift to structured expressions, interleaved with FCOM-condition-code decoding boilerplate (c0/c2/c3 bit extraction feeding a fake "ah test 0x45" parity check — this is the compiler's expansion of if (x < y) / if (fabs(x) < EPSILON) style float comparisons via fcomp + fnstsw ax + sahf, which BN failed to fold back into a plain comparison). Mechanically, best-effort reading of what's happening despite the mush:

  1. Early-out unless target_id != 0 && initialized != 0 (mirrors UnStick's guard).
  2. Resolve the live target object via CPhysicsObj::GetObjectA(target_id); if it's gone, fall back to the cached this->target_position (the last known position from HandleUpdateTarget).
  3. Compute Position::get_offset(my_position, &out, target_position) — the offset vector from self to target — store into arg2->m_fOrigin (the output Frame's origin, i.e. this becomes the STICK MOVEMENT DELTA for the frame).
  4. Convert that offset to local space via Position::globaltolocalvec, then FORCE .z = 0 — sticky movement is horizontal-only (no auto-adjust of vertical offset; presumably z is handled by normal physics/ground snap).
  5. Compute target_radius (cached) + CPhysicsObj::GetRadius(physics_obj) (own radius) feeding Position::cylinder_distance_no_z(...) minus a 0.3f constant — a horizontal cylinder-distance-to-target minus a 0.3-unit buffer, producing a "how far past the desired follow-distance are we" scalar (var_14_1).
  6. AC1Legacy::Vector3::normalize_check_small(&arg2->m_fOrigin) — normalizes the offset direction in place; if the vector was too small to normalize (near-zero), the offset is zeroed out entirely (no stick movement this tick — already at the target distance).
  7. The x87 mush (005554d5005555f9) computes a clamped speed-scaled offset: reads CMotionInterp::get_max_speed from the owning object's CMotionInterp (or falls back to a MAX_VELOCITY constant if no motion interpreter), clamps the per-tick offset magnitude by that speed, applies it to x/y (still z = 0), then separately computes a target HEADING via Position::heading(edi_2, ebp_1) (heading from self to target position) minus the object's current heading (Frame::get_heading), applies an epsilon-based snap-to-target-heading (small residual heading errors get zeroed; large ones get consumed additively — likely turning the stuck object to face the target it's following at a bounded turn rate, akin to a max-turn-rate clamp), and finally writes the resulting heading via Frame::set_heading(arg2, arg3).

Net semantic despite the mush: StickyManager::adjust_offset computes, for THIS tick's quantum, a bounded (speed-clamped, distance-buffered) horizontal position delta plus a bounded heading delta that steers the sticking object toward its target_id's position, writing both into the Frame* accumulator (arg2) that PositionManager::adjust_offset shares across all three sub-managers, which CPhysicsObj::UpdatePositionInternal then combines into this->m_position.frame via Frame::combine. This is a follow/leash steering behavior, not a hard position clamp — it moves gradually per-tick toward the target, speed- and turn-rate-limited.

StickyManager::UseTime — 0x00555610

00555610  void __fastcall StickyManager::UseTime(class StickyManager* this)

00555610  {
00555616      if (this->target_id != 0)
00555616      {
00555618          long double x87_r7_1 = ((long double)Timer::cur_time);
0055561e          long double temp0_1 = ((long double)this->sticky_timeout_time);
0055561e          (x87_r7_1 - temp0_1);
0055561e          
00555626          if ((*(uint8_t*)((char*)((((x87_r7_1 < temp0_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp0_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp0_1) ? 1 : 0) << 0xe) | 0)))))[1] & 0x41) == 0)
00555626          {
00555628              class CPhysicsObj* physics_obj = this->physics_obj;
0055562b              this->target_id = 0;
00555631              this->initialized = 0;
00555638              CPhysicsObj::clear_target(physics_obj);
00555641              /* tailcall */
00555641              return CPhysicsObj::interrupt_current_movement(this->physics_obj);
00555626          }
00555616      }
00555610  }

NOTE: same FCOM-condition-code mush pattern as adjust_offset, but decodable cleanly this time — the whole block is if (!(Timer::cur_time < sticky_timeout_time)) { unstick }, i.e. if (Timer::cur_time >= this->sticky_timeout_time) { UnStick-equivalent }. This is a per-tick sticky-TIMEOUT check: StickTo (below) sets sticky_timeout_time = Timer::cur_time + 1.0f at stick time, and every subsequent call to UseTime re-checks whether that 1-second deadline has passed; if so it force-clears the stick (same 4-step teardown as UnStick: clear target_id/initialized, CPhysicsObj::clear_target, CPhysicsObj::interrupt_current_movement). Since StickTo is the ONLY writer of sticky_timeout_time and it's always +1.0f from stick-time, and initialized is separately set by HandleUpdateTarget on receiving a fresh target position — this reads as "if we haven't heard a target-position update within 1 second of sticking, give up the stick." The deadline is NOT refreshed by HandleUpdateTarget in the code we pulled — worth double-checking against a wider window, but as extracted, the timeout is a one-shot "1 second grace period to get initialized" rather than a rolling heartbeat.

StickyManager::Destroy — 0x00555650

00555650  void __fastcall StickyManager::Destroy(class StickyManager* this)

00555650  {
00555659      if (this->target_id != 0)
0055565e          CPhysicsObj::clear_target(this->physics_obj);
0055565e      
00555667      this->target_id = 0;
0055566d      int32_t var_48 = 0x796910;
0055567d      int32_t var_40 = 0x3f800000;
00555685      int32_t var_3c = 0;
0055568d      int32_t var_38 = 0;
00555695      int32_t var_34 = 0;
0055569d      int32_t var_c = 0;
005556a5      int32_t var_8 = 0;
005556ad      int32_t var_4 = 0;
005556b5      Frame::cache(&var_40);
005556c6      this->target_position.objcell_id = 0;
005556c9      Frame::operator=(&this->target_position.frame, &var_40);
005556ce      this->initialized = 0;
00555650  }

Note this does NOT call interrupt_current_movement (unlike UnStick/UseTime's timeout branch/StickTo's re-stick branch/HandleUpdateTarget's failure branch) — Destroy just tears down state (clear target id, reset cached target_position to an identity Frame via Frame::cache, clear initialized) without touching movement. It's called from SetPhysicsObject (when re-parenting) and the destructor.

StickyManager::SetPhysicsObject — 0x005556e0

005556e0  void __thiscall StickyManager::SetPhysicsObject(class StickyManager* this, class CPhysicsObj* arg2)

005556e0  {
005556e8      if (this->physics_obj == 0)
005556e8      {
005556fe          this->physics_obj = arg2;
00555702          return;
005556e8      }
005556e8      
005556ea      StickyManager::Destroy(this);
005556f3      this->physics_obj = arg2;
005556e0  }

First-time set is a plain assignment; re-set (physics_obj already non-null) tears down any existing stick state first via Destroy, then reassigns.

StickyManager::StickTo — 0x00555710

00555710  void __thiscall StickyManager::StickTo(class StickyManager* this, uint32_t arg2, float arg3, float arg4)

00555710  {
00555716      if (this->target_id != 0)
00555716      {
00555718          class CPhysicsObj* physics_obj = this->physics_obj;
0055571b          this->target_id = 0;
00555721          this->initialized = 0;
00555728          CPhysicsObj::clear_target(physics_obj);
00555730          CPhysicsObj::interrupt_current_movement(this->physics_obj);
00555716      }
00555716      
00555749      this->target_radius = arg3;
0055574c      class CPhysicsObj* physics_obj_1 = this->physics_obj;
0055574f      this->target_id = arg2;
00555751      long double x87_r7_1 = (((long double)1f) + ((long double)Timer::cur_time));
0055575a      this->initialized = 0;
00555761      this->sticky_timeout_time = ((double)x87_r7_1);
00555771      CPhysicsObj::set_target(physics_obj_1, 0, arg2, 0.5f, ((double)((long double)0.5f)));
00555710  }

NOTE: arg4 (the 4th parameter, height, per callers) is READ NOWHERE in this function body as extracted — only arg2 (target id) and arg3 (radius) are used; target_radius = arg3 and the hardcoded set_target(..., 0.5f, 0.5) constants are used instead of arg4. Either height genuinely goes unused by StickTo itself (it's only consumed elsewhere, e.g. by PositionManager::StickTo's caller-side height computation in CPhysicsObj::stick_to_object, purely for logging/display), or BN dropped a store. Flagging — worth a targeted look at whether arg4 should feed the set_target call's 0.5f radius/height-tolerance constants.

If already stuck to something, first tears down the existing stick (same 4-step sequence as UnStick). Then: cache target_radius, set target_id = arg2, reset initialized = 0 (fresh stick — no cached target position yet, must wait for HandleUpdateTarget), set sticky_timeout_time = Timer::cur_time + 1.0 (the 1-second grace window UseTime checks), and call CPhysicsObj::set_target(physics_obj, 0, arg2, 0.5f, 0.5) — registers with the OWN physics object's target-tracking system (context id 0, target = arg2, with 0.5f/0.5 as some pair of radius/height-delta-tolerance constants) so that server/game updates about the target's position get routed back through HandleUpdateTarget.

StickyManager::HandleUpdateTarget — 0x00555780

00555780  void __thiscall StickyManager::HandleUpdateTarget(class StickyManager* this, class TargetInfo arg2)

00555780  {
00555783      uint32_t target_id = this->target_id;
00555783      
00555789      if (arg2.object_id == target_id)
00555789      {
00555799          if (arg2.status == Ok_TargetStatus)
00555799          {
0055579b              uint32_t objcell_id = arg2.target_position.objcell_id;
0055579f              this->initialized = 1;
005557aa              this->target_position.objcell_id = objcell_id;
005557ad              Frame::operator=(&this->target_position.frame, &arg2.target_position.frame);
005557b3              return;
00555799          }
00555799          
005557b8          if (target_id != 0)
005557b8          {
005557ba              class CPhysicsObj* physics_obj = this->physics_obj;
005557bd              this->target_id = 0;
005557c3              this->initialized = 0;
005557ca              CPhysicsObj::clear_target(physics_obj);
005557d2              CPhysicsObj::interrupt_current_movement(this->physics_obj);
005557b8          }
00555789      }
00555780  }

Only reacts if the incoming TargetInfo.object_id matches our current target_id (stale/mismatched updates from a previously-stuck target are ignored). On Ok_TargetStatus: cache the target's objcell_id + Frame into this->target_position and set initialized = 1 — this is what adjust_offset reads when the live CPhysicsObj::GetObjectA(target_id) lookup fails, and what gates UseTime's "haven't heard back within 1s" cleanup path since initialized starts false. On any OTHER status (target lost/out of range/error), tears down the stick with the same 4-step sequence.

StickyManager::~StickyManager — 0x005557e0 (dtor)

005557e0  void __fastcall StickyManager::~StickyManager(class StickyManager* this)

005557e0  {
005557e3      StickyManager::Destroy(this);
005557e8      this->target_position.vtable = 0x79285c;
005557e0  }

NOTE: this->target_position.vtable = 0x79285ctarget_position is a Position struct which embeds a Frame; the Frame type apparently starts with (or BN is interpreting an early field as) a vtable-looking pointer that gets reset to a static value at destruction (likely resetting an embedded Frame's internal vtable-like tag back to the base Frame type's identity, undoing any polymorphic Frame subtype). Kept verbatim per instructions.

StickyManager::Create — 0x00555800 (ctor/factory)

00555800  class StickyManager* StickyManager::Create(class CPhysicsObj* arg1)

00555800  {
00555804      void* result = operator new(0x60);
00555804      
00555812      if (result == 0)
00555866          return 0;
00555866      
00555814      *(uint32_t*)result = 0;
00555816      *(uint32_t*)((char*)result + 4) = 0;
0055581c      *(uint32_t*)((char*)result + 8) = 0x796910;
00555823      *(uint32_t*)((char*)result + 0xc) = 0;
00555826      *(uint32_t*)((char*)result + 0x10) = 0x3f800000;
0055582c      *(uint32_t*)((char*)result + 0x14) = 0;
0055582f      *(uint32_t*)((char*)result + 0x18) = 0;
00555832      *(uint32_t*)((char*)result + 0x1c) = 0;
00555835      *(uint32_t*)((char*)result + 0x44) = 0;
00555838      *(uint32_t*)((char*)result + 0x48) = 0;
0055583b      *(uint32_t*)((char*)result + 0x4c) = 0;
0055583e      Frame::cache(((char*)result + 0x10));
00555847      *(uint32_t*)((char*)result + 0x50) = 0;
0055584a      *(uint32_t*)((char*)result + 0x54) = 0;
0055585a      *(uint32_t*)((char*)result + 0x50) = arg1;
00555861      return result;
00555800  }

Allocates 0x60 bytes, zero-fills target_id (off 0) / target_radius (off 4), sets target_position.objcell_id (off 8) = 0x796910 — NOTE: this is almost certainly a vtable pointer for the embedded Frame/Position substructure rather than a literal objcell id (0x796910 recurs as a "vtable"-looking constant elsewhere in this file, e.g. StickyManager::~StickyManager's target_position.vtable = 0x79285c uses a DIFFERENT constant for a similarly-named field, and PositionManager::Destroy/MoveToObject_* functions stack-allocate local Frames with the same 0x796910 / 0x3f800000 pair as identity-transform sentinels). Frame::cache(result + 0x10) then initializes the embedded Frame (identity transform: the 0x3f800000 = 1.0f at offset 0x10 lines up with a scale/quaternion-w identity component) at the target_position.frame sub-offset. Finally physics_obj (off 0x50) = arg1, initialized (off 0x54, zeroed) stays 0, sticky_timeout_time (off 0x58, implicitly zero from the earlier ops covering 0x44-0x4c) stays 0. Net: constructs an unstuck, uninitialized StickyManager bound to arg1.


CPhysicsObj sticky/position seams

CPhysicsObj::MakePositionManager — 0x00510210

00510210  void __fastcall CPhysicsObj::MakePositionManager(class CPhysicsObj* this)

00510210  {
0051021b      if (this->position_manager == 0)
00510226          this->position_manager = PositionManager::Create(this);
00510226      
00510233      if ((this->state & 1) == 0)
00510233      {
00510235          uint32_t transient_state = this->transient_state;
00510235          
0051023d          if (transient_state >= 0)
0051023d          {
0051024b              this->update_time = (*(uint32_t*)Timer::cur_time);
00510251              *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4);
0051023d          }
0051023d          
0051025c          this->transient_state = (transient_state | 0x80);
00510233      }
00510210  }

Lazy factory: creates this->position_manager only if not already present (idempotent — safe to call unconditionally, which every caller below does). The second half (state & 1, transient_state, update_time stamping, transient_state |= 0x80) is NOT specific to PositionManager — it's the same "mark object as needing an update tick / stamp last-update-time" boilerplate that recurs verbatim in MakeMovementManager, MoveToObject_Internal, TurnToObject_Internal, MoveToObject, TurnToHeading (all seen in the extracted callers below) — i.e. lazily creating ANY manager also flags the object as active/dirty for the next tick's UpdateObjectInternal sweep. state & 1 is presumably an "already in the active/dirty set" bit being checked before re-flagging.

CPhysicsObj::get_position_manager — 0x00512130

00512130  class PositionManager* __fastcall CPhysicsObj::get_position_manager(class CPhysicsObj* this)

00512130  {
00512133      CPhysicsObj::MakePositionManager(this);
0051213f      return this->position_manager;
00512130  }

Accessor that guarantees lazy-creation before returning — callers never need their own null check.

CPhysicsObj::stick_to_object — 0x005127e0

005127e0  void __thiscall CPhysicsObj::stick_to_object(class CPhysicsObj* this, uint32_t arg2)

005127e0  {
005127e0      class CPhysicsObj* this_1 = this;
005127e4      CPhysicsObj::MakePositionManager(this);
005127e9      class CObjectMaint* CPhysicsObj::obj_maint_1 = CPhysicsObj::obj_maint;
005127e9      
005127f1      if (CPhysicsObj::obj_maint_1 != 0)
005127f1      {
005127f8          class CPhysicsObj* parent_2 = CObjectMaint::GetObjectA(CPhysicsObj::obj_maint_1, arg2);
005127f8          
005127ff          if (parent_2 != 0)
005127ff          {
00512802              class CPhysicsObj* parent_1 = parent_2;
00512804              class CPhysicsObj* parent = parent_2->parent;
00512804              
00512809              if (parent != 0)
0051280b                  parent_1 = parent;
0051280b              
0051280d              class CPartArray* part_array = parent_1->part_array;
0051280d              
00512812              if (part_array == 0)
0051281f                  arg2 = 0;
00512812              else
00512819                  arg2 = ((float)CPartArray::GetHeight(part_array));
00512819              
00512827              class CPartArray* part_array_1 = parent_1->part_array;
00512827              
0051282c              if (part_array_1 == 0)
00512839                  this_1 = nullptr;
0051282c              else
00512833                  this_1 = ((float)CPartArray::GetRadius(part_array_1));
00512833              
00512855              PositionManager::StickTo(this->position_manager, parent_1->id, this_1, arg2);
005127ff          }
005127f1      }
005127e0  }

arg2 comes in as a target object id, resolved via the global CObjectMaint (CObjectMaint::GetObjectA); if the resolved object has its OWN parent (e.g. it's a sub-part of a multi-part object like a wielded item), the stick target is redirected to the TOP-LEVEL parent instead (parent_1 = parent). Radius/height for the stick are pulled from that top-level parent's CPartArray (GetRadius/GetHeight) — this is where StickTo's arg3(radius)/arg4(height) parameters actually originate: geometry of the object being stuck to, not the sticking object itself. Finally calls PositionManager::StickTo(this->position_manager, parent_1->id, radius, height) — note it sticks to parent_1->id (the resolved top-level id), NOT the original arg2 id passed in.

CPhysicsObj::unstick_from_object — 0x0050eae0

0050eae0  void __fastcall CPhysicsObj::unstick_from_object(class CPhysicsObj* this)

0050eae0  {
0050eae0      class PositionManager* position_manager = this->position_manager;
0050eae0      
0050eae8      if (position_manager == 0)
0050eaef          return;
0050eaef      
0050eaea      /* tailcall */
0050eaea      return PositionManager::UnStick(position_manager);
0050eae0  }

Thin wrapper: null-safe forward to PositionManager::UnStick (which itself forwards to StickyManager::UnStick). Unlike MakePositionManager, this does NOT lazily create — if there's no position_manager yet, there's nothing to unstick.


Callers — where retail invokes these in the per-tick physics chain

CPhysicsObj::UpdatePositionInternal (0x00512c30) — calls PositionManager::adjust_offset

00512c30  void __thiscall CPhysicsObj::UpdatePositionInternal(class CPhysicsObj* this, float arg2, class Frame* arg3)

00512c30  {
00512c3c      int32_t var_40 = 0x3f800000;
00512c44      int32_t var_3c = 0;
00512c4c      int32_t var_38 = 0;
00512c54      int32_t var_34 = 0;
00512c5c      float var_c = 0f;
00512c64      float var_8 = 0f;
00512c6c      float var_4 = 0f;
00512c74      Frame::cache(&var_40);
00512c74      
00512c86      if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x40) == 0)
00512c86      {
00512c88          class CPartArray* part_array = this->part_array;
00512c88          
00512c8d          if (part_array != 0)
00512c95              CPartArray::Update(part_array, arg2, &var_40);
00512c95          
00512ca1          if ((this->transient_state & 2) == 0)
00512ca1          {
00512cd5              float var_c_2 = ((float)(((long double)var_c) * ((long double)0f)));
00512ce3              float var_8_2 = ((float)(((long double)var_8) * ((long double)0f)));
00512cf1              float var_4_2 = ((float)(((long double)var_4) * ((long double)0f)));
00512ca1          }
00512ca1          else
00512ca1          {
00512ca3              long double x87_r7_1 = ((long double)this->m_scale);
00512caf              float var_c_1 = ((float)(((long double)var_c) * x87_r7_1));
00512cb9              float var_8_1 = ((float)(((long double)var_8) * x87_r7_1));
00512cc3              float var_4_1 = ((float)(((long double)var_4) * x87_r7_1));
00512ca1          }
00512c86      }
00512c86      
00512cf5      class PositionManager* position_manager = this->position_manager;
00512cf5      
00512cfd      if (position_manager != 0)
00512cfd      {
00512d0a          float var_54;
00512d0a          var_54 = ((double)((long double)arg2));
00512d0e          PositionManager::adjust_offset(position_manager, &var_40, var_54);
00512cfd      }
00512cfd      
00512d22      Frame::combine(arg3, &this->m_position.frame, &var_40);
00512d22      
00512d30      if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x40) == 0)
00512d36          CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3);
00512d36      
00512d3d      CPhysicsObj::process_hooks(this);
00512c30  }

This is the per-tick chokepoint. arg2 is the frame's elapsed-time quantum. A local identity Frame (var_40, cached via Frame::cache) is built up: first CPartArray::Update(part_array, quantum, &var_40) (animation-driven part-array motion contributes to the frame delta), then — if a position_manager exists — PositionManager::adjust_offset(position_manager, &var_40, quantum) ADDS the sticky/interpolation/constraint contributions into the SAME var_40 accumulator (this is the call that fans out to StickyManager::adjust_offset, InterpolationManager::adjust_offset, ConstraintManager::adjust_offset in sequence). Finally Frame::combine(arg3, &this->m_position.frame, &var_40) composes the accumulated delta frame onto the object's actual position, producing the output frame arg3 (this is the frame that gets fed into UpdatePhysicsInternal next). So sticky steering literally competes/composes with animation-driven part-array movement in the SAME per-tick delta-frame before physics/collision resolves it.

CPhysicsObj::UpdateObjectInternal (0x005156b0) — calls PositionManager::UseTime, and transitively UpdatePositionInternal

Full function extracted (contains the UpdatePositionInternal call + the UseTime call later in the same tick):

005156b0  void __thiscall CPhysicsObj::UpdateObjectInternal(class CPhysicsObj* this, float arg2)

005156b0  {
005156b6      uint16_t transient_state = ((int16_t)this->transient_state);
005156b6      
005156bf      if (transient_state >= 0)
005156bf      {
005159b8      label_5159b8:
005159b8          class ParticleManager* particle_manager = this->particle_manager;
005159b8          
005159c0          if (particle_manager != 0)
005159c2              ParticleManager::UpdateParticles(particle_manager);
005159c2          
005159c7          class ScriptManager* script_manager = this->script_manager;
005159c7          
005159cc          if (script_manager != 0)
005159d0              ScriptManager::UpdateScripts(script_manager);
005156bf      }
005156bf      else if (this->cell != 0)
005156cf      {
005156d8          if ((*(uint8_t*)((char*)transient_state)[1] & 1) != 0)
005156de              CPhysicsObj::set_ethereal(this, 0, 0);
005156de          
005156e7          this->jumped_this_frame = 0;
005156ed          int32_t var_48 = 0x796910;
005156f5          int32_t var_44_1 = 0;
005156f9          int32_t var_40 = 0x3f800000;
00515701          int32_t var_3c_1 = 0;
00515709          int32_t var_38_1 = 0;
00515711          int32_t var_34_1 = 0;
00515719          float x = 0f;
00515721          int32_t var_8_1 = 0;
00515729          int32_t var_4_1 = 0;
00515731          Frame::cache(&var_40);
00515745          uint32_t objcell_id = this->m_position.objcell_id;
00515749          long double st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &var_40);
0051574e          class CPartArray* part_array = this->part_array;
00515753          uint32_t eax_1;
00515753          
00515753          if (part_array != 0)
00515755              eax_1 = CPartArray::GetNumSphere(part_array);
00515755          
0051575c          int32_t __return;
0051575c          
0051575c          if ((part_array != 0 && eax_1 != 0))
0051575c          {
005157ec              if (AC1Legacy::Vector3::operator==(&x, &this->m_position.frame.m_fOrigin) == 0)
005157ec              {
00515846                  uint32_t state = this->state;
00515846                  
0051584f                  if ((*(uint8_t*)((char*)state)[1] & 1) != 0)
0051584f                  {
0051585b                      AC1Legacy::Vector3::operator-(&x, &__return, &this->m_position.frame.m_fOrigin);
00515864                      Vector3::Normalize(&__return);
00515872                      Frame::set_vector_heading(&var_40, &__return);
0051584f                  }
0051584f                  else if (((state & "activation type (%s) with '%s' b…") != 0 && AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0))
0051587e                  {
00515898                      int32_t var_74_5 = AC1Legacy::Vector3::get_heading(&this->m_velocityVector);
005158a0                      Frame::set_heading(&var_40, ((float)st0_1));
0051587e                  }
0051587e                  
005158b2                  class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0);
005158b2                  
005158bb                  if (eax_10 == 0)
005158bb                  {
00515924                      x = this->m_position.frame.m_fOrigin.x;
0051592c                      float z_2 = this->m_position.frame.m_fOrigin.z;
00515933                      float y_2 = this->m_position.frame.m_fOrigin.y;
00515937                      CPhysicsObj::set_frame(this, &var_40);
0051593c                      __return = 0;
00515948                      this->cached_velocity.x = __return;
00515948                      this->cached_velocity.y = 0f;
00515948                      this->cached_velocity.z = 0f;
005158bb                  }
005158bb                  else
005158bb                  {
005158cb                      Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos);
005158de                      void __return_1;
005158de                      int32_t* eax_12 = Vector3::operator/(&__return, &__return_1, arg2);
005158e5                      float ecx_20 = eax_12[1];
005158e8                      __return = *(uint32_t*)eax_12;
005158ff                      this->cached_velocity.x = __return;
005158ff                      this->cached_velocity.y = ecx_20;
005158ff                      this->cached_velocity.z = eax_12[2];
00515914                      CPhysicsObj::SetPositionInternal(this, eax_10);
005158bb                  }
005157ec              }
005157ec              else
005157ec              {
005157f7                  x = this->m_position.frame.m_fOrigin.x;
00515802                  float y_1 = this->m_position.frame.m_fOrigin.y;
00515806                  float z_1 = this->m_position.frame.m_fOrigin.z;
0051580a                  CPhysicsObj::set_frame(this, &var_40);
0051580f                  __return = 0;
0051581b                  this->cached_velocity.x = __return;
0051581b                  this->cached_velocity.y = 0f;
0051581b                  this->cached_velocity.z = 0f;
005157ec              }
0051575c          }
0051575c          else
0051575c          {
00515764              if (this->movement_manager == 0)
00515764              {
00515766                  uint32_t transient_state_1 = this->transient_state;
00515766                  
0051576e                  if ((transient_state_1 & 2) != 0)
00515775                      this->transient_state = (transient_state_1 & 0xffffff7f);
00515764              }
00515764              
00515789              x = this->m_position.frame.m_fOrigin.x;
00515794              float y = this->m_position.frame.m_fOrigin.y;
00515798              float z = this->m_position.frame.m_fOrigin.z;
0051579c              CPhysicsObj::set_frame(this, &var_40);
005157a1              __return = 0;
005157ad              this->cached_velocity.x = __return;
005157ad              this->cached_velocity.y = 0f;
005157ad              this->cached_velocity.z = 0f;
005157ec          }
00515970          class DetectionManager* detection_manager = this->detection_manager;
00515970          
00515978          if (detection_manager != 0)
0051597a              DetectionManager::CheckDetection(detection_manager);
0051597a          
0051597f          class TargetManager* target_manager = this->target_manager;
0051597f          
00515987          if (target_manager != 0)
00515989              TargetManager::HandleTargetting(target_manager);
00515989          
0051598e          class MovementManager* movement_manager = this->movement_manager;
0051598e          
00515996          if (movement_manager != 0)
00515998              MovementManager::UseTime(movement_manager);
00515998          
0051599d          class CPartArray* part_array_1 = this->part_array;
0051599d          
005159a2          if (part_array_1 != 0)
005159a4              CPartArray::HandleMovement(part_array_1);
005159a4          
005159a9          class PositionManager* position_manager = this->position_manager;
005159a9          
005159b1          if (position_manager != 0)
005159b3              PositionManager::UseTime(position_manager);
005159b3          
00515753          goto label_5159b8;
005156cf      }
005156b0  }

Ordering within one tick (the else if (this->cell != 0) branch — the "object is in the world" path):

  1. CPhysicsObj::UpdatePositionInternal(this, arg2, &var_40) — builds the delta frame (part-array animation + PositionManager::adjust_offset sticky/interp/constraint contributions), returns a heading-ish scalar in st0_1.
  2. If the object has sphere-collision parts (part_array->GetNumSphere() != 0): compute a facing/heading update, run CPhysicsObj::transition(...) (collision/movement resolution against the delta frame), then either set_frame directly (transition failed/no-op) or SetPositionInternal with the transition's resolved sphere-path position (transition succeeded) — this is where the sticky-computed delta actually gets validated against collision before being committed.
  3. Else (no collision parts): just set_frame directly with the delta frame — no collision check.
  4. THEN: DetectionManager::CheckDetection, TargetManager::HandleTargetting, MovementManager::UseTime, CPartArray::HandleMovement, and finally PositionManager::UseTime(position_manager) — the sticky-timeout check runs LAST in the tick, AFTER the position has already been moved/collision-resolved for this frame. So a stick that times out this tick still got one more frame of steered movement + collision resolution before being cleared.

CPhysicsObj::HandleUpdateTarget (0x00512bc0) — calls PositionManager::HandleUpdateTarget

00512bc0  void __thiscall CPhysicsObj::HandleUpdateTarget(class CPhysicsObj* this, class TargetInfo arg2)

00512bc0  {
00512bc9      if (arg2.context_id == 0)
00512bc9      {
00512bd3          void var_d4;
00512bd3          
00512bd3          if (this->movement_manager != 0)
00512bd3          {
00512be5              TargetInfo::TargetInfo(&var_d4, &arg2);
00512bf0              MovementManager::HandleUpdateTarget(this->movement_manager, var_d4);
00512bd3          }
00512bd3          
00512bfd          if (this->position_manager != 0)
00512bfd          {
00512c0f              TargetInfo::TargetInfo(&var_d4, &arg2);
00512c1a              PositionManager::HandleUpdateTarget(this->position_manager, var_d4);
00512bfd          }
00512bc9      }
00512bc0  }

Top-level entry point for target-position updates arriving from elsewhere (server messages / target-tracking system), gated on context_id == 0 (context 0 presumably means "default/self" target tracking vs. some other numbered context). Fans the SAME TargetInfo out to BOTH MovementManager::HandleUpdateTarget (move-to logic) AND PositionManager::HandleUpdateTarget (sticky logic) if each manager exists. This is the producer for the StickyManager::HandleUpdateTarget consumer described above.

CPhysicsObj::exit_world (0x00514e60) — calls PositionManager::UnStick

00514e60  void __fastcall CPhysicsObj::exit_world(class CPhysicsObj* this)

00514e60  {
00514e63      class CPartArray* part_array = this->part_array;
00514e63      
00514e68      if (part_array != 0)
00514e6a          CPartArray::HandleExitWorld(part_array);
00514e6a      
00514e6f      class MovementManager* movement_manager = this->movement_manager;
00514e6f      
00514e77      if (movement_manager != 0)
00514e79          MovementManager::HandleExitWorld(movement_manager);
00514e79      
00514e7e      class PositionManager* position_manager = this->position_manager;
00514e7e      
00514e86      if (position_manager != 0)
00514e88          PositionManager::UnStick(position_manager);
00514e88      
00514e8d      class TargetManager* target_manager = this->target_manager;
00514e8d      
00514e95      if (target_manager != 0)
00514e95      {
00514e97          TargetManager::ClearTarget(target_manager);
00514ea4          TargetManager::NotifyVoyeurOfEvent(this->target_manager, ExitWorld_TargetStatus);
00514e95      }
00514e95      
00514ea9      class DetectionManager* detection_manager = this->detection_manager;
00514ea9      
00514eb1      if (detection_manager != 0)
00514eb5          DetectionManager::DestroyDetectionCylsphere(detection_manager, 0);
00514eb5      
00514ebe      CPhysicsObj::report_collision_end(this, 1);
00514e60  }

Object leaving the world (despawn/logout): unstick unconditionally (only UnStick, not full teardown — position_manager itself is kept alive).

CPhysicsObj::teleport_hook (0x00514ed0) — calls PositionManager::UnStick + StopInterpolating + UnConstrain

00514ed0  void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)

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

Teleport is the ONE place all three PositionManager sub-behaviors get explicitly torn down together (cancel any move-to, unstick, stop interpolating, unconstrain) — makes sense: after a teleport, none of the three "gradually approach some reference" behaviors should still be steering toward a pre-teleport reference frame.

MovementManager::unpack_movement (0x00524440) — calls CPhysicsObj::unstick_from_object

Context excerpt (full function is long; showing the relevant unstick call site inside the inbound-motion-unpacking case-0 branch):

00524440  int32_t __thiscall MovementManager::unpack_movement(class MovementManager* this, void** arg2, uint32_t arg3)

00524440  {
0052444f      if (this->motion_interpreter != 0)
0052444f      {
00524455          class CPhysicsObj* physics_obj = this->physics_obj;
00524455          
0052445a          if (physics_obj != 0)
0052445a          {
00524460              CPhysicsObj::interrupt_current_movement(physics_obj);
00524468              CPhysicsObj::unstick_from_object(this->physics_obj);
...
00524551                  case 0:
00524551                  {
00524551                      InterpretedMotionState::UnPack(&var_28, arg2, arg3);
...
0052457c                      MovementManager::move_to_interpreted_state(this, &var_28);
0052457c                      
00524583                      if (ebx_3 != 0)
00524589                          CPhysicsObj::stick_to_object(this->physics_obj, ebx_3);
00524589                      
0052458e                      this->motion_interpreter->standing_longjump = (ebp_1 & 0x200);
...

Two things happen in unpack_movement: (1) unconditionally at the top of the whole function, EVERY inbound motion packet interrupts current movement AND unsticks (unstick_from_object) before any of the packet's specific motion state is applied — i.e. any new motion command from the network clears a prior stick; (2) later, inside case 0 (one specific unpacked-motion sub-case), if the unpacked state included a sticky-target object id (ebx_3, read conditionally from the packed stream when a state flag bit is set), stick_to_object is called to establish a NEW stick to that id. This is the network-driven "server told the client to stick this object to another object" path (e.g. mounting, carrying, or similar attach behaviors).

CMotionInterp::MotionDone (0x00527ec0) — calls CPhysicsObj::unstick_from_object

00527ec0  void __fastcall CMotionInterp::MotionDone(class CMotionInterp* this, int32_t arg2)

00527ec0  {
00527ec3      class CPhysicsObj* physics_obj = this->physics_obj;
00527ec3      
00527ec8      if (physics_obj != 0)
00527ec8      {
00527eca          class LListData* head_ = this->pending_motions.head_;
00527eca          
00527ed2          if (head_ != 0)
00527ed2          {
00527edb              if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0)
00527edb              {
00527edd                  CPhysicsObj::unstick_from_object(physics_obj);
00527ee5                  InterpretedMotionState::RemoveAction(&this->interpreted_state);
00527eed                  RawMotionState::RemoveAction(&this->raw_state);
00527edb              }
00527edb              
00527ef2              class LListData* head__1 = this->pending_motions.head_;
00527ef2              
00527efa              if (head__1 != 0)
00527efa              {
00527efc                  class LListData* llist_next = head__1->llist_next;
00527f00                  this->pending_motions.head_ = llist_next;
00527f00                  
00527f06                  if (llist_next == 0)
00527f08                      this->pending_motions.tail_ = llist_next;
00527f08                  
00527f0f                  head__1->llist_next = 0;
00527f15                  operator delete(head__1);
00527efa              }
00527ed2          }
00527ec8      }
00527ec0  }

And its sibling CMotionInterp::HandleExitWorld (0x00527f30) has the identical unstick-on-flag-bit pattern (queue-head motion's flag 0x10000000 set → unstick) when draining pending_motions on exit-world. The 0x10000000 bit on a pending motion's flags field marks "this motion action implies/requires a stick, so completing or force-flushing it must unstick." Consistent with unpack_movement's pattern: sticks are tied to the lifecycle of a specific motion action, not held independently.

MoveToManager::PerformMovement (0x0052a900) — calls CPhysicsObj::unstick_from_object

0052a900  uint32_t __thiscall MoveToManager::PerformMovement(class MoveToManager* this, class MovementStruct const* arg2)

0052a900  {
0052a901      int32_t var_8 = 0x36;
0052a905      uint32_t edx;
0052a905      MoveToManager::CancelMoveTo(this, edx);
0052a910      CPhysicsObj::unstick_from_object(this->physics_obj);
0052a910      
0052a923      switch ((arg2->type - 6))
0052a923      {
0052a940          case 0:
0052a940          {
0052a940              MoveToManager::MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params);
0052a940              break;
0052a955          case 1:
0052a955          {
0052a955              MoveToManager::MoveToPosition(this, &arg2->pos, arg2->params);
0052a955              break;
...

Every new MoveToManager movement command starts by cancelling any in-flight move-to AND unsticking — same "new movement intent clears prior stick" pattern as unpack_movement.

MoveToManager::BeginNextNode (0x00529cb0) — calls PositionManager::StickTo via get_position_manager

00529ccb          return;
00529cbe      }
00529cbe      
00529ce5      head_ = *(uint8_t*)((char*)this->movement_params.__inner0 + 0);
00529ce5      
00529ced      if (head_ < 0)
00529ced      {
00529cef          float sought_object_height = this->sought_object_height;
00529cf5          float sought_object_radius = this->sought_object_radius;
00529d00          uint32_t top_level_object_id = this->top_level_object_id;
00529d0c          int32_t edx_3 = MoveToManager::CleanUp(this);
00529d11          class CPhysicsObj* physics_obj = this->physics_obj;
00529d11          
00529d19          if (physics_obj != 0)
00529d19          {
00529d1b              int32_t var_14_1 = 0;
00529d1d              CPhysicsObj::StopCompletely(physics_obj, edx_3);
00529d19          }
00529d19          
00529d3a          PositionManager::StickTo(CPhysicsObj::get_position_manager(this->physics_obj), top_level_object_id, sought_object_radius, sought_object_height);
00529d44          return;
00529ced      }

This is the OTHER stick-establishment path, distinct from stick_to_object: when a MoveToManager move-to-object node completes and a "sticky" flag is set in movement_params (the head_ < 0 branch — a sign-bit check on a packed byte field, likely a CanStick/Sticky movement-parameter bit), the mover: cleans up the move-to state, stops movement completely (CPhysicsObj::StopCompletely), then sticks to top_level_object_id (the object it was moving toward) using the CACHED sought_object_radius/sought_object_height (computed earlier when the move-to node was set up, not re-derived from CPartArray like stick_to_object does). This is the "arrived at destination object, now hold position relative to it" transition — e.g. finishing a move-to-object command that has a stick-on-arrival semantic.


Files referenced

  • docs/research/named-retail/acclient_2013_pseudo_c.txt lines 352066352644 (PositionManager + StickyManager method bodies, contiguous block)
  • docs/research/named-retail/acclient_2013_pseudo_c.txt lines 276403276426, 278204278222, 278344278364, 280236280241, 280559280595, 280794280866, 283079283151, 283611283757 (CPhysicsObj seams + UpdatePositionInternal/UpdateObjectInternal per-tick chain)
  • docs/research/named-retail/acclient_2013_pseudo_c.txt lines 300563300621 (MovementManager::unpack_movement)
  • docs/research/named-retail/acclient_2013_pseudo_c.txt lines 305238305271 (CMotionInterp::MotionDone)
  • docs/research/named-retail/acclient_2013_pseudo_c.txt lines 307123307161 (MoveToManager::BeginNextNode)
  • docs/research/named-retail/acclient_2013_pseudo_c.txt lines 307871307878 (MoveToManager::PerformMovement)
  • docs/research/named-retail/acclient.h lines 3095230958 (struct PositionManager)
  • docs/research/named-retail/acclient.h lines 3151831526 (struct StickyManager)
  • docs/research/named-retail/acclient.h lines 3152931537 (struct ConstraintManager, context)