feat(physics): R5-V2 — wire TargetManager voyeur system per-entity, retire AP-79
Replace the AP-79 P4 TargetTracker poll adapter with the ported retail TargetManager voyeur subscription system, wired per entity. Behaviorally a faithful no-op refactor for the common cases (server-directed creature chase + auto-walk-to-object), now driven by the retail mechanism instead of the GameWindow poll. New: EntityPhysicsHost (App) — the per-entity IPhysicsObjHost (retail CPhysicsObj stand-in), owning a TargetManager. Delegate-injected accessors so it stays free of GameWindow internals (code-structure rule #1). Wiring (GameWindow): - _physicsHosts registry (guid → host) = retail CObjectMaint::GetObjectA, backing the voyeur round-trip's cross-entity delivery. - ResolvePhysicsHost lazily creates a minimal position-only host for ANY known entity — retail lets every CPhysicsObj host a target_manager, so a STATIC object (chest/corpse) still answers add_voyeur; without this, auto-walk to a never-animated object would arm the moveto but never receive the immediate target snapshot and never start. - MoveToManager set_target/clear_target/quantum seams repointed at the host's TargetManager (remote + player). - HandleTargetting ticked unconditionally per entity BEFORE UseTime (retail UpdateObjectInternal order): per-remote loop for remotes, pre-Update block for the player. The player's tick is load-bearing for creature-chase — the player as a watched target pushes its position to the chasing NPCs' HandleUpdateTarget each frame, ahead of their UseTime. - Despawn (RemoveLiveEntityByServerGuid): NotifyVoyeurOfEvent(ExitWorld) to the entity's watchers before pruning the host — the only cleanup for a watcher whose target already sent an Ok (past Undefined, the 10s staleness never fires). Deleted: RemoteMotion.TrackedTarget* fields + _playerMoveToTarget* GameWindow fields + the two manual poll blocks. AP-79 register row retired same commit (AP section 73→72). Delivery is now synchronous-on-set_target (retail: set_target is last in MoveToObject, so the immediate snapshot lands with all moveto state in place) vs AP-79's next-frame poll — more faithful. Full suite 4006 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2b5e8a6738
commit
fffe90b30a
3 changed files with 295 additions and 130 deletions
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
115
src/AcDream.App/Rendering/EntityPhysicsHost.cs
Normal file
115
src/AcDream.App/Rendering/EntityPhysicsHost.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
||||
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
||||
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
||||
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
||||
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
|
||||
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
|
||||
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
|
||||
/// needs.
|
||||
///
|
||||
/// <para>Owns a <see cref="TargetManager"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
|
||||
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> 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
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
|
||||
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
|
||||
///
|
||||
/// <para>PositionManager (sticky) is R5-V3 — this host gains a
|
||||
/// <c>PositionManager</c> and <see cref="HandleUpdateTarget"/> fans to it then;
|
||||
/// V2 delivers only to the MoveToManager.</para>
|
||||
/// </summary>
|
||||
public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||
{
|
||||
private readonly Func<Position> _getPosition;
|
||||
private readonly Func<Vector3> _getVelocity;
|
||||
private readonly Func<float> _getRadius;
|
||||
private readonly Func<bool> _inContact;
|
||||
private readonly Func<float?> _minterpMaxSpeed;
|
||||
private readonly Func<double> _curTime;
|
||||
private readonly Func<double> _physicsTimerTime;
|
||||
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
|
||||
private readonly Action<TargetInfo> _handleUpdateTarget;
|
||||
private readonly Action _interruptCurrentMovement;
|
||||
private readonly TargetManager _targetManager;
|
||||
|
||||
public EntityPhysicsHost(
|
||||
uint id,
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> 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();
|
||||
|
||||
/// <summary>The owned voyeur manager (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>).</summary>
|
||||
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) ─────────────────
|
||||
|
||||
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
|
||||
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
|
||||
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
|
||||
/// managers' <c>UseTime</c>.</summary>
|
||||
public void HandleTargetting() => _targetManager.HandleTargetting();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
|
||||
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — 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.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
}
|
||||
|
|
@ -435,15 +435,12 @@ public sealed class GameWindow : IDisposable
|
|||
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds.</summary>
|
||||
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;
|
||||
/// <summary>
|
||||
/// 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<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — retail <c>CObjectMaint::GetObjectA(id)</c>: resolve any entity's
|
||||
/// <see cref="AcDream.Core.Physics.Motion.IPhysicsObjHost"/> by guid, the
|
||||
/// cross-entity seam every host's <c>GetObjectA</c> uses. If the entity has
|
||||
/// a bound host (from <see cref="EnsureRemoteMotionBindings"/> or
|
||||
/// <c>EnterPlayerModeNow</c>) return it; otherwise, for any entity that
|
||||
/// EXISTS in the world, lazily create a minimal position-only host. Retail
|
||||
/// gives every <c>CPhysicsObj</c> the capacity to host a
|
||||
/// <c>target_manager</c> — a STATIC object (chest, corpse) must still answer
|
||||
/// <c>add_voyeur</c> 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).
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>HandleUpdateTarget(Ok)</c> from the live entity table when the
|
||||
/// tracked target moved beyond the voyeur radius (ExitWorld on
|
||||
/// despawn), then runs <c>UseTime()</c> — 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 <see cref="AcDream.Core.Physics.Motion.MoveToManager"/>
|
||||
/// drive (<c>UseTime()</c> — steering, arrival, fail-distance). The P4
|
||||
/// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
|
||||
/// through the <see cref="AcDream.Core.Physics.Motion.TargetManager"/>
|
||||
/// voyeur system — the target's own <c>HandleTargetting</c> pushes updates
|
||||
/// into this entity's <c>HandleUpdateTarget</c>, which is called
|
||||
/// unconditionally per remote in the per-tick loop BEFORE this drive
|
||||
/// (retail <c>UpdateObjectInternal</c> order). Safe for any remote: a
|
||||
/// manager with no armed moveto no-ops, airborne bodies held by
|
||||
/// <c>UseTime</c>'s contact gate.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue