refactor(physics): extract remote motion runtime
This commit is contained in:
parent
d68c83d1a5
commit
5882b308c1
12 changed files with 368 additions and 361 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -421,307 +422,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
|
||||
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||||
/// retail's client-side motion pipeline to every remote. Mirrors
|
||||
/// retail <c>FUN_00515020</c> <c>update_object</c> → <c>FUN_00513730</c>
|
||||
/// <c>UpdatePositionInternal</c> → <c>FUN_005111D0</c>
|
||||
/// <c>UpdatePhysicsInternal</c>, and ACE's <c>PhysicsObj.cs</c> port.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail has NO special "interpolator" for remote entities — it runs
|
||||
/// the full motion state machine on every entity, local or remote,
|
||||
/// and reconciles via hard-snap on UpdatePosition. This class simply
|
||||
/// pairs a <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
|
||||
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
|
||||
/// remote gets the same treatment as the local player.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion :
|
||||
AcDream.App.World.ILiveEntityRemotePlacementRuntime
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body { get; }
|
||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||
|
||||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||||
/// ONE per-entity owner of the interp + moveto pair (acclient.h
|
||||
/// <c>/* 3463 */</c>). Constructed here with the interp; the
|
||||
/// MoveToManager side arrives via <c>MoveToFactory</c> +
|
||||
/// <c>MakeMoveToManager()</c> in EnsureRemoteMotionBindings.
|
||||
/// <see cref="Motion"/>/<see cref="MoveTo"/> below are views of its
|
||||
/// children (retail <c>get_minterp</c>-style access), kept so the
|
||||
/// dozens of existing call sites read unchanged.</summary>
|
||||
public AcDream.Core.Physics.Motion.MovementManager Movement;
|
||||
|
||||
public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp;
|
||||
/// <summary>R3-W4: the persistent per-remote dispatch sink (created
|
||||
/// once by EnsureRemoteMotionBindings; also bound as
|
||||
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
|
||||
/// cycles through the motion-table backend).</summary>
|
||||
public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
|
||||
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
|
||||
public double LastServerPosTime;
|
||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
public System.Numerics.Vector3 LastServerPos;
|
||||
/// <summary>
|
||||
/// #184: the body position at which this remote's collision SHADOW was
|
||||
/// last (re-)registered. The per-tick shadow-follows-resolved sync
|
||||
/// (<c>SyncRemoteShadowToBody</c>) skips re-flooding when the body has
|
||||
/// not moved > ε since this — a resting/idle-town crowd doesn't churn
|
||||
/// the registry, while a moving/de-overlapping crowd re-registers every
|
||||
/// tick. Sentinel <c>Zero</c> forces the first sync.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 LastShadowSyncPos;
|
||||
/// <summary>
|
||||
/// Complete root orientation paired with <see cref="LastShadowSyncPos"/>.
|
||||
/// Multipart and off-centre Setup collision geometry must be re-flooded
|
||||
/// when the object turns in place, not only when its origin translates.
|
||||
/// The default zero quaternion is the first-sync sentinel.
|
||||
/// </summary>
|
||||
public System.Numerics.Quaternion LastShadowSyncOrientation;
|
||||
/// <summary>
|
||||
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||||
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||||
/// it for a server-controlled creature, derive it from consecutive
|
||||
/// authoritative positions instead of guessing from player RUM state.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 ServerVelocity;
|
||||
public bool HasServerVelocity;
|
||||
|
||||
/// <summary>R4-V4: the entity's verbatim retail MoveToManager
|
||||
/// (server-directed movement), constructed + seam-bound by
|
||||
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5:
|
||||
/// owned by <see cref="Movement"/>; this is the child view.</summary>
|
||||
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo;
|
||||
|
||||
// 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
|
||||
/// and CMotionInterp; the per-tick remote driver consults this to
|
||||
/// decide whether to feed body steering through
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
|
||||
/// the InterpretedMotionState path.
|
||||
/// </summary>
|
||||
public bool ServerMoveToActive;
|
||||
|
||||
/// <summary>
|
||||
/// True once a MoveTo packet's full path payload (Origin + thresholds)
|
||||
/// has been parsed and the world-converted destination is stored on
|
||||
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
|
||||
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
|
||||
/// Phase L.1c (2026-04-28).
|
||||
/// </summary>
|
||||
public bool HasMoveToDestination;
|
||||
|
||||
/// <summary>
|
||||
/// World-space destination from the most recent MoveTo packet's
|
||||
/// <c>Origin</c> field, converted via the same landblock-grid
|
||||
/// arithmetic <c>OnLivePositionUpdated</c> uses.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 MoveToDestinationWorld;
|
||||
|
||||
/// <summary>
|
||||
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
|
||||
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
|
||||
/// the chase-arrival threshold per retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c>.
|
||||
/// </summary>
|
||||
public float MoveToMinDistance;
|
||||
|
||||
/// <summary>
|
||||
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
|
||||
/// the flee branch (<c>move_away</c>); chase uses
|
||||
/// <see cref="MoveToMinDistance"/>.
|
||||
/// </summary>
|
||||
public float MoveToDistanceToObject;
|
||||
|
||||
/// <summary>
|
||||
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
|
||||
/// <c>0x200</c>) is set on the active packet — i.e. this is a
|
||||
/// chase. False = flee (<c>move_away</c>) or static target.
|
||||
/// </summary>
|
||||
public bool MoveToMoveTowards;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
|
||||
/// for this entity. Used by the per-tick driver to give up
|
||||
/// steering when no refresh has arrived for
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
|
||||
/// — typically because the entity left our streaming view and
|
||||
/// the server stopped broadcasting its MoveTo updates.
|
||||
/// </summary>
|
||||
public double LastMoveToPacketTime;
|
||||
/// <summary>
|
||||
/// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
|
||||
/// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
|
||||
/// 0x0040, indoor 0x0100+). Fed into
|
||||
/// <see cref="AcDream.Core.Physics.PhysicsEngine.ResolveWithTransition"/>
|
||||
/// so the retail sphere-sweep can look up the right terrain/EnvCell
|
||||
/// polygons for each remote's per-frame motion. Zero until the first
|
||||
/// UP lands, which disables the transition step for that frame
|
||||
/// (Euler alone, matching pre-wire behavior).
|
||||
/// </summary>
|
||||
private uint _cellId;
|
||||
private Func<uint>? _canonicalCellReader;
|
||||
private Action<uint>? _canonicalCellWriter;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
|
||||
/// retain the local backing value. A MovementManager attached to an
|
||||
/// existing projectile delegates reads and writes to
|
||||
/// <see cref="LiveEntityRuntime"/>, so projectile prediction,
|
||||
/// Movement packets, and spatial rebucketing cannot develop competing
|
||||
/// cell identities.
|
||||
/// </summary>
|
||||
public uint CellId
|
||||
{
|
||||
get => _canonicalCellReader?.Invoke() ?? _cellId;
|
||||
set
|
||||
{
|
||||
_cellId = value;
|
||||
_canonicalCellWriter?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
|
||||
{
|
||||
get => LastServerPos;
|
||||
set => LastServerPos = value;
|
||||
}
|
||||
|
||||
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
|
||||
{
|
||||
get => LastServerPosTime;
|
||||
set => LastServerPosTime = value;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
|
||||
{
|
||||
get => LastShadowSyncPos;
|
||||
set => LastShadowSyncPos = value;
|
||||
}
|
||||
|
||||
System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation
|
||||
{
|
||||
get => LastShadowSyncOrientation;
|
||||
set => LastShadowSyncOrientation = value;
|
||||
}
|
||||
|
||||
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
|
||||
{
|
||||
get => Airborne;
|
||||
set => Airborne = value;
|
||||
}
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
|
||||
Movement.HitGround();
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
|
||||
Motion.LeaveGround();
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||||
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
||||
/// non-trivial +Z velocity; cleared when the next UpdatePosition
|
||||
/// snaps to a new ground location. While true, the per-tick
|
||||
/// remote update SKIPS the "force OnWalkable + apply_current_movement"
|
||||
/// step that would otherwise stomp the body's Z velocity each
|
||||
/// frame, AND enables gravity so the parabolic arc actually plays
|
||||
/// out between server snaps.
|
||||
/// </summary>
|
||||
public bool Airborne;
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote position-waypoint queue + catch-up math (retail's
|
||||
/// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset).
|
||||
/// Drives per-tick body translation for grounded player remotes
|
||||
/// via <see cref="Position"/>.
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.InterpolationManager Interp { get; } =
|
||||
new AcDream.Core.Physics.InterpolationManager();
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame combiner for animation root motion + InterpolationManager
|
||||
/// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30:
|
||||
/// queue catch-up REPLACES anim when active; anim stands when queue
|
||||
/// is idle.
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
|
||||
new AcDream.Core.Physics.RemoteMotionCombiner();
|
||||
|
||||
/// <summary>Reusable complete delta frame shared by interpolation,
|
||||
/// Sticky/Constraint, and the final body composition.</summary>
|
||||
public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } =
|
||||
new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||||
/// previous UpdatePosition's world position + timestamp. The per-tick
|
||||
/// path compares the server's broadcast pace with the literal root
|
||||
/// displacement emitted by <c>CSequence::update</c>. The old inferred
|
||||
/// walk/run velocity was the source of the periodic overshoot this
|
||||
/// diagnostic was introduced to find.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 PrevServerPos;
|
||||
public double PrevServerPosTime;
|
||||
public double LastOmegaDiagLogTime;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only: maximum scaled CSequence root-motion speed
|
||||
/// observed since the last UpdatePosition arrival. The next UP
|
||||
/// compares it with the server's observed pace. Reset on each UP.
|
||||
/// </summary>
|
||||
public float MaxRootMotionSpeedSinceLastUP;
|
||||
|
||||
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
|
||||
{
|
||||
Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody
|
||||
{
|
||||
// Remotes don't simulate gravity — server owns Z. Force
|
||||
// Contact + OnWalkable + Active so apply_current_movement
|
||||
// writes velocity through every tick (the gate in
|
||||
// MotionInterpreter.apply_current_movement is
|
||||
// PhysicsObj.OnWalkable).
|
||||
State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions,
|
||||
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active,
|
||||
// R4-V5 door-swing fix: a RemoteMotion exists only for a
|
||||
// WORLD entity — retail's physics_obj->cell is non-null the
|
||||
// moment the object is placed. Without this, the interp's
|
||||
// detached-object guard stripped every dispatched
|
||||
// transition link (see PhysicsBody.InWorld).
|
||||
InWorld = true,
|
||||
};
|
||||
// R5-V5: the interp is owned by the MovementManager facade
|
||||
// (retail CPhysicsObj::movement_manager -> motion_interpreter).
|
||||
// acdream constructs it eagerly here — retail's lazy-create
|
||||
// window is never observable (see MovementManager's class doc).
|
||||
Movement = new AcDream.Core.Physics.Motion.MovementManager(
|
||||
new AcDream.Core.Physics.MotionInterpreter(Body)
|
||||
{
|
||||
// R4-V5 #160 fix: retail remotes carry a weenie whose
|
||||
// InqRunRate FAILS, landing apply_run_to_command on
|
||||
// my_run_rate (the M13 wire feed). A null weenie took the
|
||||
// degenerate 1.0 branch — observer-side run movetos played
|
||||
// and moved in slow motion. See RemoteWeenie.
|
||||
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
|
||||
});
|
||||
}
|
||||
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
_canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read));
|
||||
_canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
|
||||
/// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a
|
||||
/// 2m residual fades within ~300ms without visible snap.</summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue