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

54 KiB

TargetManager (voyeur-subscription system) — retail decomp extract

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

Struct definitions (acclient.h)

enum TargetStatus (acclient.h:7264)

enum TargetStatus
{
  Undef_TargetStatus = 0x0,
  Ok_TargetStatus = 0x1,
  ExitWorld_TargetStatus = 0x2,
  Teleported_TargetStatus = 0x3,
  Contained_TargetStatus = 0x4,
  Parented_TargetStatus = 0x5,
  TimedOut_TargetStatus = 0x6,
  FORCE_TargetStatus_32_BIT = 0x7FFFFFFF,
};

struct TargetManager (acclient.h:31024, id 3484)

struct __cppobj TargetManager
{
  CPhysicsObj *physobj;
  TargetInfo *target_info;
  LongNIHash<TargettedVoyeurInfo> *voyeur_table;
  long double last_update_time;
};

struct TargetInfo (acclient.h:31591)

struct __cppobj TargetInfo
{
  unsigned int context_id;
  unsigned int object_id;
  float radius;
  long double quantum;
  Position target_position;
  Position interpolated_position;
  AC1Legacy::Vector3 interpolated_heading;
  AC1Legacy::Vector3 velocity;
  TargetStatus status;
  long double last_update_time;
};

struct TargettedVoyeurInfo (acclient.h:52807, id 5801, 8-byte aligned)

struct __cppobj __declspec(align(8)) TargettedVoyeurInfo
{
  unsigned int object_id;
  long double quantum;
  float radius;
  Position last_sent_position;
};

struct LongNIHash<TargettedVoyeurInfo> (acclient.h:31606, id 3483)

struct __cppobj LongNIHash<TargettedVoyeurInfo>
{
  LongNIHashData **buckets;
  int table_size;
};

struct LongNIHashIter<TargettedVoyeurInfo> (acclient.h:57648)

struct __cppobj LongNIHashIter<TargettedVoyeurInfo>
{
  LongNIHash<TargettedVoyeurInfo> *hash;
  /* + bucketNo, curDat, fDone per the operator++ body below */
};

NOTE: field layout for LongNIHashIter beyond hash isn't in the struct dump captured, but operator++'s pseudo-C shows bucketNo, curDat, fDone members (see below).


Functions

TargetManager::TargetManager (ctor) — 0051a370

0051a370  void __thiscall TargetManager::TargetManager(class TargetManager* this, class CPhysicsObj* arg2)

0051a370  {
0051a376      this->physobj = arg2;
0051a37a      this->target_info = nullptr;
0051a37d      this->voyeur_table = nullptr;
0051a380      this->last_update_time = 0f;
0051a383      *(uint32_t*)((char*)this->last_update_time)[4] = 0;
0051a370  }

NOTE: *(uint32_t*)((char*)this->last_update_time)[4] = 0 is BN's mangled way of zeroing the high dword of the 80-bit long double last_update_time field (two-dword store split across a long double). Same pattern recurs throughout this file for every long double field/parameter assignment — not a bug, just how BN represents x87 80-bit extended stores split into a 32-bit low/high pair. Flagging once here; not re-flagging every occurrence below.

TargetInfo::TargetInfo (ctor, bonus — used pervasively by callers) — 0051a420

0051a420  void __fastcall TargetInfo::TargetInfo(class TargetInfo* this)

0051a420  {
0051a429      this->context_id = 0;
0051a42b      this->object_id = 0;
0051a42e      this->radius = 0f;
0051a431      this->quantum = 0f;
0051a434      *(uint32_t*)((char*)this->quantum)[4] = 0;
0051a437      this->target_position.vtable = 0x796910;
0051a43e      this->target_position.objcell_id = 0;
0051a423      this->target_position.frame.qw = 0x3f800000;
0051a423      this->target_position.frame.qx = 0f;
0051a423      this->target_position.frame.qy = 0f;
0051a423      this->target_position.frame.qz = 0f;
0051a423      this->target_position.frame.m_fOrigin.x = 0;
0051a423      this->target_position.frame.m_fOrigin.y = 0f;
0051a423      this->target_position.frame.m_fOrigin.z = 0f;
0051a459      Frame::cache(&this->target_position.frame);
0051a461      this->interpolated_position.vtable = 0x796910;
0051a468      this->interpolated_position.objcell_id = 0;
0051a45e      this->interpolated_position.frame.qw = 0x3f800000;
0051a45e      this->interpolated_position.frame.qx = 0f;
0051a45e      this->interpolated_position.frame.qy = 0f;
0051a45e      this->interpolated_position.frame.qz = 0f;
0051a45e      this->interpolated_position.frame.m_fOrigin.x = 0;
0051a45e      this->interpolated_position.frame.m_fOrigin.y = 0f;
0051a45e      this->interpolated_position.frame.m_fOrigin.z = 0f;
0051a483      Frame::cache(&this->interpolated_position.frame);
0051a488      this->status = Undef_TargetStatus;
0051a48e      this->last_update_time = 0f;
0051a494      *(uint32_t*)((char*)this->last_update_time)[4] = 0;
0051a420  }

Default-constructs both Position members to identity quaternion at origin, status = Undef_TargetStatus, everything else zeroed. velocity/interpolated_heading are NOT explicitly zeroed here (they're POD AC1Legacy::Vector3 — likely left uninitialized by this particular ctor overload, or zeroed by a base/member ctor not captured in this pseudo-C region).

TargetManager::SetTargetQuantum — 0051a4a0

0051a4a0  void __thiscall TargetManager::SetTargetQuantum(class TargetManager* this, double arg2)

0051a4a0  {
0051a4a3      class TargetInfo* target_info = this->target_info;
0051a4a3      
0051a4a8      if (target_info != 0)
0051a4a8      {
0051a4b2          target_info->quantum = arg2;
0051a4b5          *(uint32_t*)((char*)target_info->quantum)[4] = *(uint32_t*)((char*)arg2)[4];
0051a4bf          class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(this->target_info->object_id);
0051a4bf          
0051a4c9          if (eax_1 != 0)
0051a4c9          {
0051a4cb              class TargetInfo* target_info_1 = this->target_info;
0051a4e6              CPhysicsObj::add_voyeur(eax_1, this->physobj->id, ((float)((long double)target_info_1->radius)), ((float)((long double)target_info_1->quantum)));
0051a4c9          }
0051a4a8      }
0051a4a0  }

Only acts if we currently HAVE a target (target_info != 0). Updates our own quantum (the resend interval), then re-registers ourselves as a voyeur of the target object with the new quantum — i.e. quantum changes propagate by re-adding the voyeur subscription (not a separate "update quantum" RPC on the remote side).

TargetManager::SendVoyeurUpdate — 0051a4f0

0051a4f0  void __thiscall TargetManager::SendVoyeurUpdate(class TargetManager* this, class TargettedVoyeurInfo* arg2, class Position const* arg3, enum TargetStatus arg4)

0051a4f0  {
0051a503      int32_t* esi = arg2;
0051a514      esi[6] = arg3->objcell_id;
0051a517      Frame::operator=(&esi[7], &arg3->frame);
0051a520      int32_t var_d0;
0051a520      TargetInfo::TargetInfo(&var_d0);
0051a525      class CPhysicsObj* physobj = this->physobj;
0051a527      int32_t eax_1 = esi[2];
0051a52a      int32_t edx = esi[4];
0051a52d      var_d0 = 0;
0051a538      uint32_t id = physobj->id;
0051a53f      int32_t var_c0 = eax_1;
0051a543      int32_t var_bc = esi[3];
0051a547      int32_t var_c8 = edx;
0051a556      uint32_t objcell_id = physobj->m_position.objcell_id;
0051a55a      void var_b0;
0051a55a      Frame::operator=(&var_b0, &physobj->m_position.frame);
0051a562      uint32_t objcell_id_1 = arg3->objcell_id;
0051a571      void var_68;
0051a571      Frame::operator=(&var_68, &arg3->frame);
0051a57d      void __return;
0051a57d      class AC1Legacy::Vector3* eax_3 = CPhysicsObj::get_velocity(physobj, &__return);
0051a586      float x = eax_3->x;
0051a597      float y = eax_3->y;
0051a5a2      float z = eax_3->z;
0051a5a9      enum TargetStatus var_10 = arg4;
0051a5b0      class CPhysicsObj* eax_5 = CPhysicsObj::GetObjectA(*(uint32_t*)esi);
0051a5b0      
0051a5be      if (eax_5 != 0)
0051a5c7          CPhysicsObj::receive_target_update(eax_5, &var_d0);
0051a4f0  }

Mechanically: (1) stashes arg3 (the caller-computed interpolated position) into the voyeur record's last_sent_position field (esi[6] = objcell_id, esi[7..] = frame — matches TargettedVoyeurInfo::last_sent_position being the 3rd/4th field after object_id/quantum/radius); (2) builds a fresh TargetInfo on the stack (var_d0) populated with: our own context_id? (actually esi[2]/esi[3]/esi[4] = voyeur's own quantum/radius fields per struct layout — field-name binding here is approximate since BN shows raw offsets, not member names), our physobj's id as object_id, our physobj's CURRENT position (not the interpolated arg3!) as target_position, arg3's position as interpolated_position, our CURRENT velocity, and arg4 as status; (3) looks up the voyeur's object_id (*(uint32_t*)esi, i.e. esi[0]) via CPhysicsObj::GetObjectA and if resolved, tail-calls CPhysicsObj::receive_target_update on THAT object — delivering the update directly into the voyeur's own TargetManager::ReceiveUpdate.

NOTE (field-offset ambiguity): the exact mapping of esi[2]/esi[3]/esi[4] into var_c0/var_bc/var_c8 (populating fields of the constructed TargetInfo) is inferred from struct order, not verified field-by-field — BN shows raw stack offsets (var_c0, var_bc, var_c8) copied from raw struct offsets (esi[2], esi[3], esi[4]) with no field names attached. Cross-check against TargettedVoyeurInfo layout (object_id@0, quantum@4 (long double, 12 bytes on this compiler?), radius@0x10, last_sent_position@0x14) before porting exact field semantics.

TargetManager::GetInterpolatedPosition — 0051a5e0

0051a5e0  void __thiscall TargetManager::GetInterpolatedPosition(class TargetManager* this, double arg2, class Position* arg3)

0051a5e0  {
0051a5e4      class Position* esi = arg3;
0051a5eb      class CPhysicsObj* physobj = this->physobj;
0051a5f6      esi->objcell_id = physobj->m_position.objcell_id;
0051a5fd      Frame::operator=(&esi->frame, &physobj->m_position.frame);
0051a608      arg3 = ((float)((long double)arg2));
0051a611      void __return;
0051a611      class AC1Legacy::Vector3* eax_2 = CPhysicsObj::get_velocity(this->physobj, &__return);
0051a634      esi->frame.m_fOrigin.x = ((float)((((long double)arg3) * ((long double)eax_2->x)) + ((long double)esi->frame.m_fOrigin.x)));
0051a63a      esi->frame.m_fOrigin.y = ((float)((((long double)arg3) * ((long double)eax_2->y)) + ((long double)esi->frame.m_fOrigin.y)));
0051a644      esi->frame.m_fOrigin.z = ((float)(((long double)((float)(((long double)arg3) * ((long double)eax_2->z)))) + ((long double)esi->frame.m_fOrigin.z)));
0051a5e0  }

Straightforward dead-reckoning extrapolation: out = physobj.position, then out.origin += arg2 (a time-delta / quantum, as double) * velocity per-axis. This is our own physobj's position extrapolated forward by arg2 seconds using our own current velocity — used to predict where WE will be by the time a voyeur update actually lands, so the voyeur update carries a "where I'll be at quantum-from-now" position rather than "where I am right now."

TargetManager::CheckAndUpdateVoyeur — 0051a650

0051a650  void __thiscall TargetManager::CheckAndUpdateVoyeur(class TargetManager* this, class TargettedVoyeurInfo* arg2)

0051a650  {
0051a65b      int32_t var_48 = 0x796910;
0051a663      int32_t var_44 = 0;
0051a66b      int32_t var_40 = 0x3f800000;
0051a673      int32_t var_3c = 0;
0051a67b      int32_t var_38 = 0;
0051a683      int32_t var_34 = 0;
0051a68b      int32_t var_c = 0;
0051a693      int32_t var_8 = 0;
0051a69b      int32_t var_4 = 0;
0051a6a3      Frame::cache(&var_40);
0051a6b7      int32_t var_58 = *(uint32_t*)((char*)arg2->quantum)[4];
0051a6bb      TargetManager::GetInterpolatedPosition(this, arg2->quantum, &var_48);
0051a6c8      long double st0 = Position::distance(&var_48, &arg2->last_sent_position);
0051a6cd      long double temp1 = ((long double)arg2->radius);
0051a6cd      (st0 - temp1);
0051a6cd      
0051a6d5      if ((*(uint8_t*)((char*)((((st0 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(st0, temp1))) ? 1 : 0) << 0xa) | ((((st0 == temp1) ? 1 : 0) << 0xe) | 0)))))[1] & 0x41) == 0)
0051a6e1          TargetManager::SendVoyeurUpdate(this, arg2, &var_48, Ok_TargetStatus);
0051a650  }

NOTE (x87 FCMP mush): the trailing if (...) block is a garbled x87 FCOMI/fnstsw+sahf-style comparison of st0 (the computed distance) against temp1 (arg2->radius), reconstructed by BN into a synthetic FLAGS byte then masked with 0x41 (ZF|CF). The condition as literally written computes (distance < radius) OR unordered OR (distance == radius) into bits 8/0xa/0xe of a byte, then tests bits 0x41 = 0b0100_0001 (bit0 + bit6) of the HIGH byte of that packed value — which does not line up cleanly with the bits set (8/0xa/0xe are all ABOVE bit 7, so the high byte holds bits 8,10,14 shifted down by 8 → i.e. bit0=orig bit8=st0<temp1, bit2=orig bit0xa=unordered, bit6=orig bit0xe=st0==temp1). So & 0x41 tests (distance<radius) OR (distance==radius), i.e. distance <= radius (ignoring the unordered bit which lands on bit2, not covered by 0x41). Net semantic: if (distance <= radius) { /* skip send */ } else { SendVoyeurUpdate(... Ok) } — inverted from a naive reading. In other words: only send the voyeur update when the extrapolated position has drifted MORE than radius from the last_sent_position we already told them about. This is the classic "send-on-significant-movement" dead-reckoning threshold, and the mush decodes to the expected sense. Flag as CONFIRMED-BUT-MUSH; the lead should re-derive from the bit math above rather than trust my paraphrase blindly.

TargetManager::NotifyVoyeurOfEvent — 0051a6f0

0051a6f0  void __thiscall TargetManager::NotifyVoyeurOfEvent(class TargetManager* this, enum TargetStatus arg2)

0051a6f0  {
0051a6f6      class LongNIHash<TargettedVoyeurInfo>* voyeur_table = this->voyeur_table;
0051a6f6      
0051a6fb      if (voyeur_table != 0)
0051a6fb      {
0051a703          int32_t* var_10;
0051a703          LongNIHashIter<DetectionCylsphere>::LongNIHashIter<DetectionCylsphere>(&var_10, voyeur_table);
0051a708          int32_t i_1;
0051a708          int32_t i = i_1;
0051a708          
0051a70e          if (i == 0)
0051a70e          {
0051a711              void** j_1;
0051a711              void** j = j_1;
0051a716              int32_t var_c;
0051a716              int32_t edi_1 = var_c;
0051a716              
0051a762              do
0051a762              {
0051a722                  class TargettedVoyeurInfo* eax;
0051a722                  
0051a722                  if (j == 0)
0051a729                      eax = nullptr;
0051a722                  else
0051a724                      eax = j[1];
0051a724                  
0051a72d                  if (i == 0)
0051a72d                  {
0051a733                      int32_t* ecx_1;
0051a733                      
0051a733                      for (j = *(uint32_t*)j; j == 0; j = *(uint32_t*)(*(uint32_t*)ecx_1 + (edi_1 << 2)))
0051a733                      {
0051a735                          ecx_1 = var_10;
0051a73c                          edi_1 += 1;
0051a73c                          
0051a73f                          if (edi_1 >= ecx_1[1])
0051a73f                          {
0051a748                              i = 1;
0051a748                              break;
0051a73f                          }
0051a733                      }
0051a72d                  }
0051a72d                  
0051a75b                  TargetManager::SendVoyeurUpdate(this, eax, &this->physobj->m_position, arg2);
0051a762              } while (i == 0);
0051a70e          }
0051a6fb      }
0051a6f0  }

Iterates every entry in voyeur_table (all subscribers watching US) and calls SendVoyeurUpdate for each, using our CURRENT (non-interpolated) position and the caller-supplied arg2 status (e.g. ExitWorld_TargetStatus, Teleported_TargetStatus). This is the "broadcast a status event to everyone watching me" path, distinct from the per-tick distance-gated CheckAndUpdateVoyeur path.

NOTE (BN mislabel): the ctor call LongNIHashIter<DetectionCylsphere>::LongNIHashIter<DetectionCylsphere>(&var_10, voyeur_table) is BN reusing the LongNIHashIter<DetectionCylsphere> template instantiation's mangled name for what is actually iterating this->voyeur_table, a LongNIHash<TargettedVoyeurInfo>*. The real type is LongNIHashIter<TargettedVoyeurInfo> (confirmed: the struct exists at acclient.h:57648 and TargetManager::HandleTargetting below uses the identical pattern with the same mislabeled ctor name). Treat every LongNIHashIter<DetectionCylsphere> symbol in this file's TargetManager functions as actually LongNIHashIter<TargettedVoyeurInfo>.

TargetManager::ClearTarget — 0051a7e0

0051a7e0  void __fastcall TargetManager::ClearTarget(class TargetManager* this)

0051a7e0  {
0051a7e3      class TargetInfo* target_info = this->target_info;
0051a7e3      
0051a7e8      if (target_info != 0)
0051a7e8      {
0051a7ee          class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(target_info->object_id);
0051a7ee          
0051a7f8          if (eax_1 != 0)
0051a802              CPhysicsObj::remove_voyeur(eax_1, this->physobj->id);
0051a802          
0051a807          class TargetInfo* target_info_1 = this->target_info;
0051a807          
0051a80c          if (target_info_1 != 0)
0051a80c          {
0051a814              target_info_1->interpolated_position.vtable = 0x79285c;
0051a817              target_info_1->target_position.vtable = 0x79285c;
0051a81a              operator delete(target_info_1);
0051a80c          }
0051a80c          
0051a822          this->target_info = nullptr;
0051a7e8      }
0051a7e0  }

If we currently have a target: resolve the target object, if resolved tell IT to remove_voyeur(our_id) (unsubscribe us from their voyeur table), then free our local TargetInfo and null the pointer. Mirror image of SetTarget's subscribe path.

TargetManager::AddVoyeur — 0051a830

0051a830  void __thiscall TargetManager::AddVoyeur(class TargetManager* this, uint32_t arg2, float arg3, double arg4)

0051a830  {
0051a838      class LongNIHash<TargettedVoyeurInfo>* voyeur_table = this->voyeur_table;
0051a838      
0051a840      if (voyeur_table == 0)
0051a840      {
0051a88f          void* eax_5 = operator new(8);
0051a899          class LongNIHash<TargettedVoyeurInfo>* eax_6;
0051a899          
0051a899          if (eax_5 == 0)
0051a8a6              eax_6 = nullptr;
0051a899          else
0051a89f              eax_6 = LongNIHash<DetectionInfo>::LongNIHash<DetectionInfo>(eax_5, 4);
0051a89f          
0051a8a8          this->voyeur_table = eax_6;
0051a840      }
0051a840      else
0051a840      {
0051a850          void** edx_3 = voyeur_table->buckets[(COMBINE(0, ((arg2 >> 0x10) ^ arg2)) % voyeur_table->table_size)];
0051a850          
0051a855          if (edx_3 != 0)
0051a855          {
0051a85a              while (edx_3[2] != arg2)
0051a85a              {
0051a860                  edx_3 = *(uint32_t*)edx_3;
0051a860                  
0051a864                  if (edx_3 == 0)
0051a864                      goto label_51a8b3;
0051a85a              }
0051a85a              
0051a86b              void* edx_4 = edx_3[1];
0051a86b              
0051a870              if (edx_4 != 0)
0051a870              {
0051a87b                  *(uint32_t*)((char*)edx_4 + 0x10) = arg3;
0051a883                  *(uint32_t*)((char*)edx_4 + 8) = arg4;
0051a886                  *(uint32_t*)((char*)edx_4 + 0xc) = *(uint32_t*)((char*)arg4)[4];
0051a88a                  return;
0051a870              }
0051a855          }
0051a840      }
0051a840      
0051a8b3  label_51a8b3:
0051a8b3      void* esi = operator new(0x60);
0051a8b3      
0051a8ba      if (esi == 0)
0051a8f3          esi = nullptr;
0051a8ba      else
0051a8ba      {
0051a8bc          *(uint32_t*)esi = 0;
0051a8be          *(uint32_t*)((char*)esi + 8) = 0;
0051a8c1          *(uint32_t*)((char*)esi + 0xc) = 0;
0051a8c4          *(uint32_t*)((char*)esi + 0x10) = 0;
0051a8ca          *(uint32_t*)((char*)esi + 0x14) = 0x796910;
0051a8d1          *(uint32_t*)((char*)esi + 0x18) = 0;
0051a8d4          *(uint32_t*)((char*)esi + 0x1c) = 0x3f800000;
0051a8da          *(uint32_t*)((char*)esi + 0x20) = 0;
0051a8dd          *(uint32_t*)((char*)esi + 0x24) = 0;
0051a8e0          *(uint32_t*)((char*)esi + 0x28) = 0;
0051a8e3          *(uint32_t*)((char*)esi + 0x50) = 0;
0051a8e6          *(uint32_t*)((char*)esi + 0x54) = 0;
0051a8e9          *(uint32_t*)((char*)esi + 0x58) = 0;
0051a8ec          Frame::cache(((char*)esi + 0x1c));
0051a8ba      }
0051a8ba      
0051a902      *(uint32_t*)esi = arg2;
0051a904      *(uint32_t*)((char*)esi + 0x10) = arg3;
0051a907      *(uint32_t*)((char*)esi + 8) = arg4;
0051a90a      *(uint32_t*)((char*)esi + 0xc) = *(uint32_t*)((char*)arg4)[4];
0051a911      LongNIHash<DetectionInfo>::add(this->voyeur_table, esi, arg2);
0051a922      TargetManager::SendVoyeurUpdate(this, esi, &this->physobj->m_position, Ok_TargetStatus);
0051a830  }

arg2 = voyeur's object id, arg3 = radius (float), arg4 = quantum (double). Lazily creates voyeur_table (4 buckets) on first use. Hand-rolled hash-bucket lookup: if an existing TargettedVoyeurInfo for arg2 is found, just updates its radius/quantum in place and returns early (no immediate send). Otherwise allocates a fresh TargettedVoyeurInfo (0x60 bytes), zero-inits it (including last_sent_position via Frame::cache), sets object_id/radius/quantum, inserts into the hash, then immediately calls SendVoyeurUpdate with our CURRENT position and Ok_TargetStatus — i.e. brand-new voyeurs get an immediate snapshot rather than waiting for the next HandleTargetting tick.

NOTE (BN mislabel): LongNIHash<DetectionInfo>::LongNIHash<DetectionInfo> and LongNIHash<DetectionInfo>::add are BN reusing the DetectionInfo hash template instantiation's mangled symbol for what is actually LongNIHash<TargettedVoyeurInfo>'s ctor/add — same class of mislabel as above (voyeur_table is declared LongNIHash<TargettedVoyeurInfo>*).

TargetManager::ReceiveUpdate — 0051a930

0051a930  void __thiscall TargetManager::ReceiveUpdate(class TargetManager* this, class TargetInfo const* arg2)

0051a930  {
0051a936      class TargetInfo* target_info = this->target_info;
0051a936      
0051a93c      if (target_info != 0)
0051a93c      {
0051a946          uint32_t object_id = arg2->object_id;
0051a946          
0051a94c          if (object_id == target_info->object_id)
0051a94c          {
0051a952              target_info->object_id = object_id;
0051a955              this->target_info->radius = arg2->radius;
0051a961              class TargetInfo* target_info_2 = this->target_info;
0051a964              target_info_2->quantum = arg2->quantum;
0051a96a              *(uint32_t*)((char*)target_info_2->quantum)[4] = *(uint32_t*)((char*)arg2->quantum)[4];
0051a973              class Position* eax_3 = &this->target_info->target_position;
0051a97d              eax_3->objcell_id = arg2->target_position.objcell_id;
0051a980              Frame::operator=(&eax_3->frame, &arg2->target_position.frame);
0051a98b              class Position* eax_5 = &this->target_info->interpolated_position;
0051a995              eax_5->objcell_id = arg2->interpolated_position.objcell_id;
0051a998              Frame::operator=(&eax_5->frame, &arg2->interpolated_position.frame);
0051a9a0              class AC1Legacy::Vector3* eax_7 = &this->target_info->velocity;
0051a9ad              eax_7->x = arg2->velocity.x;
0051a9b2              eax_7->y = arg2->velocity.y;
0051a9b8              eax_7->z = arg2->velocity.z;
0051a9bb              this->target_info->status = arg2->status;
0051a9cf              class TargetInfo* target_info_3 = this->target_info;
0051a9d8              target_info_3->last_update_time = (*(uint32_t*)Timer::cur_time);
0051a9de              *(uint32_t*)((char*)target_info_3->last_update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4);
0051a9f5              int32_t __return;
0051a9f5              class AC1Legacy::Vector3* eax_10 = Position::get_offset(&this->physobj->m_position, &__return, &this->target_info->interpolated_position);
0051a9ff              class AC1Legacy::Vector3* ecx_13 = &this->target_info->interpolated_heading;
0051aa05              ecx_13->x = eax_10->x;
0051aa0a              ecx_13->y = eax_10->y;
0051aa10              ecx_13->z = eax_10->z;
0051aa10              
0051aa23              if (AC1Legacy::Vector3::normalize_check_small(&this->target_info->interpolated_heading) != 0)
0051aa23              {
0051aa25                  class TargetInfo* target_info_1 = this->target_info;
0051aa28                  __return = 0;
0051aa34                  target_info_1->interpolated_heading.x = __return;
0051aa34                  target_info_1->interpolated_heading.y = 0f;
0051aa34                  target_info_1->interpolated_heading.z = 1f;
0051aa23              }
0051aa23              
0051aa66              void var_e4;
0051aa66              TargetInfo::TargetInfo(&var_e4, this->target_info);
0051aa6d              CPhysicsObj::HandleUpdateTarget(this->physobj, var_e4);
0051aa6d              
0051aa79              if (arg2->status == ExitWorld_TargetStatus)
0051aa7d                  TargetManager::ClearTarget(this);
0051a94c          }
0051a93c      }
0051a930  }

This is the "I am a voyeur and just got an update FROM the thing I'm watching" handler — called via CPhysicsObj::receive_target_update from SendVoyeurUpdate's tail-call on the SENDER side. Only processes if we still have an active target_info AND the incoming object_id matches what we're tracking (stale/mismatched updates dropped silently). Copies radius/quantum/target_position/interpolated_position/velocity/status wholesale from the wire payload, stamps last_update_time = Timer::cur_time, then recomputes interpolated_heading as the normalized offset from OUR position to the target's interpolated_position (falls back to (0,0,1) — i.e. forward/+Z — if the offset vector is too small to normalize, via normalize_check_small). Finally fans the whole TargetInfo snapshot out to CPhysicsObj::HandleUpdateTarget (which forwards to MovementManager::HandleUpdateTarget + PositionManager::HandleUpdateTarget — see below), and if the target's status says ExitWorld_TargetStatus, immediately clears our own subscription (target left the world → give up watching it).

TargetManager::HandleTargetting — 0051aa90

0051aa90  void __fastcall TargetManager::HandleTargetting(class TargetManager* this)

0051aa90  {
0051aa9d      long double x87_r7 = (((long double)PhysicsTimer::curr_time) - ((long double)this->last_update_time));
0051aaa0      long double temp0 = ((long double)0.5);
0051aaa0      (x87_r7 - temp0);
0051aaa6      int32_t eax;
0051aaa6      eax = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0))));
0051aaa8      bool p = /* bool p = unimplemented  {test ah, 0x5} */;
0051aaa8      
0051aaab      if (p)
0051aaab      {
0051aab1          class TargetInfo* target_info = this->target_info;
0051aab1          
0051aac0          if ((target_info != 0 && target_info->status == Undef_TargetStatus))
0051aac0          {
0051aac8              long double x87_r7_2 = (((long double)10.0) + ((long double)target_info->last_update_time));
0051aace              long double temp1_1 = ((long double)Timer::cur_time);
0051aace              (x87_r7_2 - temp1_1);
0051aad4              enum TargetStatus eax_1;
0051aad4              eax_1 = ((((x87_r7_2 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp1_1) ? 1 : 0) << 0xe) | 0))));
0051aad6              bool p_1 = /* bool p_1 = unimplemented  {test ah, 0x5} */;
0051aad6              
0051aad9              if (!(p_1))
0051aad9              {
0051aadb                  target_info->status = TimedOut_TargetStatus;
0051aaf1                  void var_e8;
0051aaf1                  TargetInfo::TargetInfo(&var_e8, this->target_info);
0051aaf8                  CPhysicsObj::HandleUpdateTarget(this->physobj, var_e8);
0051aad9              }
0051aac0          }
0051aac0          
0051aafd          class LongNIHash<TargettedVoyeurInfo>* voyeur_table = this->voyeur_table;
0051aafd          
0051ab02          if (voyeur_table != 0)
0051ab02          {
0051ab09              void var_10;
0051ab09              int32_t edx_1 = LongNIHashIter<DetectionCylsphere>::LongNIHashIter<DetectionCylsphere>(&var_10, voyeur_table);
0051ab14              int32_t i;
0051ab14              
0051ab14              while (i == 0)
0051ab14              {
0051ab1c                  void* var_8;
0051ab1c                  class TargettedVoyeurInfo* esi_1;
0051ab1c                  
0051ab1c                  if (var_8 == 0)
0051ab23                      esi_1 = nullptr;
0051ab1c                  else
0051ab1e                      esi_1 = *(uint32_t*)((char*)var_8 + 4);
0051ab1e                  
0051ab25                  int32_t var_1c_2 = 0;
0051ab2b                  LongNIHashIter<TargettedVoyeurInfo>::operator++(&var_10, edx_1);
0051ab33                  edx_1 = TargetManager::CheckAndUpdateVoyeur(this, esi_1);
0051ab14              }
0051ab02          }
0051ab02          
0051ab4c          this->last_update_time = (*(uint32_t*)PhysicsTimer::curr_time);
0051ab4f          *(uint32_t*)((char*)this->last_update_time)[4] = *(int32_t*)((char*)PhysicsTimer::curr_time + 4);
0051aaab      }
0051aa90  }

This is the per-tick driver entry point — called unconditionally once per physics tick from CPhysicsObj::UpdateObjectInternal (see Callers below) whenever this->target_manager != 0. There is no TargetManager::UseTime functionHandleTargetting IS the tick driver, gated by its own internal quantum check rather than an outer UseTime wrapper (unlike MovementManager::UseTime / PositionManager::UseTime / StickyManager::UseTime which sit as siblings in the same call chain).

NOTE (x87 FCMP mush, two occurrences): both p and p_1 are unimplemented {test ah, 0x5} — BN couldn't lower the FLAGS test after the synthetic FCOMI byte pack. Bit pattern is IDENTICAL shape to the one decoded in CheckAndUpdateVoyeur above: test ah, 0x5 tests bits 0b101 = bit0+bit2 of the high byte, i.e. (in the same bit-index scheme as before) orig-bit-8 (<) and orig-bit-0xa (unordered). So: p = (x87_r7 < temp0) OR unordered(x87_r7, temp0), i.e. p = !(PhysicsTimer::curr_time - last_update_time >= 0.5) ... wait, sign check: x87_r7 = curr_time - last_update_time, compared < 0.5. p true means elapsed < 0.5 (or unordered) → guard body runs when p is true. Net: **the whole per-tick body (voyeur re-check + timeout-status loop) only runs when elapsed_since_last_update < 0.5s... ** that reads backwards for a "tick every N seconds" gate (normally you'd expect the body to run when elapsed >= threshold, then reset the timer). FLAG FOR LEAD: re-derive this comparison by hand against x86 FCOMI/SAHF semantics before porting — my bit-algebra above may have the sense inverted (in CheckAndUpdateVoyeur the same 0x41-style mask decoded to <=, but here it's a bare test ah,0x5 i.e. 0x05 mask = bit0+bit2, different mask than CheckAndUpdateVoyeur's 0x41 = bit0+bit6 — these are NOT the same test and I do not have confident hand-verified parity between them). The inner 10-second timeout check (p_1, same 0x5 mask pattern on (10.0 + last_update_time) vs Timer::cur_time, guarded by !p_1) reads as "target status still Undef 10s after last update → promote to TimedOut" which is directionally sane (timeout-if-stale), so by symmetry p at the top is very likely the equivalent "run this tick's logic once quantum-time has elapsed" gate — but get x86 semantics verified rather than trusting this narrative.

NOTE (BN mislabel): same LongNIHashIter<DetectionCylsphere>::LongNIHashIter<DetectionCylsphere> mislabel as in NotifyVoyeurOfEvent — actually LongNIHashIter<TargettedVoyeurInfo>. The subsequent LongNIHashIter<TargettedVoyeurInfo>::operator++ call (correctly named this time) confirms the iterator's real element type.

Per-voyeur, the tick body calls CheckAndUpdateVoyeur (the distance-threshold gated sender) for every entry, then stamps this->last_update_time = PhysicsTimer::curr_time at the end (used for next tick's p gate).

TargetManager::SetTarget — 0051ac30

0051ac30  void __thiscall TargetManager::SetTarget(class TargetManager* this, uint32_t arg2, uint32_t arg3, float arg4, double arg5)

0051ac30  {
0051ac39      class TargetInfo* target_info = this->target_info;
0051ac39      
0051ac3f      if (target_info != 0)
0051ac3f      {
0051ac45          class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(target_info->object_id);
0051ac45          
0051ac4f          if (eax_1 != 0)
0051ac59              CPhysicsObj::remove_voyeur(eax_1, this->physobj->id);
0051ac59          
0051ac5e          class TargetInfo* target_info_1 = this->target_info;
0051ac5e          
0051ac63          if (target_info_1 != 0)
0051ac63          {
0051ac6b              target_info_1->interpolated_position.vtable = 0x79285c;
0051ac6e              target_info_1->target_position.vtable = 0x79285c;
0051ac71              operator delete(target_info_1);
0051ac63          }
0051ac63          
0051ac79          this->target_info = nullptr;
0051ac3f      }
0051ac3f      
0051ac89      if (arg3 == 0)
0051ac89      {
0051ad3d          uint32_t var_d0;
0051ad3d          TargetInfo::TargetInfo(&var_d0);
0051ad59          var_d0 = arg2;
0051ad60          int32_t var_cc_1 = 0;
0051ad6b          int32_t var_10_1 = 6;
0051ad76          void var_1a8;
0051ad76          TargetInfo::TargetInfo(&var_1a8, &var_d0);
0051ad7d          CPhysicsObj::HandleUpdateTarget(this->physobj, var_1a8);
0051ad7d          return;
0051ac89      }
0051ac89      
0051ac94      void* eax_2 = operator new(0xd0);
0051ac9e      uint32_t* eax_3;
0051ac9e      
0051ac9e      if (eax_2 == 0)
0051aca9          eax_3 = nullptr;
0051ac9e      else
0051aca2          eax_3 = TargetInfo::TargetInfo(eax_2);
0051aca2      
0051acb2      this->target_info = eax_3;
0051acb5      *(uint32_t*)eax_3 = arg2;
0051acb7      this->target_info->object_id = arg3;
0051acc4      this->target_info->radius = arg4;
0051acca      class TargetInfo* target_info_3 = this->target_info;
0051acdb      target_info_3->quantum = arg5;
0051acde      *(uint32_t*)((char*)target_info_3->quantum)[4] = *(uint32_t*)((char*)arg5)[4];
0051ace6      class TargetInfo* target_info_4 = this->target_info;
0051acef      target_info_4->last_update_time = (*(uint32_t*)Timer::cur_time);
0051acf5      *(uint32_t*)((char*)target_info_4->last_update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4);
0051ad02      class CPhysicsObj* eax_8 = CPhysicsObj::GetObjectA(this->target_info->object_id);
0051ad02      
0051ad0c      if (eax_8 != 0)
0051ad0c      {
0051ad0e          class TargetInfo* target_info_2 = this->target_info;
0051ad29          CPhysicsObj::add_voyeur(eax_8, this->physobj->id, ((float)((long double)target_info_2->radius)), ((float)((long double)target_info_2->quantum)));
0051ad0c      }
0051ac30  }

arg2 = context_id, arg3 = new target object_id, arg4 = radius, arg5 = quantum. First unconditionally tears down any EXISTING target (unsubscribe voyeur from old target, free old TargetInfo) — same body as ClearTarget inlined. Then: if arg3 == 0 (clearing to "no target"), synthesizes a TargetInfo with context_id = arg2, status = 6 (TimedOut_TargetStatus, NOTE: value 6 is written directly as a raw int at var_10_1 = 6 rather than the named enum constant — same value as TimedOut_TargetStatus, so this "clear" path reports itself as a timeout-status update to HandleUpdateTarget) and returns early WITHOUT allocating this->target_info (stays null). Otherwise (arg3 != 0): allocates a fresh heap TargetInfo (0xd0 bytes), populates context_id/object_id/radius/quantum/last_update_time = Timer::cur_time, resolves the target object, and if found calls add_voyeur on it to subscribe. Note this does NOT immediately fetch/ apply the target's current position — that arrives asynchronously via the target's own SendVoyeurUpdatereceive_target_updateReceiveUpdate round-trip.

TargetManager::RemoveVoyeur — 0051ad90

0051ad90  int32_t __thiscall TargetManager::RemoveVoyeur(class TargetManager* this, uint32_t arg2)

0051ad90  {
0051ad90      class LongNIHash<TargettedVoyeurInfo>* voyeur_table = this->voyeur_table;
0051ad90      
0051ad95      if (voyeur_table != 0)
0051ad95      {
0051ad9c          class DetectionCylsphere* eax_2 = LongNIHash<DetectionCylsphere>::remove(voyeur_table, arg2);
0051ad9c          
0051ada3          if (eax_2 != 0)
0051ada3          {
0051ada6              eax_2->detection_type = 0x79285c;
0051adad              operator delete(eax_2);
0051adba              return 1;
0051ada3          }
0051ad95      }
0051ad95      
0051adbf      return 0;
0051ad90  }

Removes voyeur arg2 from voyeur_table and frees the TargettedVoyeurInfo record; returns 1 if one was found/removed, 0 otherwise.

NOTE (BN mislabel): LongNIHash<DetectionCylsphere>::remove — same mislabel pattern, actually operating on LongNIHash<TargettedVoyeurInfo> (confirmed by the this parameter type LongNIHash<TargettedVoyeurInfo>*). Also eax_2->detection_type = 0x79285c is writing a vtable-pointer-looking constant into a field named by BN as detection_type — this is almost certainly the vtable-poison pattern used elsewhere in this file right before operator delete (see ClearTarget's ->vtable = 0x79285c and SetTarget's identical pattern) applied to what should be TargettedVoyeurInfo's embedded Position last_sent_position's vtable slot, mislabeled as detection_type because BN is treating the freed object as a DetectionCylsphere* per the mislabel above.


TargetManager::~TargetManager (dtor, bonus) — 0051abd0

0051abd0  void __fastcall TargetManager::~TargetManager(class TargetManager* this)

0051abd0  {
0051abd3      class TargetInfo* target_info = this->target_info;
0051abd3      
0051abd8      if (target_info != 0)
0051abd8      {
0051abe0          target_info->interpolated_position.vtable = 0x79285c;
0051abe3          target_info->target_position.vtable = 0x79285c;
0051abe6          operator delete(target_info);
0051abee          this->target_info = nullptr;
0051abd8      }
0051abd8      
0051abf5      class LongNIHash<TargettedVoyeurInfo>* voyeur_table = this->voyeur_table;
0051abf5      
0051abfa      if (voyeur_table != 0)
0051abfa      {
0051abfc          LongNIHash<TargettedVoyeurInfo>::destroy_contents(voyeur_table);
0051ac01          class LongNIHash<TargettedVoyeurInfo>* voyeur_table_1 = this->voyeur_table;
0051ac01          
0051ac06          if (voyeur_table_1 != 0)
0051ac06          {
0051ac0a              LongNIHash<DetectionCylsphere>::flush(voyeur_table_1);
0051ac12              operator delete[](voyeur_table_1->buckets);
0051ac18              voyeur_table_1->buckets = 0;
0051ac1e              operator delete(voyeur_table_1);
0051ac06          }
0051abfa      }
0051abd0  }

Frees target_info (if any), then destroys+flushes+frees the entire voyeur_table hash (all subscriber records). Does NOT notify anyone (no NotifyVoyeurOfEvent call) — pure teardown, unlike exit_world which explicitly notifies before the TargetManager itself is destroyed elsewhere (line 281877, CPhysicsObj's own dtor/cleanup path).

NOTE (BN mislabel): LongNIHash<DetectionCylsphere>::flush at 0051ac0a — actually LongNIHash<TargettedVoyeurInfo>::flush (voyeur_table_1 is typed LongNIHash<TargettedVoyeurInfo>*).


CPhysicsObj-level seams (no dedicated MakeTargetManager)

Grep for MakeTargetManager returned nothing — retail does not have a factory function by that name. Instead, TargetManager construction is lazily inlined at the two call sites that first need it: CPhysicsObj::set_target (becoming a voyeur OF something) and CPhysicsObj::add_voyeur (gaining a voyeur watching us). Both follow the same if (this->target_manager == 0) { alloc + placement-new TargetManager(this) } pattern.

CPhysicsObj::set_target — 0050ed30

0050ed30  void __thiscall CPhysicsObj::set_target(class CPhysicsObj* this, uint32_t arg2, uint32_t arg3, float arg4, double arg5)

0050ed30  {
0050ed3b      if (this->target_manager == 0)
0050ed3b      {
0050ed3f          void* eax_1 = operator new(0x18);
0050ed49          class TargetManager* eax_2;
0050ed49          
0050ed49          if (eax_1 == 0)
0050ed55              eax_2 = nullptr;
0050ed49          else
0050ed4e              eax_2 = TargetManager::TargetManager(eax_1, this);
0050ed4e          
0050ed57          this->target_manager = eax_2;
0050ed3b      }
0050ed3b      
0050ed69      int32_t var_8_2 = *(uint32_t*)((char*)arg5)[4];
0050ed7c      TargetManager::SetTarget(this->target_manager, arg2, arg3, arg4, arg5);
0050ed30  }

Lazy-construct (0x18 = 24 bytes, matches TargetManager's 4 fields: ptr+ptr+ptr+long double(?) — actually physobj+target_info+voyeur_table = 12 bytes + last_update_time long double = likely 8-12 bytes padded to 24), then forward to TargetManager::SetTarget.

CPhysicsObj::clear_target — 0050ed90

0050ed90  void __fastcall CPhysicsObj::clear_target(class CPhysicsObj* this)

0050ed90  {
0050ed90      class TargetManager* target_manager = this->target_manager;
0050ed90      
0050ed98      if (target_manager == 0)
0050ed9f          return;
0050ed9f      
0050ed9a      /* tailcall */
0050ed9a      return TargetManager::ClearTarget(target_manager);
0050ed90  }

No-op if no target_manager exists yet (does NOT lazily construct just to clear). Tail-calls TargetManager::ClearTarget otherwise.

CPhysicsObj::set_target_quantum — 0050eda0

0050eda0  void __thiscall CPhysicsObj::set_target_quantum(class CPhysicsObj* this, double arg2)

0050eda0  {
0050eda0      class TargetManager* target_manager = this->target_manager;
0050eda0      
0050eda8      if (target_manager != 0)
0050eda8      {
0050edb2          int32_t var_4_1 = *(uint32_t*)((char*)arg2)[4];
0050edb4          TargetManager::SetTargetQuantum(target_manager, arg2);
0050eda8      }
0050eda0  }

CPhysicsObj::get_target_quantum — 0050edc0

0050edc0  class TargetManager* __fastcall CPhysicsObj::get_target_quantum(class CPhysicsObj const* this)

0050edc0  {
0050edc0      class TargetManager* result = this->target_manager;
0050edc0      
0050edc8      if (result != 0)
0050edc8      {
0050edca          result = result->target_info;
0050edca          
0050edcf          if (result != 0)
0050edd1              result->last_update_time;
0050edc8      }
0050edc8      
0050eddb      return result;
0050edc0  }

NOTE: despite the name get_target_quantum, the return-type/body as decompiled reads target_info->last_update_time in an expression-statement with the result DISCARDED, then returns result which by that point holds this->target_manager->target_info (a pointer!), not a quantum double. This strongly looks like BN mis-decompiling a long double return through EAX/x87 — the real function almost certainly returns target_info->quantum (a long double, returned via st0, which BN's pseudo-C sometimes fails to model and instead shows the last pointer left in a GPR). The caller at MoveToManager::UseTime (0052a07e, fsubr st0, [esp+8] immediately after the call) treats the return value as an x87 float on the FPU stack, confirming this reads a long double via st0, NOT a pointer. FLAG FOR LEAD: this function's decompiled body/return type is unreliable; trust the CALLER's x87 usage (treats return as long double quantum) over the shown C.

CPhysicsObj::receive_target_update — 0050ede0

0050ede0  void __thiscall CPhysicsObj::receive_target_update(class CPhysicsObj* this, class TargetInfo const* arg2)

0050ede0  {
0050ede0      class TargetManager* target_manager = this->target_manager;
0050ede0      
0050ede8      if (target_manager == 0)
0050edef          return;
0050edef      
0050edea      /* tailcall */
0050edea      return TargetManager::ReceiveUpdate(target_manager, arg2);
0050ede0  }

No-op if no manager. This is the entry point SendVoyeurUpdate tail-calls into on the recipient object.

CPhysicsObj::add_voyeur — 0050ee00

0050ee00  void __thiscall CPhysicsObj::add_voyeur(class CPhysicsObj* this, uint32_t arg2, float arg3, float arg4)

0050ee00  {
0050ee00      int32_t __saved_esi_1;
0050ee00      int32_t __saved_esi = __saved_esi_1;
0050ee00      
0050ee0b      if (this->target_manager == 0)
0050ee0b      {
0050ee0d          int64_t var_c;
0050ee0d          *(uint32_t*)((char*)var_c)[4] = 0x18;
0050ee0f          int32_t eax_1 = operator new();
0050ee19          class TargetManager* eax_2;
0050ee19          
0050ee19          if (eax_1 == 0)
0050ee25              eax_2 = nullptr;
0050ee19          else
0050ee19          {
0050ee1b              *(uint32_t*)((char*)var_c)[4] = this;
0050ee1e              eax_2 = TargetManager::TargetManager(eax_1);
0050ee19          }
0050ee19          
0050ee27          this->target_manager = eax_2;
0050ee0b      }
0050ee0b      
0050ee47      TargetManager::AddVoyeur(this->target_manager, arg2, arg3, ((double)((long double)arg4)));
0050ee00  }

Same lazy-construct pattern as set_target, then forward to TargetManager::AddVoyeur. arg2 = voyeur object id, arg3 = radius, arg4 = quantum (float, widened to double for the call).

CPhysicsObj::remove_voyeur — 0050ee50

0050ee50  int32_t __thiscall CPhysicsObj::remove_voyeur(class CPhysicsObj* this, uint32_t arg2)

0050ee50  {
0050ee50      class TargetManager* target_manager = this->target_manager;
0050ee50      
0050ee58      if (target_manager == 0)
0050ee61          return 0;
0050ee61      
0050ee5a      /* tailcall */
0050ee5a      return TargetManager::RemoveVoyeur(target_manager, arg2);
0050ee50  }

Callers (per-tick driver + SetTarget producers)

Per-tick driver: CPhysicsObj::UpdateObjectInternal — 005156b0 (excerpt)

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);

and immediately preceding (full excerpt, lines 283730-283753):

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);

Confirms the per-tick fan-out order inside CPhysicsObj::UpdateObjectInternal (the retail per-object physics-tick function, called every physics tick per live CPhysicsObj):

  1. DetectionManager::CheckDetection
  2. TargetManager::HandleTargetting ← the voyeur-subscription tick (no separate UseTime)
  3. MovementManager::UseTime
  4. CPartArray::HandleMovement
  5. PositionManager::UseTime

This ordering matters for R5's facade design: TargetManager ticks BEFORE MovementManager and PositionManager each frame, so any target-driven HandleUpdateTarget callback into those two managers (see next section) happens with THIS tick's fresh distance-check data, ahead of movement/position processing the same tick.

CPhysicsObj::HandleUpdateTarget — 00512bc0 (fan-out target)

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  }

Only fans out when arg2.context_id == 0 (context_id nonzero presumably reserved for a different consumer not wired here, e.g. quest/AI scripted targeting — not confirmed in this extract). Copy-constructs a fresh TargetInfo per recipient and forwards to both MovementManager::HandleUpdateTarget and PositionManager::HandleUpdateTarget unconditionally (both get the callback if both managers exist — not an either/or). TargetManager::ReceiveUpdate, TargetManager::HandleTargetting (timeout path), and TargetManager::SetTarget (the arg3==0 clear path) are the three call sites feeding this fan-out.

SetTarget producers

MoveToManager::MoveToObject — 00529680 (excerpt, the set_target call):

00529791          if (arg3 == physics_obj_2->id)
00529791          {
00529795              edx = MoveToManager::CleanUp(this);
00529795              goto label_52979a;
00529791          }
00529791          
005297b8          int32_t var_50_4 = 0;
005297c4          CPhysicsObj::set_target(physics_obj_2, 0, this->top_level_object_id, 0.5f, 0f);

MoveToManager::TurnToObject — 005297d0 (excerpt):

00529900          int32_t __saved_edi_2 = 0;
0052990c          this->initialized = 0;
00529916          CPhysicsObj::set_target(physics_obj_1, 0, arg3, 0.5f, 0f);

Both MoveToManager entry points that establish "move to / turn to a specific object" call CPhysicsObj::set_target(physobj, context_id=0, target_object_id, radius=0.5, quantum=0.0) — a zero quantum, meaning (per HandleTargetting's gate) the tick-driven resend is effectively "as fast as the 0.5s-ish quantum-check tick allows" rather than throttled further; radius is a fixed 0.5 (game units — i.e. "notify me if the target moves more than half a unit from what I last knew").

MoveToManager::CleanUp — clears the target when movement ends (excerpt, line 306731-306732):

0052962c          if ((this->top_level_object_id != 0 && this->movement_type != Invalid))
00529634              CPhysicsObj::clear_target(this->physics_obj);

MoveToManager::UseTime — adaptive quantum re-tuning (heavily x87-mangled excerpt, lines 307376-307434): computes a candidate quantum from Position::distance(starting_position, physics_obj->m_position) and get_velocity-derived speed via a sqrt-based formula (fsqrt visible at 0052a057), compares the delta between the new candidate and the current CPhysicsObj::get_target_quantum() value against a 1.0 threshold and 0.1 epsilon guard, and if the change is significant enough calls CPhysicsObj::set_target_quantum(physics_obj, var_88_3). NOTE: this whole block is FCMP/x87-mangled past reliable hand-decoding — several lines are literal /* unimplemented {fld/fmul/fsqrt/...} */ placeholders BN could not lower into pseudo-C at all. Treat as "adaptive quantum retuning happens somewhere in MoveToManager::UseTime based on distance traveled since move-start and current speed" — do NOT port the exact formula from this extract without a fresh, careful manual disassembly pass; the pseudo-C here is not trustworthy enough to line-port.

StickyManager — a second, independent TargetManager consumer

Not explicitly requested but discovered while tracing clear_target callers — directly relevant to R5's PositionManager/Sticky scope:

StickyManager::StickTo — 00555710 (excerpt): tears down any existing target then calls CPhysicsObj::set_target(physics_obj_1, 0, arg2, 0.5f, 0.5) — same radius (0.5) as MoveToManager but a nonzero 0.5s quantum this time (throttled resend, unlike MoveToManager's 0.0).

StickyManager::UseTime — 00555610: on a Timer::cur_time >= sticky_timeout_time gate (mangled FCMP, same 0x41-mask shape as CheckAndUpdateVoyeur → reads as >=), clears target_id and calls CPhysicsObj::clear_target + CPhysicsObj::interrupt_current_movement.

StickyManager::Destroy — 00555650: unconditional clear_target if target_id != 0 during teardown.

StickyManager::HandleUpdateTarget — 00555780: the TargetInfo consumer callback fanned out from CPhysicsObj::HandleUpdateTarget. Ignores updates whose object_id doesn't match this->target_id. On a match: if status == Ok_TargetStatus, copies target_position into its own tracked position and marks initialized = 1; otherwise (any other status, including timeout/exit/teleport) tears itself down (clear_target + interrupt_current_movement) exactly like UseTime's timeout path.

This confirms two independent per-CPhysicsObj consumers of the same TargetManager/voyeur machinery: MoveToManager (move/turn-to-object, quantum=0, immediate-as-tick-allows resend) and StickyManager (quantum=0.5s throttled resend, timeout-driven unstick). Both receive their updates through the identical CPhysicsObj::HandleUpdateTarget{MovementManager,PositionManager}::HandleUpdateTarget fan-out — i.e. StickyManager::HandleUpdateTarget is presumably reached VIA PositionManager::HandleUpdateTarget (StickyManager is a PositionManager sub-component per the project's existing R5 handoff doc), not directly off CPhysicsObj.