345 lines
16 KiB
C#
345 lines
16 KiB
C#
using AcDream.App.World;
|
|
|
|
namespace AcDream.App.Physics;
|
|
|
|
/// <summary>
|
|
/// Per-remote-entity physics + motion stack — verbatim application of
|
|
/// retail's client-side motion pipeline to every remote. Mirrors retail
|
|
/// <c>CPhysicsObj::update_object</c> (0x00515D10) →
|
|
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) →
|
|
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30) →
|
|
/// <c>CPhysicsObj::UpdatePhysicsInternal</c> (0x00510700), 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,
|
|
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer
|
|
{
|
|
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 through LiveEntityRuntime's
|
|
// exact-incarnation PhysicsHost slot.
|
|
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
|
|
private bool _fullPhysicsHostBound;
|
|
public EntityPhysicsHost? Host => _fullPhysicsHostBound
|
|
? _physicsHostReader?.Invoke() as EntityPhysicsHost
|
|
: null;
|
|
/// <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 BindCanonicalRuntime(
|
|
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
|
|
Func<uint> readCell,
|
|
Action<uint> writeCell)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(readPhysicsHost);
|
|
ArgumentNullException.ThrowIfNull(readCell);
|
|
ArgumentNullException.ThrowIfNull(writeCell);
|
|
if (_physicsHostReader is not null
|
|
|| _canonicalCellReader is not null
|
|
|| _canonicalCellWriter is not null)
|
|
{
|
|
throw new InvalidOperationException("The remote canonical runtime context is already bound.");
|
|
}
|
|
|
|
// All validation precedes publication. Nothing below can throw, so a
|
|
// rejected context never leaves a half-bound reusable component.
|
|
_physicsHostReader = readPhysicsHost;
|
|
_canonicalCellReader = readCell;
|
|
_canonicalCellWriter = writeCell;
|
|
}
|
|
|
|
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(read);
|
|
ArgumentNullException.ThrowIfNull(write);
|
|
if (_canonicalCellReader is not null || _canonicalCellWriter is not null)
|
|
throw new InvalidOperationException("The remote canonical cell source is already bound.");
|
|
_canonicalCellReader = read;
|
|
_canonicalCellWriter = write;
|
|
}
|
|
|
|
public void MarkFullPhysicsHostBound()
|
|
{
|
|
if (_physicsHostReader is null)
|
|
throw new InvalidOperationException("The canonical physics-host source is not bound.");
|
|
_fullPhysicsHostBound = true;
|
|
}
|
|
}
|