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