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

1256 lines
54 KiB
Markdown

# 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)
```c
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)
```c
struct __cppobj TargetManager
{
CPhysicsObj *physobj;
TargetInfo *target_info;
LongNIHash<TargettedVoyeurInfo> *voyeur_table;
long double last_update_time;
};
```
### `struct TargetInfo` (acclient.h:31591)
```c
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)
```c
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)
```c
struct __cppobj LongNIHash<TargettedVoyeurInfo>
{
LongNIHashData **buckets;
int table_size;
};
```
### `struct LongNIHashIter<TargettedVoyeurInfo>` (acclient.h:57648)
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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` function** — `HandleTargetting` 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
```c
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 `SendVoyeurUpdate``receive_target_update`
`ReceiveUpdate` round-trip.
### `TargetManager::RemoveVoyeur` — 0051ad90
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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
```c
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)
```c
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):
```c
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)
```c
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):
```c
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):
```c
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):
```c
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`.