refactor(runtime): own per-session physics simulation
Move the sole PhysicsEngine, production cache, collision admissions, canonical bodies and hosts, remote components, ordinary/remote worksets, simulation, cell commits, and shadow synchronization under RuntimeEntityObjectLifetime. Keep App as the prepared-asset, animation-input, and render-projection adapter while preserving the named-retail update and collision order. Add exact-incarnation, object-clock, callback-reentrancy, GUID-reuse, two-runtime isolation, source ownership, collision publication, and graphical projection coverage. Release build and the complete 8,588-test solution pass. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
0dc3bfdeff
commit
7e6033d0ad
39 changed files with 3685 additions and 1722 deletions
|
|
@ -1,344 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <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 stored on the exact <c>LiveEntityRecord</c>, 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>R5-V3: owns a <see cref="PositionManager"/> too (retail
|
||||
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
|
||||
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
|
||||
/// behaviorally identical because the empty facade no-ops until its first
|
||||
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
|
||||
/// deliveries to the injected MoveToManager fan FIRST, then the
|
||||
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
|
||||
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
|
||||
/// @0x00512c1a).</para>
|
||||
/// </summary>
|
||||
public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||
{
|
||||
private Func<Position> _getPosition;
|
||||
private Func<Vector3> _getVelocity;
|
||||
private Func<float> _getRadius;
|
||||
private Func<bool> _inContact;
|
||||
private Func<float?> _minterpMaxSpeed;
|
||||
private Func<double> _curTime;
|
||||
private Func<double> _physicsTimerTime;
|
||||
private Func<uint, IPhysicsObjHost?> _getObjectA;
|
||||
private Action<TargetInfo> _handleUpdateTarget;
|
||||
private 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 = null!;
|
||||
_getVelocity = null!;
|
||||
_getRadius = null!;
|
||||
_inContact = null!;
|
||||
_minterpMaxSpeed = null!;
|
||||
_curTime = null!;
|
||||
_physicsTimerTime = null!;
|
||||
_getObjectA = null!;
|
||||
_handleUpdateTarget = null!;
|
||||
_interruptCurrentMovement = null!;
|
||||
Rebind(
|
||||
getPosition,
|
||||
getVelocity,
|
||||
getRadius,
|
||||
inContact,
|
||||
minterpMaxSpeed,
|
||||
curTime,
|
||||
physicsTimerTime,
|
||||
getObjectA,
|
||||
handleUpdateTarget,
|
||||
interruptCurrentMovement);
|
||||
_targetManager = new TargetManager(this);
|
||||
PositionManager = new PositionManager(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebinds the changing CPhysicsObj access seams without replacing this
|
||||
/// object's retail-lifetime managers. A static/minimal live object can gain
|
||||
/// a MovementManager later, and the local player controller can be rebuilt,
|
||||
/// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager
|
||||
/// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and
|
||||
/// constraint state therefore survives the upgrade.
|
||||
/// </summary>
|
||||
private void Rebind(
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(getPosition);
|
||||
ArgumentNullException.ThrowIfNull(getVelocity);
|
||||
ArgumentNullException.ThrowIfNull(getRadius);
|
||||
ArgumentNullException.ThrowIfNull(inContact);
|
||||
ArgumentNullException.ThrowIfNull(minterpMaxSpeed);
|
||||
ArgumentNullException.ThrowIfNull(curTime);
|
||||
ArgumentNullException.ThrowIfNull(physicsTimerTime);
|
||||
ArgumentNullException.ThrowIfNull(getObjectA);
|
||||
ArgumentNullException.ThrowIfNull(handleUpdateTarget);
|
||||
ArgumentNullException.ThrowIfNull(interruptCurrentMovement);
|
||||
|
||||
_getPosition = getPosition;
|
||||
_getVelocity = getVelocity;
|
||||
_getRadius = getRadius;
|
||||
_inContact = inContact;
|
||||
_minterpMaxSpeed = minterpMaxSpeed;
|
||||
_curTime = curTime;
|
||||
_physicsTimerTime = physicsTimerTime;
|
||||
_getObjectA = getObjectA;
|
||||
_handleUpdateTarget = handleUpdateTarget;
|
||||
_interruptCurrentMovement = interruptCurrentMovement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a freshly composed delegate configuration while retaining this
|
||||
/// host's exact identity and manager instances.
|
||||
/// </summary>
|
||||
private void RebindFrom(EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (configuration.Id != Id)
|
||||
throw new ArgumentException(
|
||||
"A physics-host configuration must match the existing host GUID.",
|
||||
nameof(configuration));
|
||||
|
||||
Rebind(
|
||||
configuration._getPosition,
|
||||
configuration._getVelocity,
|
||||
configuration._getRadius,
|
||||
configuration._inContact,
|
||||
configuration._minterpMaxSpeed,
|
||||
configuration._curTime,
|
||||
configuration._physicsTimerTime,
|
||||
configuration._getObjectA,
|
||||
configuration._handleUpdateTarget,
|
||||
configuration._interruptCurrentMovement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the first host for an exact live-object incarnation, or
|
||||
/// enriches that incarnation's existing minimal host in place. This is the
|
||||
/// shared remote/player composition seam; it deliberately never replaces
|
||||
/// TargetManager or PositionManager ownership.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost InstallOrRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition.");
|
||||
}
|
||||
|
||||
if (current.PhysicsHost is null)
|
||||
{
|
||||
runtime.InstallPhysicsHost(current, configuration);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
if (current.PhysicsHost is not EntityPhysicsHost existing)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation.");
|
||||
}
|
||||
|
||||
existing.RebindFrom(configuration);
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an exact incarnation and returns the manager-stable host that
|
||||
/// a later <see cref="InstallOrRebind"/> will publish, without changing any
|
||||
/// live delegates. Player-mode construction uses this preparation edge so
|
||||
/// DAT/physics/camera failures cannot expose a half-rebound host.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation.");
|
||||
}
|
||||
|
||||
return current.PhysicsHost switch
|
||||
{
|
||||
null => configuration,
|
||||
EntityPhysicsHost existing => existing,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the position-only CPhysicsObj facade used before an animated or
|
||||
/// player movement owner exists. The delegates capture the exact live
|
||||
/// record rather than the visible-world projection, so exit_world
|
||||
/// notifications retain the object's last cell and frame after the picker
|
||||
/// view has already withdrawn it.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost CreateMinimal(
|
||||
LiveEntityRecord record,
|
||||
Func<uint, IPhysicsObjHost?> resolve,
|
||||
Func<double> now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
ArgumentNullException.ThrowIfNull(now);
|
||||
return new EntityPhysicsHost(
|
||||
record.ServerGuid,
|
||||
getPosition: () => new Position(
|
||||
record.FullCellId,
|
||||
record.WorldEntity?.Position ?? Vector3.Zero,
|
||||
record.WorldEntity?.Rotation ?? Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => null,
|
||||
curTime: now,
|
||||
physicsTimerTime: now,
|
||||
getObjectA: resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
|
||||
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
|
||||
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
|
||||
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
|
||||
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
|
||||
public PositionManager PositionManager { get; }
|
||||
|
||||
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
|
||||
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
|
||||
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
|
||||
_targetManager.GetRelationshipTarget(objectId);
|
||||
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
|
||||
// MovementManager (the injected MoveToManager fan) first, then
|
||||
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
|
||||
_handleUpdateTarget(info);
|
||||
PositionManager.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, IPhysicsObjHost sender) =>
|
||||
_targetManager.ReceiveUpdate(info, sender);
|
||||
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
|
||||
=> _targetManager.AddVoyeur(watcher, radius, quantum);
|
||||
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
|
||||
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
|
||||
|
||||
// ── 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);
|
||||
|
||||
/// <summary>
|
||||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
/// </summary>
|
||||
public void NotifyHidden() =>
|
||||
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
|
||||
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
|
||||
/// non-Ok teardown path). Called after a teleport placement.</summary>
|
||||
public void NotifyTeleported()
|
||||
{
|
||||
_targetManager.ClearTarget();
|
||||
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||
}
|
||||
}
|
||||
82
src/AcDream.App/Physics/EntityPhysicsHostComposition.cs
Normal file
82
src/AcDream.App/Physics/EntityPhysicsHostComposition.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Graphical configuration factory for Runtime's presentation-free
|
||||
/// <see cref="EntityPhysicsHost"/>. Runtime owns exact-incarnation
|
||||
/// installation, stable identity, manager lifetime, and lookup.
|
||||
/// </summary>
|
||||
internal static class EntityPhysicsHostComposition
|
||||
{
|
||||
internal static EntityPhysicsHost InstallOrRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
bool ProjectionIsCurrent() =>
|
||||
runtime.TryGetRecord(
|
||||
expectedRecord.ServerGuid,
|
||||
out LiveEntityRecord current)
|
||||
&& ReferenceEquals(current, expectedRecord);
|
||||
return runtime.Physics.InstallOrRebindPhysicsHost(
|
||||
expectedRecord.Canonical,
|
||||
configuration,
|
||||
ProjectionIsCurrent);
|
||||
}
|
||||
|
||||
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
bool ProjectionIsCurrent() =>
|
||||
runtime.TryGetRecord(
|
||||
expectedRecord.ServerGuid,
|
||||
out LiveEntityRecord current)
|
||||
&& ReferenceEquals(current, expectedRecord);
|
||||
return runtime.Physics.SelectStablePhysicsHostWithoutRebind(
|
||||
expectedRecord.Canonical,
|
||||
configuration,
|
||||
ProjectionIsCurrent);
|
||||
}
|
||||
|
||||
internal static EntityPhysicsHost CreateMinimal(
|
||||
LiveEntityRecord record,
|
||||
Func<uint, IPhysicsObjHost?> resolve,
|
||||
Func<double> now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
ArgumentNullException.ThrowIfNull(now);
|
||||
return new EntityPhysicsHost(
|
||||
record.ServerGuid,
|
||||
getPosition: () => new Position(
|
||||
record.FullCellId,
|
||||
record.WorldEntity?.Position
|
||||
?? record.PhysicsBody?.Position
|
||||
?? Vector3.Zero,
|
||||
record.WorldEntity?.Rotation
|
||||
?? record.PhysicsBody?.Orientation
|
||||
?? Quaternion.Identity),
|
||||
getVelocity: () => record.PhysicsBody?.Velocity ?? Vector3.Zero,
|
||||
getRadius: () => 0f,
|
||||
inContact: () => record.PhysicsBody?.InContact ?? true,
|
||||
minterpMaxSpeed: () => null,
|
||||
curTime: now,
|
||||
physicsTimerTime: now,
|
||||
getObjectA: resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
}
|
||||
}
|
||||
|
|
@ -181,7 +181,7 @@ internal sealed class LiveEntityMotionRuntimeController
|
|||
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
|
||||
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
|
||||
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||||
host = EntityPhysicsHost.InstallOrRebind(
|
||||
host = EntityPhysicsHostComposition.InstallOrRebind(
|
||||
liveEntities,
|
||||
hostRecord,
|
||||
configuredHost);
|
||||
|
|
@ -242,7 +242,7 @@ internal sealed class LiveEntityMotionRuntimeController
|
|||
return existing;
|
||||
|
||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
var minimal = EntityPhysicsHost.CreateMinimal(
|
||||
var minimal = EntityPhysicsHostComposition.CreateMinimal(
|
||||
activeRecord,
|
||||
ResolvePhysicsHost,
|
||||
NowSeconds);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,29 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the body-backed, manager-less branch of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0). A retained canonical
|
||||
/// body can temporarily outlive its RemoteMotion or projectile component; it
|
||||
/// must still compose the complete PartArray Frame, integrate physics, sweep
|
||||
/// through Transition, commit its cell, and move its collision shadow.
|
||||
/// Projects the presentation-free Runtime result of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) into App's
|
||||
/// <see cref="WorldEntity"/> and spatial buckets.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly RuntimeOrdinaryPhysicsUpdater _runtime;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
|
||||
_getSetupCylinder;
|
||||
|
||||
public LiveEntityOrdinaryPhysicsUpdater(
|
||||
PhysicsEngine physics,
|
||||
RuntimePhysicsState physics,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_runtime = new RuntimeOrdinaryPhysicsUpdater(
|
||||
physics ?? throw new ArgumentNullException(nameof(physics)));
|
||||
_getSetupCylinder = getSetupCylinder
|
||||
?? throw new ArgumentNullException(nameof(getSetupCylinder));
|
||||
}
|
||||
|
|
@ -47,165 +46,47 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
|||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(rootFrame);
|
||||
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
|
||||
if (record.PhysicsBody is not { } body
|
||||
|| !IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
{
|
||||
if (record.PhysicsBody is not { } body)
|
||||
return false;
|
||||
}
|
||||
|
||||
body.State = record.FinalPhysicsState;
|
||||
Vector3 priorPosition = body.Position;
|
||||
Quaternion priorOrientation = body.Orientation;
|
||||
bool previousContact = body.InContact;
|
||||
bool previousOnWalkable = body.OnWalkable;
|
||||
|
||||
// UpdatePositionInternal 0x00512CA1: PartArray root translation is
|
||||
// scaled only while transient OnWalkable is set. Its orientation stays
|
||||
// in the complete Frame and composes regardless of that translation
|
||||
// gate.
|
||||
Vector3 candidatePosition = priorPosition;
|
||||
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
|
||||
{
|
||||
candidatePosition += Vector3.Transform(
|
||||
rootFrame.Origin * objectScale,
|
||||
priorOrientation);
|
||||
}
|
||||
|
||||
Quaternion candidateOrientation = priorOrientation;
|
||||
if (!rootFrame.Orientation.IsIdentity)
|
||||
{
|
||||
candidateOrientation = FrameOps.SetRotate(
|
||||
candidatePosition,
|
||||
priorOrientation,
|
||||
priorOrientation * rootFrame.Orientation);
|
||||
}
|
||||
|
||||
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
|
||||
body.calc_acceleration();
|
||||
body.UpdatePhysicsInternal(quantum);
|
||||
// Omega integration writes Orientation directly; mirror it into the
|
||||
// retained Position frame before Transition consumes the candidate.
|
||||
body.SetFrameInCurrentCell(body.Position, body.Orientation);
|
||||
|
||||
// UpdatePositionInternal processes PartArray hooks after physics but
|
||||
// before UpdateObjectInternal performs its transition sweep.
|
||||
if (sequencer is not null)
|
||||
captureAnimationHooks(entity.Id, sequencer);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
Vector3 integratedPosition = body.Position;
|
||||
uint sourceCellId = record.FullCellId;
|
||||
uint resolvedCellId = sourceCellId;
|
||||
bool frameChanged = integratedPosition != priorPosition
|
||||
|| body.Orientation != priorOrientation;
|
||||
var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
|
||||
bool ExternalOwnerValid() =>
|
||||
IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch);
|
||||
|
||||
if (integratedPosition != priorPosition
|
||||
&& sourceCellId != 0
|
||||
&& radius >= 0.05f
|
||||
&& _physics.LandblockCount > 0)
|
||||
{
|
||||
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||
priorPosition,
|
||||
integratedPosition,
|
||||
sourceCellId,
|
||||
if (!_runtime.TryBegin(
|
||||
record.Canonical,
|
||||
rootFrame,
|
||||
objectScale,
|
||||
quantum,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: IsPlayerGuid(record.ServerGuid)
|
||||
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
|
||||
: ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: entity.Id);
|
||||
objectClockEpoch,
|
||||
sequencer,
|
||||
captureAnimationHooks,
|
||||
ExternalOwnerValid,
|
||||
out RuntimeOrdinaryPhysicsCommit commit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolved.Ok)
|
||||
return _runtime.Complete(
|
||||
commit,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
snapshot =>
|
||||
{
|
||||
resolvedCellId = resolved.CellId != 0
|
||||
? resolved.CellId
|
||||
: sourceCellId;
|
||||
body.CommitTransitionPosition(resolvedCellId, resolved.Position);
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable);
|
||||
body.CachedVelocity = quantum > 0f
|
||||
? (body.Position - priorPosition) / quantum
|
||||
: Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
// transition() returned null: UpdateObjectInternal keeps the
|
||||
// integrated frame in the current cell and reports no realized
|
||||
// transition velocity.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The no-PartArray-sphere arm calls set_frame and writes zero
|
||||
// cached_velocity rather than fabricating a collision shape.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
if (!ExternalOwnerValid())
|
||||
return false;
|
||||
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != sourceCellId
|
||||
&& !runtime.RebucketLiveEntity(record.ServerGuid, resolvedCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
if (frameChanged && record.IsSpatiallyVisible && record.FullCellId != 0)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.ShadowObjects,
|
||||
entity.Id,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
|
||||
return IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch);
|
||||
entity.SetPosition(snapshot.Position);
|
||||
entity.Rotation = snapshot.Orientation;
|
||||
entity.ParentCellId = snapshot.FullCellId;
|
||||
return ExternalOwnerValid();
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsCurrent(
|
||||
|
|
@ -220,7 +101,4 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
|||
&& ReferenceEquals(record.PhysicsBody, body)
|
||||
&& record.RemoteMotionRuntime is null
|
||||
&& record.ProjectileRuntime is null;
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
|
||||
using RemoteMotion = AcDream.Runtime.Physics.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,345 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +0,0 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared host adapter for retail's moved-object shadow re-registration
|
||||
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x00515330). Both the local body
|
||||
/// and remote dead-reckoning bodies publish their resolved position through
|
||||
/// this one cell-offset calculation.
|
||||
/// </summary>
|
||||
internal static class ShadowPositionSynchronizer
|
||||
{
|
||||
public static void Sync(
|
||||
ShadowObjectRegistry registry,
|
||||
uint entityId,
|
||||
Vector3 position,
|
||||
Quaternion orientation,
|
||||
uint cellId,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
if (cellId == 0)
|
||||
return;
|
||||
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
float worldOffsetX = (landblockX - liveCenterX) * 192f;
|
||||
float worldOffsetY = (landblockY - liveCenterY) * 192f;
|
||||
registry.UpdatePosition(
|
||||
entityId,
|
||||
position,
|
||||
orientation,
|
||||
worldOffsetX,
|
||||
worldOffsetY,
|
||||
cellId,
|
||||
seedCellId: cellId);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue