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>
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 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
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
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):
DetectionManager::CheckDetectionTargetManager::HandleTargetting← the voyeur-subscription tick (no separateUseTime)MovementManager::UseTimeCPartArray::HandleMovementPositionManager::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.