diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 5e469273..8932aa5e 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 73 rows
+## 3. Documented approximation (AP) — 72 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -177,7 +177,6 @@ accepted-divergence entries (#96, #49, #50).
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
-| AP-79 | **P4 TargetTracker minimal adapter**: MoveToManager's setTarget/clearTarget seams store the tracked guid on RemoteMotion (remotes, R4-V4) or the `_playerMoveToTarget*` GameWindow fields (local player, R4-V5); GameWindow's per-tick block (remotes) / pre-Update feed (player) delivers `HandleUpdateTarget(Ok)` from the live entity table whenever the target moved beyond the set_target radius (and `ExitWorld` when the entity despawns) — retail's TargetManager is a full subscription system with per-target quantums and callback scheduling (R4-V4 + R4-V5, 2026-07-03) | `src/AcDream.App/Rendering/GameWindow.cs` (the tracker block in TickAnimations + the setTarget seam in EnsureRemoteMotionBindings; the player twin: `_playerMoveToTarget*` fields, the EnterPlayerModeNow setTarget seam, and the pre-Update feed in OnUpdateFrame) | The manager's deferred-start/retarget lifecycle only needs position deltas at the tracked radius; the adapter delivers exactly that from data the client already holds | Update cadence is frame-quantized rather than quantum-scheduled; a target moving sub-radius never re-fires (retail same); multi-listener semantics absent until R5's TargetManager port | `TargetManager::ReceiveUpdate` chain (r4-moveto-decomp.md); retire in R5 |
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs
new file mode 100644
index 00000000..00e5e2b4
--- /dev/null
+++ b/src/AcDream.App/Rendering/EntityPhysicsHost.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+
+namespace AcDream.App.Rendering;
+
+///
+/// R5-V2 — the App-side per entity: acdream's
+/// stand-in for retail's CPhysicsObj as the movement managers see it.
+/// One is built per entity (a remote RemoteMotion or the local player)
+/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow
+/// and registered in GameWindow._physicsHosts (guid → host), so
+/// can resolve OTHER entities' hosts — the
+/// cross-entity delivery path the voyeur system
+/// needs.
+///
+/// Owns a (retail
+/// CPhysicsObj::target_manager). Its set_target/clear_target/
+/// add_voyeur/remove_voyeur/receive_target_update seams
+/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
+/// target seams are repointed here, replacing the AP-79 poll adapter. The
+/// per-entity accessors (position/velocity/radius/contact/clocks) and the
+/// fan-out are injected by GameWindow so this
+/// class stays free of GameWindow's internals (code-structure rule #1).
+///
+/// PositionManager (sticky) is R5-V3 — this host gains a
+/// PositionManager and fans to it then;
+/// V2 delivers only to the MoveToManager.
+///
+public sealed class EntityPhysicsHost : IPhysicsObjHost
+{
+ private readonly Func _getPosition;
+ private readonly Func _getVelocity;
+ private readonly Func _getRadius;
+ private readonly Func _inContact;
+ private readonly Func _minterpMaxSpeed;
+ private readonly Func _curTime;
+ private readonly Func _physicsTimerTime;
+ private readonly Func _getObjectA;
+ private readonly Action _handleUpdateTarget;
+ private readonly Action _interruptCurrentMovement;
+ private readonly TargetManager _targetManager;
+
+ public EntityPhysicsHost(
+ uint id,
+ Func getPosition,
+ Func getVelocity,
+ Func getRadius,
+ Func inContact,
+ Func minterpMaxSpeed,
+ Func curTime,
+ Func physicsTimerTime,
+ Func getObjectA,
+ Action handleUpdateTarget,
+ Action interruptCurrentMovement)
+ {
+ Id = id;
+ _getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition));
+ _getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity));
+ _getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius));
+ _inContact = inContact ?? throw new ArgumentNullException(nameof(inContact));
+ _minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed));
+ _curTime = curTime ?? throw new ArgumentNullException(nameof(curTime));
+ _physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime));
+ _getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA));
+ _handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget));
+ _interruptCurrentMovement = interruptCurrentMovement
+ ?? throw new ArgumentNullException(nameof(interruptCurrentMovement));
+ _targetManager = new TargetManager(this);
+ }
+
+ // ── IPhysicsObjHost accessors ──────────────────────────────────────────
+ public uint Id { get; }
+ public Position Position => _getPosition();
+ public Vector3 Velocity => _getVelocity();
+ public float Radius => _getRadius();
+ public bool InContact => _inContact();
+ public float? MinterpMaxSpeed => _minterpMaxSpeed();
+ public double CurTime => _curTime();
+ public double PhysicsTimerTime => _physicsTimerTime();
+
+ /// The owned voyeur manager (retail
+ /// CPhysicsObj::target_manager).
+ public TargetManager TargetManager => _targetManager;
+
+ // ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
+ public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
+ public void HandleUpdateTarget(TargetInfo info) => _handleUpdateTarget(info);
+ public void InterruptCurrentMovement() => _interruptCurrentMovement();
+
+ public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
+ => _targetManager.SetTarget(contextId, objectId, radius, quantum);
+
+ public void ClearTarget() => _targetManager.ClearTarget();
+ public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info);
+ public void AddVoyeur(uint watcherId, float radius, double quantum)
+ => _targetManager.AddVoyeur(watcherId, radius, quantum);
+ public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId);
+
+ // ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
+
+ /// Retail TargetManager::HandleTargetting — the per-tick
+ /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
+ /// for every entity in UpdateObjectInternal, BEFORE the movement
+ /// managers' UseTime.
+ public void HandleTargetting() => _targetManager.HandleTargetting();
+
+ /// Retail CPhysicsObj::exit_world's
+ /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every
+ /// watcher of this entity that it left the world (they drop the
+ /// stick/moveto). Called on despawn before the host is removed from the
+ /// registry.
+ public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a417a0b9..e8c83238 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -435,15 +435,12 @@ public sealed class GameWindow : IDisposable
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds.
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo;
- // R4-V4 (pin P4): the minimal TargetTracker adapter state — the
- // manager's setTarget/clearTarget seams store the tracked guid here;
- // the per-tick block feeds HandleUpdateTarget(Ok/ExitWorld) from the
- // live entity table. Full TargetManager port is R5 (register row).
- public uint TrackedTargetGuid;
- public float TrackedTargetRadius;
- public System.Numerics.Vector3 TrackedTargetLastFedPos;
- public bool TrackedTargetFedOnce;
- public double TargetQuantum;
+ // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager
+ // voyeur system (retail CPhysicsObj::target_manager). Replaces the
+ // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
+ // clear_target/quantum seams route here, HandleTargetting ticks per
+ // frame, and OTHER entities resolve this one via GameWindow._physicsHosts.
+ public EntityPhysicsHost? Host;
///
/// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager
@@ -952,17 +949,19 @@ public sealed class GameWindow : IDisposable
// time. Cleared before the deferred send fires — single-fire, no retry.
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
- // R4-V5 (pin P4): the local player's minimal TargetTracker adapter
- // state — the exact twin of RemoteMotion's TrackedTarget* fields. The
- // player MoveToManager's setTarget/clearTarget seams store the tracked
- // guid here; the pre-Update feed in OnUpdateFrame delivers
- // HandleUpdateTarget(Ok/ExitWorld) from the live entity table. Full
- // TargetManager port is R5 (register row, landed with V4).
- private uint _playerMoveToTargetGuid;
- private float _playerMoveToTargetRadius;
- private System.Numerics.Vector3 _playerMoveToTargetLastFedPos;
- private bool _playerMoveToTargetFedOnce;
- private double _playerMoveToTargetQuantum;
+ // R5-V2: the local player's CPhysicsObj stand-in — owns the player's
+ // TargetManager voyeur system, registered in _physicsHosts so remote
+ // entities chasing the player resolve it. Replaces the AP-79
+ // _playerMoveToTarget* poll fields.
+ private EntityPhysicsHost? _playerHost;
+
+ // R5-V2: guid → per-entity IPhysicsObjHost registry (retail's
+ // CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam,
+ // giving the TargetManager voyeur round-trip its cross-entity delivery
+ // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
+ // (player); pruned in RemoveLiveEntityByServerGuid.
+ private readonly Dictionary _physicsHosts = new();
+
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
private const double ServerControlledVelocityStaleSeconds = 0.60;
private int _liveSpawnReceived; // diagnostics
@@ -4267,6 +4266,11 @@ public sealed class GameWindow : IDisposable
// distance/heading math matches the harness exactly.
var rmT = rm;
var mtBody = rm.Body;
+ // R5-V2: forward-declared so the MoveToManager's target seams route
+ // into the entity's TargetManager (retail CPhysicsObj::set_target →
+ // TargetManager::SetTarget). Assigned right after the manager is built.
+ EntityPhysicsHost host = null!;
+ double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
rm.MoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
rm.Motion,
stopCompletely: () => rmT.Motion.StopCompletely(),
@@ -4276,37 +4280,101 @@ public sealed class GameWindow : IDisposable
mtBody.Orientation),
setHeading: (h, _) => mtBody.Orientation =
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
- getOwnRadius: () => 0f, // pin P4 note: setup cylsphere radius lands with R5's TargetManager port
+ getOwnRadius: () => 0f, // pin P4 note: setup cylsphere radius lands with R5-V3
getOwnHeight: () => 0f,
contact: () => mtBody.OnWalkable,
isInterpolating: () => rmT.Interp.IsActive,
getVelocity: () => mtBody.Velocity,
getSelfId: () => serverGuid,
- setTarget: (_, tlid, radius, _) =>
- {
- rmT.TrackedTargetGuid = tlid;
- rmT.TrackedTargetRadius = radius;
- rmT.TrackedTargetFedOnce = false;
- },
- clearTarget: () => rmT.TrackedTargetGuid = 0,
- getTargetQuantum: () => rmT.TargetQuantum,
- setTargetQuantum: q => rmT.TargetQuantum = q,
- // R4-V5: real clock (same epoch-seconds base the per-remote
- // tick uses). V4 left this on the ctor's per-CALL stub, which
- // advanced 1/30 s per _curTime() READ — multiple reads inside
- // one dispatch skewed the progress/fail-distance clocks
- // (CheckProgressMade's 1 s window). The V2 harness contract
- // says production always supplies a real clock.
- curTime: () => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds);
+ // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
+ // → the entity's TargetManager (replaces the AP-79 TrackedTarget*
+ // poll). The manager passes (0, top_level_id, 0.5, 0.0).
+ setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
+ clearTarget: () => host.ClearTarget(),
+ getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
+ setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
+ // R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
+ curTime: NowSeconds);
// TS-36 (remote side): the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (V2's reentrancy tests prove the wiring is inert-safe).
rm.Motion.InterruptCurrentMovement =
() => rmT.MoveTo?.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled);
+
+ // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
+ // is the entity's WORLD position (WorldEntity.Position — exactly the
+ // source the AP-79 poll used for a tracked target, so the voyeur system
+ // delivers the identical position for a moveto's quantum-0 case).
+ // HandleTargetting ticks per frame (added in the per-remote loop);
+ // HandleUpdateTarget fans deliveries to this entity's MoveToManager
+ // (PositionManager sticky joins the fan-out in V3).
+ host = new EntityPhysicsHost(
+ serverGuid,
+ getPosition: () => new AcDream.Core.Physics.Position(
+ rmT.CellId,
+ _entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt)
+ ? selfEnt.Position : mtBody.Position,
+ mtBody.Orientation),
+ getVelocity: () => mtBody.Velocity,
+ getRadius: () => 0f, // R5-V3: real setup cylsphere radius
+ inContact: () => mtBody.OnWalkable,
+ minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
+ curTime: NowSeconds,
+ physicsTimerTime: NowSeconds,
+ getObjectA: ResolvePhysicsHost,
+ handleUpdateTarget: info => rmT.MoveTo?.HandleUpdateTarget(info),
+ interruptCurrentMovement: () => rmT.MoveTo?.CancelMoveTo(
+ AcDream.Core.Physics.WeenieError.ActionCancelled));
+ rm.Host = host;
+ _physicsHosts[serverGuid] = host;
return rm.Sink;
}
+ ///
+ /// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's
+ /// by guid, the
+ /// cross-entity seam every host's GetObjectA uses. If the entity has
+ /// a bound host (from or
+ /// EnterPlayerModeNow) return it; otherwise, for any entity that
+ /// EXISTS in the world, lazily create a minimal position-only host. Retail
+ /// gives every CPhysicsObj the capacity to host a
+ /// target_manager — a STATIC object (chest, corpse) must still answer
+ /// add_voyeur so a mover can moveto/stick to it (without this,
+ /// auto-walk to a never-animated object would arm the moveto but never
+ /// receive the immediate target snapshot, and never start). The minimal
+ /// host is registered so subsequent lookups reuse it; it is NOT ticked
+ /// (a static object never drifts, so the AddVoyeur immediate snapshot is
+ /// all a watcher needs; despawn still fires ExitWorld via the registry).
+ ///
+ private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
+ {
+ if (_physicsHosts.TryGetValue(id, out var existing))
+ return existing;
+ if (!_entitiesByServerGuid.ContainsKey(id))
+ return null;
+
+ double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ var minimal = new EntityPhysicsHost(
+ id,
+ getPosition: () => new AcDream.Core.Physics.Position(
+ 0u,
+ _entitiesByServerGuid.TryGetValue(id, out var e)
+ ? e.Position : System.Numerics.Vector3.Zero,
+ System.Numerics.Quaternion.Identity),
+ getVelocity: () => System.Numerics.Vector3.Zero, // static target
+ getRadius: () => 0f,
+ inContact: () => true,
+ minterpMaxSpeed: () => null,
+ curTime: NowSeconds,
+ physicsTimerTime: NowSeconds,
+ getObjectA: ResolvePhysicsHost,
+ handleUpdateTarget: _ => { }, // not a watcher
+ interruptCurrentMovement: () => { });
+ _physicsHosts[id] = minimal;
+ return minimal;
+ }
+
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@@ -4323,6 +4391,16 @@ public sealed class GameWindow : IDisposable
aeGone.Sequencer?.Manager.HandleExitWorld();
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
rmGone.Motion.HandleExitWorld();
+ // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
+ // entity that it left the world (TargetManager::NotifyVoyeurOfEvent
+ // ExitWorld) — a watcher moving-to/stuck-to this entity drops the
+ // moveto/stick. This is the ONLY clean-up for a watcher whose target
+ // already sent an Ok (once status != Undefined the 10 s staleness
+ // timeout never fires), so it must run before the host is pruned.
+ if (_physicsHosts.TryGetValue(serverGuid, out var hostGone)
+ && hostGone is EntityPhysicsHost ephGone)
+ ephGone.NotifyExitWorld();
+ _physicsHosts.Remove(serverGuid);
_animatedEntities.Remove(existingEntity.Id);
_classificationCache.InvalidateEntity(existingEntity.Id);
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
@@ -4457,51 +4535,20 @@ public sealed class GameWindow : IDisposable
}
///
- /// R4-V5 (extracted from the V4 NPC tick block, now shared with the
- /// grounded player-remote pipeline — the glide fix): the P4
- /// TargetTracker delivery + the per-tick MoveToManager drive. Feeds
- /// HandleUpdateTarget(Ok) from the live entity table when the
- /// tracked target moved beyond the voyeur radius (ExitWorld on
- /// despawn), then runs UseTime() — steering, arrival,
- /// fail-distance. Safe for any remote: a manager with no armed moveto
- /// no-ops, and airborne bodies are held by UseTime's contact gate.
+ /// R4-V5 / R5-V2: the per-tick
+ /// drive (UseTime() — steering, arrival, fail-distance). The P4
+ /// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
+ /// through the
+ /// voyeur system — the target's own HandleTargetting pushes updates
+ /// into this entity's HandleUpdateTarget, which is called
+ /// unconditionally per remote in the per-tick loop BEFORE this drive
+ /// (retail UpdateObjectInternal order). Safe for any remote: a
+ /// manager with no armed moveto no-ops, airborne bodies held by
+ /// UseTime's contact gate.
///
private void TickRemoteMoveTo(RemoteMotion rm)
{
- if (rm.MoveTo is not { } mtm)
- return;
-
- if (rm.TrackedTargetGuid != 0)
- {
- if (_entitiesByServerGuid.TryGetValue(rm.TrackedTargetGuid, out var trackedEnt))
- {
- var tpos = trackedEnt.Position;
- if (!rm.TrackedTargetFedOnce
- || System.Numerics.Vector3.Distance(tpos, rm.TrackedTargetLastFedPos)
- > rm.TrackedTargetRadius)
- {
- rm.TrackedTargetFedOnce = true;
- rm.TrackedTargetLastFedPos = tpos;
- var tp = new AcDream.Core.Physics.Position(
- rm.CellId, tpos, System.Numerics.Quaternion.Identity);
- mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
- rm.TrackedTargetGuid,
- AcDream.Core.Physics.Motion.TargetStatus.Ok,
- tp, tp));
- }
- }
- else
- {
- var lp = new AcDream.Core.Physics.Position(
- rm.CellId, rm.TrackedTargetLastFedPos,
- System.Numerics.Quaternion.Identity);
- mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
- rm.TrackedTargetGuid,
- AcDream.Core.Physics.Motion.TargetStatus.ExitWorld,
- lp, lp));
- }
- }
- mtm.UseTime();
+ rm.MoveTo?.UseTime();
}
///
@@ -7985,43 +8032,14 @@ public sealed class GameWindow : IDisposable
? localEnt.Id
: 0u;
- // R4-V5 (pin P4): feed the player MoveToManager's target tracker
- // BEFORE Update ticks UseTime — same feed-then-tick order the
- // per-remote block uses. One immediate delivery after set_target,
- // then re-delivery when the target moved beyond the voyeur
- // radius; despawn delivers ExitWorld (the manager cancels
- // 0x37/0x38 itself).
- if (_playerController.MoveTo is { } playerMtm
- && _playerMoveToTargetGuid != 0)
- {
- if (_entitiesByServerGuid.TryGetValue(_playerMoveToTargetGuid, out var trackedEnt))
- {
- var tpos = trackedEnt.Position;
- if (!_playerMoveToTargetFedOnce
- || System.Numerics.Vector3.Distance(tpos, _playerMoveToTargetLastFedPos)
- > _playerMoveToTargetRadius)
- {
- _playerMoveToTargetFedOnce = true;
- _playerMoveToTargetLastFedPos = tpos;
- var tp = new AcDream.Core.Physics.Position(
- _playerController.CellId, tpos, System.Numerics.Quaternion.Identity);
- playerMtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
- _playerMoveToTargetGuid,
- AcDream.Core.Physics.Motion.TargetStatus.Ok,
- tp, tp));
- }
- }
- else
- {
- var lp = new AcDream.Core.Physics.Position(
- _playerController.CellId, _playerMoveToTargetLastFedPos,
- System.Numerics.Quaternion.Identity);
- playerMtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
- _playerMoveToTargetGuid,
- AcDream.Core.Physics.Motion.TargetStatus.ExitWorld,
- lp, lp));
- }
- }
+ // R5-V2: tick the player's TargetManager BEFORE Update ticks
+ // MoveToManager.UseTime (retail UpdateObjectInternal order). This is
+ // the load-bearing call for creature-chase: the player, as a watched
+ // target, pushes its position to its voyeurs (the NPCs moving-to it)
+ // here — that push lands in each NPC's HandleUpdateTarget the same
+ // frame, ahead of the NPCs' own UseTime in the per-remote loop
+ // (which runs after this block). Replaces the AP-79 player poll.
+ _playerHost?.HandleTargetting();
var result = _playerController.Update((float)dt, input);
@@ -9616,6 +9634,16 @@ public sealed class GameWindow : IDisposable
// TickAnimations advance.
&& rm.LastServerPosTime > 0)
{
+ // R5-V2: retail UpdateObjectInternal ticks TargetManager::
+ // HandleTargetting UNCONDITIONALLY per entity, BEFORE the
+ // movement managers' UseTime. This is where this entity, as a
+ // watched target, pushes its position to its voyeurs (any entity
+ // moving-to it), and where its own target-info staleness times
+ // out. Runs for every remote regardless of the grounded/airborne
+ // branch below (which drive MoveToManager.UseTime via
+ // TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
+ rm.Host?.HandleTargetting();
+
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
{
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
@@ -12987,6 +13015,9 @@ public sealed class GameWindow : IDisposable
// server-side on its own turn paths; the full-frame diff lands with
// the R7 outbound-cadence port.
var pcMoveTo = _playerController;
+ // R5-V2: forward-declared so the player MoveToManager's target seams
+ // route into the player's TargetManager (retail CPhysicsObj::set_target).
+ EntityPhysicsHost playerHost = null!;
var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
pcMoveTo.Motion,
stopCompletely: () => pcMoveTo.Motion.StopCompletely(),
@@ -13001,19 +13032,39 @@ public sealed class GameWindow : IDisposable
isInterpolating: () => false,
getVelocity: () => pcMoveTo.BodyVelocity,
getSelfId: () => _playerServerGuid,
- setTarget: (_, tlid, radius, _) =>
- {
- _playerMoveToTargetGuid = tlid;
- _playerMoveToTargetRadius = radius;
- _playerMoveToTargetFedOnce = false;
- },
- clearTarget: () => _playerMoveToTargetGuid = 0,
- getTargetQuantum: () => _playerMoveToTargetQuantum,
- setTargetQuantum: q => _playerMoveToTargetQuantum = q,
+ // R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the
+ // player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll).
+ setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q),
+ clearTarget: () => playerHost.ClearTarget(),
+ getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
+ setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q),
curTime: () => pcMoveTo.SimTimeSeconds);
- _playerMoveToTargetGuid = 0;
- _playerMoveToTargetFedOnce = false;
- _playerMoveToTargetQuantum = 0.0;
+
+ // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is
+ // the player's WORLD position (WorldEntity.Position — what the AP-79
+ // poll used), so an NPC watching the player receives the identical
+ // position. Registered in _physicsHosts so those NPCs' GetObjectA
+ // resolves the player; HandleTargetting is ticked in the player
+ // pre-Update block.
+ playerHost = new EntityPhysicsHost(
+ _playerServerGuid,
+ getPosition: () => new AcDream.Core.Physics.Position(
+ pcMoveTo.CellId,
+ _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf)
+ ? pSelf.Position : pcMoveTo.Position,
+ pcMoveTo.BodyOrientation),
+ getVelocity: () => pcMoveTo.BodyVelocity,
+ getRadius: () => 0f, // R5-V3: real setup cylsphere radius
+ inContact: () => pcMoveTo.BodyInContact,
+ minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(),
+ curTime: () => pcMoveTo.SimTimeSeconds,
+ physicsTimerTime: () => pcMoveTo.SimTimeSeconds,
+ getObjectA: ResolvePhysicsHost,
+ handleUpdateTarget: info => _playerController?.MoveTo?.HandleUpdateTarget(info),
+ interruptCurrentMovement: () => _playerController?.MoveTo?.CancelMoveTo(
+ AcDream.Core.Physics.WeenieError.ActionCancelled));
+ _playerHost = playerHost;
+ _physicsHosts[_playerServerGuid] = playerHost;
// AD-27 re-anchored (was the deleted AutoWalkArrived event): fire
// the deferred close-range Use/PickUp action when the moveto