refactor(runtime): own projectile simulation

Move projectile component identity, prediction invalidation, spatial worksets, authoritative corrections, and the retail physics step into AcDream.Runtime. Keep App as the DAT-shape and presentation adapter so ACE outcomes and visible behavior remain unchanged.
This commit is contained in:
Erik 2026-07-26 14:17:42 +02:00
parent fce8e7b18e
commit 2aee33569f
19 changed files with 1255 additions and 335 deletions

View file

@ -437,7 +437,6 @@ internal sealed class LivePresentationCompositionPhase
var projectileController = new ProjectileController( var projectileController = new ProjectileController(
liveEntities, liveEntities,
d.PhysicsEngine,
new DatProjectileSetupResolver(content.Dats, d.DatLock), new DatProjectileSetupResolver(content.Dats, d.DatLock),
new EntityRootPosePublisher(d.EffectPoses), new EntityRootPosePublisher(d.EffectPoses),
d.WorldOrigin) d.WorldOrigin)

View file

@ -35,51 +35,30 @@ internal sealed class DatProjectileSetupResolver : IProjectileSetupResolver
} }
/// <summary> /// <summary>
/// Update-thread presentation owner for live retail missiles. Logical identity, /// Update-thread presentation adapter for live retail missiles. Runtime owns
/// generation, accepted network state, and teardown remain owned by /// logical identity, the projectile component, prediction versions, the
/// <see cref="LiveEntityRuntime"/>; this controller only advances the /// canonical body/cell, and the split simulation transaction. This adapter
/// <see cref="PhysicsBody"/> attached to that canonical record and commits its /// prepares the DAT Setup sphere, preserves animation-hook ordering, and
/// frame through the existing <see cref="WorldEntity"/> projection. /// projects committed frames into <see cref="WorldEntity"/> and effect poses.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Retail anchors are <c>CPhysicsObj::update_object</c> <c>0x00515D10</c>, /// Retail anchors are <c>CPhysicsObj::update_object</c> <c>0x00515D10</c>,
/// <c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c>, and /// <c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c>, and
/// <c>CPhysicsObj::SetPositionInternal</c> <c>0x00515330</c>. The pure /// <c>CPhysicsObj::SetPositionInternal</c> <c>0x00515330</c>. The pure
/// integration/sweep is implemented by <see cref="ProjectilePhysicsStepper"/>. /// integration/sweep is invoked by Runtime through
/// <see cref="RuntimeProjectilePhysicsUpdater"/>.
/// ACE remains authoritative for collision outcomes, damage, effects, and /// ACE remains authoritative for collision outcomes, damage, effects, and
/// deletion; no such events are synthesized here. /// deletion; no such events are synthesized here.
/// </remarks> /// </remarks>
internal sealed class ProjectileController internal sealed class ProjectileController
{ {
internal sealed class Runtime : ILiveEntityProjectileRuntime
{
internal Runtime(
ushort generation,
PhysicsBody body,
ProjectileCollisionSphere collisionSphere)
{
Generation = generation;
Body = body;
CollisionSphere = collisionSphere;
}
internal ushort Generation { get; }
public PhysicsBody Body { get; }
internal ProjectileCollisionSphere CollisionSphere { get; }
internal ulong PredictionAuthorityVersion { get; private set; }
internal void InvalidatePrediction() =>
PredictionAuthorityVersion++;
}
internal readonly record struct QuantumStep( internal readonly record struct QuantumStep(
LiveEntityRecord Record, LiveEntityRecord Record,
Runtime Runtime,
WorldEntity Entity, WorldEntity Entity,
ProjectileQuantumPreparation Preparation, RuntimeProjectilePhysicsCommit RuntimeCommit);
ulong PredictionAuthorityVersion);
private readonly LiveEntityRuntime _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly ProjectilePhysicsStepper _stepper; private readonly RuntimeProjectilePhysicsUpdater _runtimeUpdater;
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly IProjectileSetupResolver? _setupResolver; private readonly IProjectileSetupResolver? _setupResolver;
private readonly IEntityRootPosePublisher? _rootPoses; private readonly IEntityRootPosePublisher? _rootPoses;
@ -89,15 +68,14 @@ internal sealed class ProjectileController
internal ProjectileController( internal ProjectileController(
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
PhysicsEngine physicsEngine,
IProjectileSetupResolver? setupResolver = null, IProjectileSetupResolver? setupResolver = null,
IEntityRootPosePublisher? rootPoses = null, IEntityRootPosePublisher? rootPoses = null,
LiveWorldOriginState? origin = null) LiveWorldOriginState? origin = null)
{ {
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
ArgumentNullException.ThrowIfNull(physicsEngine); _runtimeUpdater = new RuntimeProjectilePhysicsUpdater(
_stepper = new ProjectilePhysicsStepper(physicsEngine); liveEntities.Physics);
_shadows = physicsEngine.ShadowObjects; _shadows = liveEntities.Physics.Engine.ShadowObjects;
_setupResolver = setupResolver; _setupResolver = setupResolver;
_rootPoses = rootPoses; _rootPoses = rootPoses;
_origin = origin; _origin = origin;
@ -107,7 +85,7 @@ internal sealed class ProjectileController
internal Action<string>? DiagnosticSink { get; set; } internal Action<string>? DiagnosticSink { get; set; }
internal int Count => _liveEntities.Records.Count( internal int Count => _liveEntities.Records.Count(
static record => record.ProjectileRuntime is Runtime); static record => record.ProjectileRuntime is not null);
internal bool CanAcceptVectorPayload( internal bool CanAcceptVectorPayload(
uint serverGuid, uint serverGuid,
@ -167,7 +145,7 @@ internal sealed class ProjectileController
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
return false; return false;
if (record.ProjectileRuntime is Runtime retained) if (record.ProjectileRuntime is RuntimeProjectile retained)
{ {
retained.Body.State = record.FinalPhysicsState; retained.Body.State = record.FinalPhysicsState;
return true; return true;
@ -261,7 +239,7 @@ internal sealed class ProjectileController
// Claim the incarnation's one CPhysicsObj before Rebucket can // Claim the incarnation's one CPhysicsObj before Rebucket can
// publish visibility callbacks. A callback that also needs a body // publish visibility callbacks. A callback that also needs a body
// must observe and adopt this same instance, never race a private // must observe and adopt this same instance, never race a private
// projectile candidate into SetProjectileRuntime. // projectile candidate into Runtime's canonical component slot.
body = _liveEntities.GetOrCreatePhysicsBody( body = _liveEntities.GetOrCreatePhysicsBody(
record.ServerGuid, record.ServerGuid,
_ => _ =>
@ -308,7 +286,7 @@ internal sealed class ProjectileController
// the one projectile component before this outer call resumes. Adopt // the one projectile component before this outer call resumes. Adopt
// that component; never replace it with a second Runtime that merely // that component; never replace it with a second Runtime that merely
// happens to share the canonical CPhysicsObj body. // happens to share the canonical CPhysicsObj body.
if (currentRecord.ProjectileRuntime is Runtime concurrentRuntime) if (currentRecord.ProjectileRuntime is RuntimeProjectile concurrentRuntime)
return ReferenceEquals(concurrentRuntime.Body, body); return ReferenceEquals(concurrentRuntime.Body, body);
// A newer Position can legally arrive from a projection callback // A newer Position can legally arrive from a projection callback
@ -317,11 +295,14 @@ internal sealed class ProjectileController
// canonical body frame, never the pre-callback cell captured above. // canonical body frame, never the pre-callback cell captured above.
canonicalCellId = body.CellPosition.ObjCellId; canonicalCellId = body.CellPosition.ObjCellId;
var runtime = new Runtime( RuntimeProjectile runtime = (RuntimeProjectile)
record.Generation, _liveEntities.BindProjectileRuntime(
body, record.ServerGuid,
sphere); body,
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime); sphere,
() => _liveEntities.IsCurrentRecord(record)
&& ReferenceEquals(record.WorldEntity, entity)
&& ReferenceEquals(record.PhysicsBody, body));
if (HasVisibleCell(record) && !IsHidden(record)) if (HasVisibleCell(record) && !IsHidden(record))
{ {
@ -374,7 +355,7 @@ internal sealed class ProjectileController
{ {
ArgumentNullException.ThrowIfNull(expectedRecord); ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid; uint serverGuid = expectedRecord.ServerGuid;
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)) if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime))
return false; return false;
if (!ReferenceEquals(record, expectedRecord) if (!ReferenceEquals(record, expectedRecord)
|| record.VectorAuthorityVersion != expectedVectorAuthorityVersion || record.VectorAuthorityVersion != expectedVectorAuthorityVersion
@ -400,17 +381,19 @@ internal sealed class ProjectileController
} }
_lastFiniteGameTime = currentTime; _lastFiniteGameTime = currentTime;
runtime.InvalidatePrediction(); return _runtimeUpdater.ApplyAuthoritativeVector(
if (!_liveEntities.TryCommitAuthoritativeVector( record.Canonical,
record, expectedVectorAuthorityVersion,
runtime.Body, expectedVelocityAuthorityVersion,
velocity, velocity,
angularVelocity, angularVelocity,
currentTime)) currentTime,
{ () => TryGetCurrent(
return true; serverGuid,
} out LiveEntityRecord current,
return true; out RuntimeProjectile currentRuntime)
&& ReferenceEquals(current, record)
&& ReferenceEquals(currentRuntime, runtime));
} }
internal bool ApplyAuthoritativeState( internal bool ApplyAuthoritativeState(
@ -453,34 +436,22 @@ internal sealed class ProjectileController
DiagnosticSink?.Invoke( DiagnosticSink?.Invoke(
$"Ignored invalid State update clock for live object 0x{serverGuid:X8}; state remains authoritative."); $"Ignored invalid State update clock for live object 0x{serverGuid:X8}; state remains authoritative.");
if (record.ProjectileRuntime is Runtime runtime) if (record.ProjectileRuntime is RuntimeProjectile runtime)
{ {
runtime.InvalidatePrediction(); return _runtimeUpdater.ApplyAuthoritativeState(
bool wasMissile = record.Canonical,
(runtime.Body.State & PhysicsStateFlags.Missile) != 0; expectedStateAuthorityVersion,
runtime.Body.State = state; state,
if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile) effectiveClock,
{ liveCenterX,
// Ownership transfers back from generic remote motion to the liveCenterY,
// absolute projectile clock. State alone must not activate, () => _liveEntities.TryGetRecord(
// but an already-active body keeps moving from now rather serverGuid,
// than replaying the ordinary-motion interval. A malformed out LiveEntityRecord current)
// local receipt clock is not retail state and cannot clear && ReferenceEquals(current, record)
// Active; translate to the last finite App clock instead. && ReferenceEquals(
runtime.Body.LastUpdateTime = effectiveClock; current.ProjectileRuntime,
if (record.FullCellId != 0) runtime));
{
runtime.Body.SnapToCell(
record.FullCellId,
runtime.Body.Position,
CellLocalFromWorld(
runtime.Body.Position,
record.FullCellId,
liveCenterX,
liveCenterY));
}
}
return true;
} }
// SetState applies to the canonical CPhysicsObj independently of // SetState applies to the canonical CPhysicsObj independently of
@ -552,7 +523,7 @@ internal sealed class ProjectileController
{ {
ArgumentNullException.ThrowIfNull(expectedRecord); ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid; uint serverGuid = expectedRecord.ServerGuid;
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime)
|| !ReferenceEquals(record, expectedRecord) || !ReferenceEquals(record, expectedRecord)
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion || record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
@ -574,76 +545,39 @@ internal sealed class ProjectileController
} }
_lastFiniteGameTime = currentTime; _lastFiniteGameTime = currentTime;
PhysicsBody body = runtime.Body; bool ExternalOwnerValid() =>
runtime.InvalidatePrediction(); TryGetCurrent(
bool wasInWorld = body.InWorld; serverGuid,
body.Orientation = orientation; out LiveEntityRecord current,
body.SnapToCell(fullCellId, worldPosition, cellLocalPosition); out RuntimeProjectile currentRuntime)
body.State = record.FinalPhysicsState; && ReferenceEquals(current, record)
// PositionPack::UnPack initializes an absent velocity to zero and && ReferenceEquals(currentRuntime, runtime)
// MoveOrTeleport installs that exact vector through set_velocity. && ReferenceEquals(current.WorldEntity, entity)
// Apply it to this retained CPhysicsObj before any callback can && current.PositionAuthorityVersion
// displace the incarnation; a replacement record owns another body. == expectedPositionAuthorityVersion;
if (record.VelocityAuthorityVersion == expectedVelocityAuthorityVersion return _runtimeUpdater.ApplyAuthoritativePosition(
&& !_liveEntities.TryCommitAuthoritativeVelocity( record.Canonical,
record, expectedPositionAuthorityVersion,
body, expectedVelocityAuthorityVersion,
velocity, worldPosition,
currentTime)) cellLocalPosition,
{ orientation,
return true; velocity,
} fullCellId,
uint canonicalCellId = body.CellPosition.ObjCellId; currentTime,
liveCenterX,
entity.SetPosition(worldPosition); liveCenterY,
entity.Rotation = orientation; snapshot =>
entity.ParentCellId = canonicalCellId;
if (!_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId)
|| !TryGetCurrent(serverGuid, out LiveEntityRecord afterRebucket, out Runtime afterRuntime)
|| !ReferenceEquals(afterRebucket, record)
|| !ReferenceEquals(afterRuntime, runtime)
|| !ReferenceEquals(afterRebucket.WorldEntity, entity)
|| afterRebucket.PositionAuthorityVersion
!= expectedPositionAuthorityVersion)
{
// This packet was a projectile correction when admitted. A
// re-entrant observer displaced that incarnation; report handled
// so the generic remote path cannot mutate the replacement.
return true;
}
if (HasVisibleCell(record) && !IsHidden(record))
{
if (!wasInWorld)
{ {
// prepare_to_enter_world rebases the CPhysicsObj timestamp in if (!ExternalOwnerValid())
// the same operation that restores Active. The record clock is return false;
// canonical, but keep this legacy body field synchronized for entity.SetPosition(snapshot.Position);
// the absolute-time Core API and diagnostics. entity.Rotation = snapshot.Orientation;
body.LastUpdateTime = currentTime; entity.ParentCellId = snapshot.FullCellId;
Activate(body, currentTime); _rootPoses?.UpdateRoot(entity);
} return ExternalOwnerValid();
body.InWorld = true; },
ShadowPositionSynchronizer.Sync( ExternalOwnerValid);
_shadows,
entity.Id,
worldPosition,
orientation,
canonicalCellId,
liveCenterX,
liveCenterY);
}
else if (HasVisibleCell(record))
{
body.InWorld = true;
body.LastUpdateTime = currentTime;
_shadows.Suspend(entity.Id);
}
else
{
SuspendOutsideWorld(runtime, entity.Id);
}
_rootPoses?.UpdateRoot(entity);
return true;
} }
/// <summary> /// <summary>
@ -660,7 +594,7 @@ internal sealed class ProjectileController
internal bool TryGetBody(uint serverGuid, out PhysicsBody body) internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
{ {
if (TryGetCurrent(serverGuid, out _, out Runtime runtime)) if (TryGetCurrent(serverGuid, out _, out RuntimeProjectile runtime))
{ {
body = runtime.Body; body = runtime.Body;
return true; return true;
@ -672,8 +606,7 @@ internal sealed class ProjectileController
internal bool LeaveWorld(LiveEntityRecord record) internal bool LeaveWorld(LiveEntityRecord record)
{ {
ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record);
if (record.ProjectileRuntime is not Runtime runtime if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| runtime.Generation != record.Generation
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return false; return false;
SuspendOutsideWorld(runtime, entity.Id); SuspendOutsideWorld(runtime, entity.Id);
@ -712,8 +645,7 @@ internal sealed class ProjectileController
_liveEntities.CopySpatialProjectileRecordsTo(_spatialProjectileSnapshot); _liveEntities.CopySpatialProjectileRecordsTo(_spatialProjectileSnapshot);
foreach (LiveEntityRecord record in _spatialProjectileSnapshot) foreach (LiveEntityRecord record in _spatialProjectileSnapshot)
{ {
if (record.ProjectileRuntime is not Runtime runtime if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| runtime.Generation != record.Generation
|| !_liveEntities.IsCurrentSpatialProjectile(record, runtime) || !_liveEntities.IsCurrentSpatialProjectile(record, runtime)
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
continue; continue;
@ -847,30 +779,34 @@ internal sealed class ProjectileController
{ {
ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record);
step = default; step = default;
if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime runtime) if (!TryGetCurrent(
record.ServerGuid,
out LiveEntityRecord current,
out RuntimeProjectile runtime)
|| !ReferenceEquals(record, current) || !ReferenceEquals(record, current)
|| current.WorldEntity is not { } entity || current.WorldEntity is not { } entity
|| IsHidden(current) || IsHidden(current)
|| !HasVisibleCell(current)) || !HasVisibleCell(current))
return false; return false;
runtime.Body.State = current.FinalPhysicsState; bool ExternalOwnerValid() =>
bool isParented = current.Snapshot.ParentGuid is not null _liveEntities.IsCurrentRecord(current)
|| current.Snapshot.Physics?.Parent is not null; && ReferenceEquals(current.WorldEntity, entity)
ProjectileQuantumPreparation preparation = _stepper.BeginQuantum( && ReferenceEquals(current.ProjectileRuntime, runtime);
runtime.Body, if (!_runtimeUpdater.TryBegin(
quantum, current.Canonical,
current.FullCellId, quantum,
runtime.CollisionSphere, current.ObjectClockEpoch,
isParented); ExternalOwnerValid,
if (!preparation.Simulated) out RuntimeProjectilePhysicsCommit runtimeCommit))
{
return false; return false;
}
step = new QuantumStep( step = new QuantumStep(
current, current,
runtime,
entity, entity,
preparation, runtimeCommit);
runtime.PredictionAuthorityVersion);
return true; return true;
} }
@ -880,89 +816,42 @@ internal sealed class ProjectileController
int liveCenterY) int liveCenterY)
{ {
LiveEntityRecord current = step.Record; LiveEntityRecord current = step.Record;
Runtime runtime = step.Runtime;
WorldEntity entity = step.Entity; WorldEntity entity = step.Entity;
if (!IsCurrentQuantumStep(step))
return false;
ProjectileAdvanceResult result = _stepper.CompleteQuantum(
runtime.Body,
step.Preparation,
runtime.CollisionSphere,
entity.Id,
designatedTargetId: 0u);
if (!result.Simulated || !IsCurrentQuantumStep(step))
return false;
uint priorCellId = current.FullCellId;
uint resolvedCellId = result.CellId != 0 ? result.CellId : priorCellId;
// Commit the retained CPhysicsObj's complete frame before Rebucket
// publishes a loaded-to-pending edge. Pending is still the same live
// object; its canonical body must already name the destination cell
// so later hydration cannot resurrect the source-cell frame.
runtime.Body.SnapToCell(
resolvedCellId,
runtime.Body.Position,
CellLocalFromWorld(
runtime.Body.Position,
resolvedCellId,
liveCenterX,
liveCenterY));
entity.SetPosition(runtime.Body.Position);
entity.Rotation = runtime.Body.Orientation;
entity.ParentCellId = resolvedCellId;
if (resolvedCellId != priorCellId
&& !_liveEntities.RebucketLiveEntity(current.ServerGuid, resolvedCellId))
{
return false;
}
// A legitimate destination can now be pending and therefore absent
// from the spatial-projectile index. Revalidate identity/mutation,
// not visibility, after the canonical cell commit.
if (!IsCurrentQuantumIdentity(step)) if (!IsCurrentQuantumIdentity(step))
{
return false; return false;
} QuantumStep exactStep = step;
if (HasVisibleCell(current)) return _runtimeUpdater.Complete(
{ exactStep.RuntimeCommit,
ShadowPositionSynchronizer.Sync( liveCenterX,
_shadows, liveCenterY,
entity.Id, snapshot =>
runtime.Body.Position, {
runtime.Body.Orientation, if (!IsCurrentQuantumIdentity(exactStep))
current.FullCellId, return false;
liveCenterX, entity.SetPosition(snapshot.Position);
liveCenterY); entity.Rotation = snapshot.Orientation;
_rootPoses?.UpdateRoot(entity); entity.ParentCellId = snapshot.FullCellId;
if (!IsCurrentQuantumIdentity(step)) if (HasVisibleCell(current))
return false; _rootPoses?.UpdateRoot(entity);
} return IsCurrentQuantumIdentity(exactStep);
else });
{
SuspendOutsideWorld(runtime, entity.Id);
}
return IsCurrentQuantumIdentity(step);
} }
private bool IsCurrentQuantumStep(in QuantumStep step) =>
IsCurrentQuantumIdentity(step)
&& _liveEntities.IsCurrentSpatialProjectile(step.Record, step.Runtime);
private bool IsCurrentQuantumIdentity(in QuantumStep step) => private bool IsCurrentQuantumIdentity(in QuantumStep step) =>
TryGetCurrent( TryGetCurrent(
step.Record.ServerGuid, step.Record.ServerGuid,
out LiveEntityRecord current, out LiveEntityRecord current,
out Runtime runtime) out RuntimeProjectile runtime)
&& ReferenceEquals(current, step.Record) && ReferenceEquals(current, step.Record)
&& ReferenceEquals(runtime, step.Runtime)
&& ReferenceEquals(current.WorldEntity, step.Entity) && ReferenceEquals(current.WorldEntity, step.Entity)
&& ReferenceEquals(runtime, step.RuntimeCommit.Projectile)
&& runtime.PredictionAuthorityVersion && runtime.PredictionAuthorityVersion
== step.PredictionAuthorityVersion; == step.RuntimeCommit.PredictionAuthorityVersion;
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{ {
if (record.ProjectileRuntime is not Runtime runtime if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| record.WorldEntity is not { } entity || record.WorldEntity is not { } entity
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record) || !ReferenceEquals(current, record)
@ -1020,22 +909,13 @@ internal sealed class ProjectileController
private static bool IsHidden(LiveEntityRecord record) => private static bool IsHidden(LiveEntityRecord record) =>
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
private void SuspendOutsideWorld(Runtime runtime, uint localEntityId) private void SuspendOutsideWorld(RuntimeProjectile runtime, uint localEntityId)
{ {
runtime.Body.InWorld = false; runtime.Body.InWorld = false;
Deactivate(runtime.Body); Deactivate(runtime.Body);
_shadows.Suspend(localEntityId); _shadows.Suspend(localEntityId);
} }
private static void Activate(PhysicsBody body, double currentTime)
{
if ((body.State & PhysicsStateFlags.Static) != 0)
return;
if ((body.TransientState & TransientStateFlags.Active) == 0)
body.LastUpdateTime = currentTime;
body.TransientState |= TransientStateFlags.Active;
}
private static void Deactivate(PhysicsBody body) => private static void Deactivate(PhysicsBody body) =>
body.TransientState &= ~TransientStateFlags.Active; body.TransientState &= ~TransientStateFlags.Active;
@ -1089,11 +969,10 @@ internal sealed class ProjectileController
private bool TryGetCurrent( private bool TryGetCurrent(
uint serverGuid, uint serverGuid,
out LiveEntityRecord record, out LiveEntityRecord record,
out Runtime runtime) out RuntimeProjectile runtime)
{ {
if (_liveEntities.TryGetRecord(serverGuid, out record!) if (_liveEntities.TryGetRecord(serverGuid, out record!)
&& record.ProjectileRuntime is Runtime found && record.ProjectileRuntime is RuntimeProjectile found)
&& found.Generation == record.Generation)
{ {
runtime = found; runtime = found;
return true; return true;

View file

@ -83,8 +83,8 @@ internal sealed class LiveEntityAnimationScheduler
LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState; LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState;
RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion; RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion;
ProjectileController.Runtime? projectile = RuntimeProjectile? projectile =
record.ProjectileRuntime as ProjectileController.Runtime; record.ProjectileRuntime as RuntimeProjectile;
ulong objectClockEpoch = record.ObjectClockEpoch; ulong objectClockEpoch = record.ObjectClockEpoch;
if (!IsCurrent( if (!IsCurrent(
@ -167,7 +167,7 @@ internal sealed class LiveEntityAnimationScheduler
WorldEntity entity, WorldEntity entity,
LiveEntityAnimationState? animation, LiveEntityAnimationState? animation,
RemoteMotion? remote, RemoteMotion? remote,
ProjectileController.Runtime? projectile, RuntimeProjectile? projectile,
float elapsedSeconds, float elapsedSeconds,
Vector3? playerPosition, Vector3? playerPosition,
bool localHiddenPartPoseDirty, bool localHiddenPartPoseDirty,
@ -508,7 +508,7 @@ internal sealed class LiveEntityAnimationScheduler
WorldEntity entity, WorldEntity entity,
LiveEntityAnimationState? animation, LiveEntityAnimationState? animation,
RemoteMotion? remote, RemoteMotion? remote,
ProjectileController.Runtime? projectile, RuntimeProjectile? projectile,
ulong objectClockEpoch) => ulong objectClockEpoch) =>
runtime.IsCurrentSpatialRootObject(record) runtime.IsCurrentSpatialRootObject(record)
&& record.ObjectClockEpoch == objectClockEpoch && record.ObjectClockEpoch == objectClockEpoch

View file

@ -20,12 +20,6 @@ public interface ILiveEntityAnimationRuntime
uint CurrentMotion { get; } uint CurrentMotion { get; }
} }
/// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime
{
PhysicsBody Body { get; }
}
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary> /// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
public interface ILiveEntityEffectProfile { } public interface ILiveEntityEffectProfile { }
@ -257,7 +251,7 @@ public sealed class LiveEntityRecord
get => Canonical.RequiresRemotePlacementRuntime; get => Canonical.RequiresRemotePlacementRuntime;
set => _directory.SetRequiresRemotePlacementRuntime(Canonical, value); set => _directory.SetRequiresRemotePlacementRuntime(Canonical, value);
} }
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile;
public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; } public bool ResourcesRegistered { get; internal set; }
public bool IsSpatiallyProjected { get; internal set; } public bool IsSpatiallyProjected { get; internal set; }
@ -396,7 +390,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// projection. Hidden and Frozen remain members: their state controls what // projection. Hidden and Frozen remain members: their state controls what
// each retail update path advances after the O(active) lookup. // each retail update path advances after the O(active) lookup.
private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new(); private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new();
private readonly Dictionary<RuntimeEntityKey, ILiveEntityProjectileRuntime> _spatialProjectiles = new();
private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new(); private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new();
private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new(); private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new();
private bool _isClearing; private bool _isClearing;
@ -477,7 +470,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count; internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count;
internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount; internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount;
internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count; internal int SpatialProjectileRuntimeCount => _physics.SpatialProjectileCount;
internal int SpatialRootObjectCount => _physics.SpatialRootCount; internal int SpatialRootObjectCount => _physics.SpatialRootCount;
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments; public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
@ -1475,9 +1468,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return true; return true;
} }
public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime) internal IRuntimeProjectile BindProjectileRuntime(
uint serverGuid,
PhysicsBody body,
ProjectileCollisionSphere collisionSphere,
Func<bool>? externalOwnerValid = null)
{ {
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(body);
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null) || record.WorldEntity is null)
{ {
@ -1487,24 +1484,28 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null) if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition."); $"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition.");
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody)) && !ReferenceEquals(canonicalBody, body))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation."); $"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
} }
record.ProjectileRuntime = runtime; IRuntimeProjectile runtime = _physics.BindProjectile(
record.PhysicsBody = candidateBody; record.Canonical,
candidateBody.State = record.FinalPhysicsState; body,
SynchronizePhysicsBodyActiveState(record); collisionSphere,
() => IsCurrentRecord(record)
&& record.WorldEntity is { } entity
&& record.LocalEntityId == entity.Id
&& (externalOwnerValid?.Invoke() ?? true));
RefreshSpatialRuntimeIndexes(record); RefreshSpatialRuntimeIndexes(record);
return runtime;
} }
public bool TryGetProjectileRuntime( public bool TryGetProjectileRuntime(
uint serverGuid, uint serverGuid,
out ILiveEntityProjectileRuntime runtime) out IRuntimeProjectile runtime)
{ {
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
&& record.ProjectileRuntime is { } found) && record.ProjectileRuntime is { } found)
@ -1523,7 +1524,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|| record.ProjectileRuntime is null) || record.ProjectileRuntime is null)
return false; return false;
record.ProjectileRuntime = null; _physics.ClearProjectile(record.Canonical);
RefreshSpatialRuntimeIndexes(record); RefreshSpatialRuntimeIndexes(record);
return true; return true;
} }
@ -2021,11 +2022,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
destination.Clear(); destination.Clear();
foreach ((RuntimeEntityKey key, ILiveEntityProjectileRuntime runtime) _physics.CopySpatialProjectilesTo(_spatialRootCanonicalScratch);
in _spatialProjectiles) for (int i = 0; i < _spatialRootCanonicalScratch.Count; i++)
{ {
if (_projections.TryGet(key, out LiveEntityRecord? record) RuntimeEntityRecord canonical = _spatialRootCanonicalScratch[i];
&& ReferenceEquals(record.ProjectileRuntime, runtime) if (_projections.TryGet(canonical, out LiveEntityRecord? record)
&& HasSpatialRuntimeProjection(record)) && HasSpatialRuntimeProjection(record))
{ {
destination.Add(record); destination.Add(record);
@ -2035,12 +2036,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
internal bool IsCurrentSpatialProjectile( internal bool IsCurrentSpatialProjectile(
LiveEntityRecord record, LiveEntityRecord record,
ILiveEntityProjectileRuntime runtime) => IRuntimeProjectile runtime) =>
IsCurrentRecord(record) IsCurrentRecord(record)
&& ReferenceEquals(record.ProjectileRuntime, runtime) && _physics.IsSpatialProjectile(record.Canonical, runtime)
&& record.ProjectionKey is { } key
&& _spatialProjectiles.TryGetValue(key, out var indexed)
&& ReferenceEquals(indexed, runtime)
&& HasSpatialRuntimeProjection(record); && HasSpatialRuntimeProjection(record);
public bool IsHidden(uint serverGuid) => public bool IsHidden(uint serverGuid) =>
@ -2459,17 +2457,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
} }
if (spatial && record.ProjectileRuntime is { } projectile)
{
_spatialProjectiles[key] = projectile;
}
else if (current
|| (record.ProjectileRuntime is { } retainedProjectile
&& _spatialProjectiles.TryGetValue(key, out var indexedProjectile)
&& ReferenceEquals(indexedProjectile, retainedProjectile)))
{
_spatialProjectiles.Remove(key);
}
} }
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record) private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record)
@ -2486,11 +2473,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_spatialAnimations.Remove(key); _spatialAnimations.Remove(key);
} }
if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile)
&& ReferenceEquals(indexedProjectile, record.ProjectileRuntime))
{
_spatialProjectiles.Remove(key);
}
} }
private void OnSpatialVisibilityChanged(RuntimeEntityKey key, bool visible) private void OnSpatialVisibilityChanged(RuntimeEntityKey key, bool visible)
@ -2681,7 +2663,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
_spatialAnimations.Clear(); _spatialAnimations.Clear();
_spatialProjectiles.Clear();
_physics.ClearSpatialWorksets(); _physics.ClearSpatialWorksets();
_projections.ClearConverged(); _projections.ClearConverged();
_spatial.ClearLiveEntityLifetimeState(); _spatial.ClearLiveEntityLifetimeState();
@ -2717,6 +2698,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// a retryable tombstone until every external cleanup acknowledges // a retryable tombstone until every external cleanup acknowledges
// success. // success.
RemoveSpatialRuntimeIndexes(record); RemoveSpatialRuntimeIndexes(record);
_physics.ClearProjectile(record.Canonical);
if (!record.RuntimeComponentsTeardownCompleted) if (!record.RuntimeComponentsTeardownCompleted)
{ {
@ -2756,7 +2738,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
record.PhysicsHost = null; record.PhysicsHost = null;
record.RequiresRemotePlacementRuntime = false; record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null; record.PhysicsBody = null;
record.ProjectileRuntime = null;
record.EffectProfile = null; record.EffectProfile = null;
record.IsSpatiallyProjected = false; record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false; record.IsSpatiallyVisible = false;

View file

@ -355,6 +355,22 @@ public sealed class RuntimeEntityDirectory
record.RemoteMotionBindingInProgress = value; record.RemoteMotionBindingInProgress = value;
} }
public void SetProjectile(
RuntimeEntityRecord record,
IRuntimeProjectile? projectile)
{
EnsureKnown(record);
record.Projectile = projectile;
}
public void SetProjectileBindingInProgress(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.ProjectileBindingInProgress = value;
}
public void SetRequiresRemotePlacementRuntime( public void SetRequiresRemotePlacementRuntime(
RuntimeEntityRecord record, RuntimeEntityRecord record,
bool value) bool value)

View file

@ -97,6 +97,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Events = new RuntimeEntityObjectEventStream(Entities, Objects); Events = new RuntimeEntityObjectEventStream(Entities, Objects);
} }
internal RuntimeEntityObjectLifetime(
PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
ArgumentNullException.ThrowIfNull(physicsEngine);
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(Entities, physicsEngine);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
}
public RuntimeEntityDirectory Entities { get; } public RuntimeEntityDirectory Entities { get; }
public RuntimePhysicsState Physics { get; } public RuntimePhysicsState Physics { get; }
public ClientObjectTable Objects { get; } public ClientObjectTable Objects { get; }
@ -344,6 +358,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Physics.RemoveSpatialProjection(canonical); Physics.RemoveSpatialProjection(canonical);
Entities.SetRemoteMotion(canonical, null); Entities.SetRemoteMotion(canonical, null);
Entities.SetRemoteMotionBindingInProgress(canonical, false); Entities.SetRemoteMotionBindingInProgress(canonical, false);
Entities.SetProjectile(canonical, null);
Entities.SetProjectileBindingInProgress(canonical, false);
Entities.SetRequiresRemotePlacementRuntime(canonical, false); Entities.SetRequiresRemotePlacementRuntime(canonical, false);
Entities.SetPhysicsHost(canonical, null); Entities.SetPhysicsHost(canonical, null);
Entities.SetPhysicsBody(canonical, null); Entities.SetPhysicsBody(canonical, null);

View file

@ -71,6 +71,8 @@ public sealed class RuntimeEntityRecord
public bool PhysicsBodyAcquisitionInProgress { get; internal set; } public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
public IRuntimeRemoteMotion? RemoteMotion { get; internal set; } public IRuntimeRemoteMotion? RemoteMotion { get; internal set; }
public bool RemoteMotionBindingInProgress { get; internal set; } public bool RemoteMotionBindingInProgress { get; internal set; }
public IRuntimeProjectile? Projectile { get; internal set; }
public bool ProjectileBindingInProgress { get; internal set; }
public bool RequiresRemotePlacementRuntime { get; internal set; } public bool RequiresRemotePlacementRuntime { get; internal set; }
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; } public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
public ulong PositionAuthorityVersion { get; private set; } public ulong PositionAuthorityVersion { get; private set; }

View file

@ -8,6 +8,7 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
int RetainedShadowRegistrationCount, int RetainedShadowRegistrationCount,
int SpatialRootCount, int SpatialRootCount,
int SpatialRemoteCount, int SpatialRemoteCount,
int SpatialProjectileCount,
int CollisionAdmissionCount, int CollisionAdmissionCount,
int CollisionGenerationCount, int CollisionGenerationCount,
bool OwnsProductionDataCache, bool OwnsProductionDataCache,
@ -64,6 +65,8 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRoots = new(); _spatialRoots = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion> private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
_spatialRemotes = new(); _spatialRemotes = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeProjectile>
_spatialProjectiles = new();
private readonly Dictionary<uint, ulong> _collisionGenerations = new(); private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission> private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new(); _collisionAdmissions = new();
@ -83,11 +86,22 @@ public sealed class RuntimePhysicsState : IDisposable
}; };
} }
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
PhysicsEngine engine)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
Engine = engine ?? throw new ArgumentNullException(nameof(engine));
DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction();
Engine.DataCache = DataCache;
}
internal RuntimeEntityDirectory Entities { get; } internal RuntimeEntityDirectory Entities { get; }
public PhysicsEngine Engine { get; } public PhysicsEngine Engine { get; }
public PhysicsDataCache DataCache { get; } public PhysicsDataCache DataCache { get; }
public int SpatialRootCount => _spatialRoots.Count; public int SpatialRootCount => _spatialRoots.Count;
public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialRemoteCount => _spatialRemotes.Count;
public int SpatialProjectileCount => _spatialProjectiles.Count;
public RuntimePhysicsOwnershipSnapshot CaptureOwnership() => public RuntimePhysicsOwnershipSnapshot CaptureOwnership() =>
new( new(
@ -95,6 +109,7 @@ public sealed class RuntimePhysicsState : IDisposable
Engine.ShadowObjects.RetainedRegistrationCount, Engine.ShadowObjects.RetainedRegistrationCount,
_spatialRoots.Count, _spatialRoots.Count,
_spatialRemotes.Count, _spatialRemotes.Count,
_spatialProjectiles.Count,
_collisionAdmissions.Count, _collisionAdmissions.Count,
_collisionGenerations.Count, _collisionGenerations.Count,
ReferenceEquals(Engine.DataCache, DataCache), ReferenceEquals(Engine.DataCache, DataCache),
@ -123,6 +138,10 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRemotes[key] = remote; _spatialRemotes[key] = remote;
else else
_spatialRemotes.Remove(key); _spatialRemotes.Remove(key);
if (record.Projectile is { } projectile)
_spatialProjectiles[key] = projectile;
else
_spatialProjectiles.Remove(key);
return; return;
} }
@ -148,6 +167,167 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRemotes.Remove(key); _spatialRemotes.Remove(key);
} }
public void RefreshProjectileComponent(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key
|| !_spatialRoots.TryGetValue(key, out RuntimeEntityRecord? root)
|| !ReferenceEquals(root, record)
|| !Entities.IsCurrent(record))
{
RemoveSpatialProjectile(record);
return;
}
if (record.Projectile is { } projectile)
_spatialProjectiles[key] = projectile;
else
_spatialProjectiles.Remove(key);
}
/// <summary>
/// Installs the one projectile component for an exact canonical
/// incarnation. App may prepare the DAT collision sphere, but Runtime owns
/// component identity, prediction invalidation, and workset membership.
/// </summary>
public IRuntimeProjectile BindProjectile(
RuntimeEntityRecord record,
PhysicsBody body,
ProjectileCollisionSphere collisionSphere,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
EnsureCurrent(record);
if (!collisionSphere.IsValid)
{
throw new ArgumentOutOfRangeException(
nameof(collisionSphere),
"A Runtime projectile requires one valid prepared Setup sphere.");
}
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before projectile binding.");
}
if (record.Projectile is { } retained)
{
if (!ReferenceEquals(retained.Body, body)
|| !ReferenceEquals(record.PhysicsBody, body))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} projectile changed its canonical physics body.");
}
retained.Body.State = record.FinalPhysicsState;
RefreshProjectileComponent(record);
return retained;
}
if (record.ProjectileBindingInProgress)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} projectile binding is already in progress.");
}
if (!ReferenceEquals(record.PhysicsBody, body))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} projectile must borrow its canonical physics body.");
}
ulong sessionVersion = Entities.SessionLifetimeVersion;
Entities.SetProjectileBindingInProgress(record, true);
try
{
var component = new RuntimeProjectile(body, collisionSphere);
if (Entities.SessionLifetimeVersion != sessionVersion
|| !Entities.IsCurrent(record)
|| !ReferenceEquals(record.PhysicsBody, body)
|| record.Projectile is not null
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during projectile binding.");
}
Entities.SetProjectile(record, component);
body.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
RefreshProjectileComponent(record);
return component;
}
finally
{
if (Entities.IsCurrent(record))
Entities.SetProjectileBindingInProgress(record, false);
else
record.ProjectileBindingInProgress = false;
}
}
public bool ClearProjectile(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Projectile is null)
return false;
Entities.SetProjectile(record, null);
Entities.SetProjectileBindingInProgress(record, false);
RefreshProjectileComponent(record);
return true;
}
/// <summary>
/// Commits retail <c>CPhysicsObj::set_velocity</c> (0x005113F0) and,
/// when supplied, the paired <c>set_omega</c> write to the incarnation's
/// canonical body. Runtime owns the shared body/object-clock activation
/// edge; graphical hosts only validate and translate inbound payloads.
/// </summary>
internal bool TryCommitAuthoritativeVector(
RuntimeEntityRecord record,
PhysicsBody body,
System.Numerics.Vector3 velocity,
System.Numerics.Vector3? angularVelocity,
double currentTime,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
if (!IsFinite(velocity)
|| (angularVelocity is { } omega && !IsFinite(omega))
|| !double.IsFinite(currentTime)
|| !Entities.IsCurrent(record)
|| !ReferenceEquals(record.PhysicsBody, body)
|| !(externalOwnerValid?.Invoke() ?? true))
{
return false;
}
bool wasBodyActive =
(body.TransientState & TransientStateFlags.Active) != 0;
body.set_velocity(velocity);
if ((record.FinalPhysicsState & PhysicsStateFlags.Static) != 0)
{
if (!wasBodyActive)
body.TransientState &= ~TransientStateFlags.Active;
}
else
{
bool clockReactivated = record.ObjectClock.Activate();
if (clockReactivated || !wasBodyActive)
body.LastUpdateTime = currentTime;
}
if (angularVelocity is { } acceptedOmega)
body.Omega = acceptedOmega;
return Entities.IsCurrent(record)
&& ReferenceEquals(record.PhysicsBody, body)
&& (externalOwnerValid?.Invoke() ?? true);
}
public void SetRemoteMotion( public void SetRemoteMotion(
RuntimeEntityRecord record, RuntimeEntityRecord record,
IRuntimeRemoteMotion runtime, IRuntimeRemoteMotion runtime,
@ -476,6 +656,7 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRoots.Remove(key); _spatialRoots.Remove(key);
} }
RemoveSpatialRemote(record); RemoveSpatialRemote(record);
RemoveSpatialProjectile(record);
} }
public bool IsSpatialRoot(RuntimeEntityRecord record) => public bool IsSpatialRoot(RuntimeEntityRecord record) =>
@ -493,6 +674,15 @@ public sealed class RuntimePhysicsState : IDisposable
&& _spatialRemotes.TryGetValue(key, out IRuntimeRemoteMotion? indexed) && _spatialRemotes.TryGetValue(key, out IRuntimeRemoteMotion? indexed)
&& ReferenceEquals(indexed, remote); && ReferenceEquals(indexed, remote);
public bool IsSpatialProjectile(
RuntimeEntityRecord record,
IRuntimeProjectile projectile) =>
IsSpatialRoot(record)
&& ReferenceEquals(record.Projectile, projectile)
&& record.Key is { } key
&& _spatialProjectiles.TryGetValue(key, out IRuntimeProjectile? indexed)
&& ReferenceEquals(indexed, projectile);
public void CopySpatialRootsTo(List<RuntimeEntityRecord> destination) public void CopySpatialRootsTo(List<RuntimeEntityRecord> destination)
{ {
EnsureNotDisposed(); EnsureNotDisposed();
@ -529,6 +719,26 @@ public sealed class RuntimePhysicsState : IDisposable
} }
} }
public void CopySpatialProjectilesTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, IRuntimeProjectile projectile)
in _spatialProjectiles)
{
if (Entities.TryGetByLocalId(
key.LocalEntityId,
out RuntimeEntityRecord record)
&& record.Key == key
&& ReferenceEquals(record.Projectile, projectile)
&& IsSpatialRoot(record))
{
destination.Add(record);
}
}
}
internal bool CommitOrdinaryCell( internal bool CommitOrdinaryCell(
RuntimeEntityRecord record, RuntimeEntityRecord record,
PhysicsBody body, PhysicsBody body,
@ -551,10 +761,34 @@ public sealed class RuntimePhysicsState : IDisposable
IsExactOwner); IsExactOwner);
} }
internal bool CommitProjectileCell(
RuntimeEntityRecord record,
IRuntimeProjectile projectile,
ulong predictionAuthorityVersion,
uint fullCellId,
Func<bool>? externalOwnerValid)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(projectile);
bool IsExactOwner() =>
Entities.IsCurrent(record)
&& ReferenceEquals(record.Projectile, projectile)
&& ReferenceEquals(record.PhysicsBody, projectile.Body)
&& projectile.PredictionAuthorityVersion
== predictionAuthorityVersion
&& (externalOwnerValid?.Invoke() ?? true);
return CommitCanonicalCell(
record,
fullCellId,
IsExactOwner);
}
public void ClearSpatialWorksets() public void ClearSpatialWorksets()
{ {
EnsureNotDisposed(); EnsureNotDisposed();
_spatialRemotes.Clear(); _spatialRemotes.Clear();
_spatialProjectiles.Clear();
_spatialRoots.Clear(); _spatialRoots.Clear();
} }
@ -672,6 +906,7 @@ public sealed class RuntimePhysicsState : IDisposable
if (_disposed) if (_disposed)
return; return;
_spatialRemotes.Clear(); _spatialRemotes.Clear();
_spatialProjectiles.Clear();
_spatialRoots.Clear(); _spatialRoots.Clear();
_collisionAdmissions.Clear(); _collisionAdmissions.Clear();
_collisionGenerations.Clear(); _collisionGenerations.Clear();
@ -730,6 +965,21 @@ public sealed class RuntimePhysicsState : IDisposable
} }
} }
private void RemoveSpatialProjectile(RuntimeEntityRecord record)
{
if (record.Key is not { } key)
return;
if (_spatialProjectiles.TryGetValue(
key,
out IRuntimeProjectile? projectile)
&& (record.Projectile is null
|| ReferenceEquals(projectile, record.Projectile)
|| !Entities.IsCurrent(record)))
{
_spatialProjectiles.Remove(key);
}
}
private void EnsureNotDisposed() => private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
@ -775,6 +1025,11 @@ public sealed class RuntimePhysicsState : IDisposable
private static uint CanonicalLandblock(uint value) => private static uint CanonicalLandblock(uint value) =>
(value & 0xFFFF0000u) | 0xFFFFu; (value & 0xFFFF0000u) | 0xFFFFu;
private static bool IsFinite(System.Numerics.Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
private static void SynchronizeBodyActiveState(RuntimeEntityRecord record) private static void SynchronizeBodyActiveState(RuntimeEntityRecord record)
{ {
if (record.PhysicsBody is not { } body) if (record.PhysicsBody is not { } body)

View file

@ -0,0 +1,40 @@
using AcDream.Core.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// Presentation-free projectile component attached to one canonical Runtime
/// entity incarnation. The body is retail's one <c>CPhysicsObj</c>; the
/// prepared Setup sphere and prediction version die with the same incarnation.
/// </summary>
public interface IRuntimeProjectile
{
PhysicsBody Body { get; }
ProjectileCollisionSphere CollisionSphere { get; }
ulong PredictionAuthorityVersion { get; }
}
internal sealed class RuntimeProjectile : IRuntimeProjectile
{
internal RuntimeProjectile(
PhysicsBody body,
ProjectileCollisionSphere collisionSphere)
{
Body = body ?? throw new ArgumentNullException(nameof(body));
if (!collisionSphere.IsValid)
{
throw new ArgumentOutOfRangeException(
nameof(collisionSphere),
"A Runtime projectile requires one valid prepared Setup sphere.");
}
CollisionSphere = collisionSphere;
}
public PhysicsBody Body { get; }
public ProjectileCollisionSphere CollisionSphere { get; }
public ulong PredictionAuthorityVersion { get; private set; }
internal void InvalidatePrediction() =>
PredictionAuthorityVersion++;
}

View file

@ -0,0 +1,500 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal sealed class RuntimeProjectilePhysicsCommit
{
internal required RuntimeProjectilePhysicsUpdater Owner { get; init; }
internal required RuntimeEntityRecord Record { get; init; }
internal required RuntimeProjectile Projectile { get; init; }
internal required ProjectileQuantumPreparation Preparation { get; init; }
internal required ulong PredictionAuthorityVersion { get; init; }
internal required ulong ObjectClockEpoch { get; init; }
internal required Func<bool>? ExternalOwnerValid { get; init; }
internal bool Completed { get; set; }
}
/// <summary>
/// Presentation-free owner of the projectile slice in retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (<c>0x005156B0</c>). The caller
/// preserves retail hook ordering by holding the returned transaction across
/// <c>process_hooks</c>; Runtime owns integration, transition, prediction
/// versions, canonical cell state, and the physics shadow.
/// </summary>
internal sealed class RuntimeProjectilePhysicsUpdater
{
private readonly RuntimePhysicsState _physics;
private readonly ProjectilePhysicsStepper _stepper;
internal RuntimeProjectilePhysicsUpdater(RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_stepper = new ProjectilePhysicsStepper(physics.Engine);
}
internal bool TryBegin(
RuntimeEntityRecord record,
float quantum,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid,
out RuntimeProjectilePhysicsCommit commit)
{
ArgumentNullException.ThrowIfNull(record);
if (record.Projectile is not RuntimeProjectile projectile
|| !IsSpatialCurrent(
record,
projectile,
projectile.PredictionAuthorityVersion,
objectClockEpoch,
externalOwnerValid)
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| record.FullCellId == 0)
{
commit = null!;
return false;
}
projectile.Body.State = record.FinalPhysicsState;
bool isParented = record.Snapshot.ParentGuid is not null
|| record.Snapshot.Physics?.Parent is not null;
ProjectileQuantumPreparation preparation = _stepper.BeginQuantum(
projectile.Body,
quantum,
record.FullCellId,
projectile.CollisionSphere,
isParented);
if (!preparation.Simulated
|| !IsSpatialCurrent(
record,
projectile,
projectile.PredictionAuthorityVersion,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
commit = new RuntimeProjectilePhysicsCommit
{
Owner = this,
Record = record,
Projectile = projectile,
Preparation = preparation,
PredictionAuthorityVersion =
projectile.PredictionAuthorityVersion,
ObjectClockEpoch = objectClockEpoch,
ExternalOwnerValid = externalOwnerValid,
};
return true;
}
internal bool Complete(
RuntimeProjectilePhysicsCommit commit,
int liveCenterX,
int liveCenterY,
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection)
{
ArgumentNullException.ThrowIfNull(commit);
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
if (!ReferenceEquals(commit.Owner, this))
{
throw new InvalidOperationException(
"A projectile-physics commit belongs to another Runtime owner.");
}
if (commit.Completed)
{
throw new InvalidOperationException(
"A projectile-physics commit has already completed.");
}
commit.Completed = true;
if (!IsSpatialCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
PhysicsBody body = commit.Projectile.Body;
ProjectileAdvanceResult result = _stepper.CompleteQuantum(
body,
commit.Preparation,
commit.Projectile.CollisionSphere,
commit.Record.LocalEntityId ?? 0u,
designatedTargetId: 0u);
if (!result.Simulated
|| !IsSpatialCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
uint resolvedCellId = result.CellId != 0
? result.CellId
: commit.Record.FullCellId;
body.SnapToCell(
resolvedCellId,
body.Position,
CellLocalFromWorld(
body.Position,
resolvedCellId,
liveCenterX,
liveCenterY));
var snapshot = new RuntimePhysicsFrameSnapshot(
body.Position,
body.Orientation,
resolvedCellId);
if (!_physics.CommitProjectileCell(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
resolvedCellId,
commit.ExternalOwnerValid)
|| !IsIdentityCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ExternalOwnerValid)
|| !acknowledgeProjection(snapshot)
|| !IsIdentityCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ExternalOwnerValid))
{
return false;
}
if (_physics.IsSpatialProjectile(
commit.Record,
commit.Projectile)
&& (commit.Record.FinalPhysicsState
& PhysicsStateFlags.Hidden) == 0)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
commit.Record.LocalEntityId ?? 0u,
body.Position,
body.Orientation,
commit.Record.FullCellId,
liveCenterX,
liveCenterY);
}
else
{
_physics.Engine.ShadowObjects.Suspend(
commit.Record.LocalEntityId ?? 0u);
}
return IsIdentityCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ExternalOwnerValid);
}
internal bool ApplyAuthoritativeVector(
RuntimeEntityRecord record,
ulong expectedVectorAuthorityVersion,
ulong expectedVelocityAuthorityVersion,
Vector3 velocity,
Vector3 angularVelocity,
double currentTime,
Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetCurrent(
record,
externalOwnerValid,
out RuntimeProjectile projectile)
|| record.VectorAuthorityVersion
!= expectedVectorAuthorityVersion
|| record.VelocityAuthorityVersion
!= expectedVelocityAuthorityVersion)
{
return false;
}
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
&& record.RemoteMotion is not null)
{
return false;
}
if (!IsFinite(velocity)
|| !IsFinite(angularVelocity)
|| !double.IsFinite(currentTime))
{
return true;
}
projectile.InvalidatePrediction();
_ = _physics.TryCommitAuthoritativeVector(
record,
projectile.Body,
velocity,
angularVelocity,
currentTime,
externalOwnerValid);
return true;
}
internal bool ApplyAuthoritativeState(
RuntimeEntityRecord record,
ulong expectedStateAuthorityVersion,
PhysicsStateFlags state,
double effectiveClock,
int liveCenterX,
int liveCenterY,
Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetCurrent(
record,
externalOwnerValid,
out RuntimeProjectile projectile)
|| record.StateAuthorityVersion
!= expectedStateAuthorityVersion)
{
return false;
}
projectile.InvalidatePrediction();
PhysicsBody body = projectile.Body;
if (!double.IsFinite(effectiveClock))
{
effectiveClock = double.IsFinite(body.LastUpdateTime)
? body.LastUpdateTime
: 0d;
}
bool wasMissile =
(body.State & PhysicsStateFlags.Missile) != 0;
body.State = state;
if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile)
{
body.LastUpdateTime = effectiveClock;
if (record.FullCellId != 0)
{
body.SnapToCell(
record.FullCellId,
body.Position,
CellLocalFromWorld(
body.Position,
record.FullCellId,
liveCenterX,
liveCenterY));
}
}
return true;
}
internal bool ApplyAuthoritativePosition(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
ulong expectedVelocityAuthorityVersion,
Vector3 worldPosition,
Vector3 cellLocalPosition,
Quaternion orientation,
Vector3 velocity,
uint fullCellId,
double currentTime,
int liveCenterX,
int liveCenterY,
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection,
Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
if (!TryGetCurrent(
record,
externalOwnerValid,
out RuntimeProjectile projectile)
|| record.PositionAuthorityVersion
!= expectedPositionAuthorityVersion
|| (record.FinalPhysicsState
& PhysicsStateFlags.Missile) == 0)
{
return false;
}
if (!double.IsFinite(currentTime)
|| !IsFinite(worldPosition)
|| !IsFinite(cellLocalPosition)
|| !IsFinite(velocity)
|| !PositionFrameValidation.IsValid(
fullCellId,
cellLocalPosition,
orientation))
{
return true;
}
PhysicsBody body = projectile.Body;
projectile.InvalidatePrediction();
ulong predictionVersion = projectile.PredictionAuthorityVersion;
bool wasInWorld = body.InWorld;
body.Orientation = orientation;
body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
body.State = record.FinalPhysicsState;
if (record.VelocityAuthorityVersion
== expectedVelocityAuthorityVersion)
{
_ = _physics.TryCommitAuthoritativeVector(
record,
body,
velocity,
angularVelocity: null,
currentTime,
externalOwnerValid);
}
bool IsExactOwner() =>
TryGetCurrent(
record,
externalOwnerValid,
out RuntimeProjectile current)
&& ReferenceEquals(current, projectile)
&& current.PredictionAuthorityVersion == predictionVersion
&& record.PositionAuthorityVersion
== expectedPositionAuthorityVersion;
if (!_physics.CommitProjectileCell(
record,
projectile,
predictionVersion,
body.CellPosition.ObjCellId,
IsExactOwner)
|| !IsExactOwner())
{
// This packet was accepted for the old incarnation. A re-entrant
// observer displaced it, so the replacement owns another body.
return true;
}
var snapshot = new RuntimePhysicsFrameSnapshot(
body.Position,
body.Orientation,
body.CellPosition.ObjCellId);
if (!acknowledgeProjection(snapshot) || !IsExactOwner())
return true;
bool spatial = _physics.IsSpatialProjectile(record, projectile);
bool hidden =
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
uint localId = record.LocalEntityId ?? 0u;
if (spatial && !hidden)
{
if (!wasInWorld)
{
body.LastUpdateTime = currentTime;
Activate(body, currentTime);
}
body.InWorld = true;
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
localId,
body.Position,
body.Orientation,
record.FullCellId,
liveCenterX,
liveCenterY);
}
else if (spatial)
{
body.InWorld = true;
body.LastUpdateTime = currentTime;
_physics.Engine.ShadowObjects.Suspend(localId);
}
else
{
body.InWorld = false;
Deactivate(body);
_physics.Engine.ShadowObjects.Suspend(localId);
}
return true;
}
private bool IsSpatialCurrent(
RuntimeEntityRecord record,
RuntimeProjectile projectile,
ulong predictionAuthorityVersion,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid) =>
_physics.IsSpatialProjectile(record, projectile)
&& record.ObjectClockEpoch == objectClockEpoch
&& IsIdentityCurrent(
record,
projectile,
predictionAuthorityVersion,
externalOwnerValid);
private bool IsIdentityCurrent(
RuntimeEntityRecord record,
RuntimeProjectile projectile,
ulong predictionAuthorityVersion,
Func<bool>? externalOwnerValid) =>
_physics.Entities.IsCurrent(record)
&& ReferenceEquals(record.Projectile, projectile)
&& ReferenceEquals(record.PhysicsBody, projectile.Body)
&& projectile.PredictionAuthorityVersion
== predictionAuthorityVersion
&& (externalOwnerValid?.Invoke() ?? true);
private bool TryGetCurrent(
RuntimeEntityRecord record,
Func<bool>? externalOwnerValid,
out RuntimeProjectile projectile)
{
if (_physics.Entities.IsCurrent(record)
&& record.Projectile is RuntimeProjectile current
&& ReferenceEquals(record.PhysicsBody, current.Body)
&& (externalOwnerValid?.Invoke() ?? true))
{
projectile = current;
return true;
}
projectile = null!;
return false;
}
private static Vector3 CellLocalFromWorld(
Vector3 worldPosition,
uint cellId,
int liveCenterX,
int liveCenterY)
{
int landblockX = (int)((cellId >> 24) & 0xFFu);
int landblockY = (int)((cellId >> 16) & 0xFFu);
return worldPosition - new Vector3(
(landblockX - liveCenterX) * 192f,
(landblockY - liveCenterY) * 192f,
0f);
}
private static void Activate(PhysicsBody body, double currentTime)
{
if ((body.State & PhysicsStateFlags.Static) != 0)
return;
if ((body.TransientState & TransientStateFlags.Active) == 0)
body.LastUpdateTime = currentTime;
body.TransientState |= TransientStateFlags.Active;
}
private static void Deactivate(PhysicsBody body) =>
body.TransientState &= ~TransientStateFlags.Active;
private static bool IsFinite(Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
namespace AcDream.App.Tests; namespace AcDream.App.Tests;
@ -20,6 +21,18 @@ internal static class LiveEntityRuntimeFixture
return new LiveEntityRuntime(spatial, resources, lifetime); return new LiveEntityRuntime(spatial, resources, lifetime);
} }
public static LiveEntityRuntime Create(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
var lifetime = new RuntimeEntityObjectLifetime(
physicsEngine,
firstLocalEntityId);
return new LiveEntityRuntime(spatial, resources, lifetime);
}
public static LiveEntityRuntime Create( public static LiveEntityRuntime Create(
GpuWorldState spatial, GpuWorldState spatial,
ILiveEntityResourceLifecycle resources, ILiveEntityResourceLifecycle resources,
@ -47,4 +60,21 @@ internal static class LiveEntityRuntimeFixture
runtimeComponentLifecycle, runtimeComponentLifecycle,
lifetime); lifetime);
} }
public static LiveEntityRuntime Create(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle,
PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
var lifetime = new RuntimeEntityObjectLifetime(
physicsEngine,
firstLocalEntityId);
return new LiveEntityRuntime(
spatial,
resources,
runtimeComponentLifecycle,
lifetime);
}
} }

View file

@ -162,7 +162,7 @@ public sealed class ProjectileControllerTests
seedCellId: CellA, seedCellId: CellA,
isStatic: false); isStatic: false);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; IRuntimeProjectile runtime = record.ProjectileRuntime!;
Assert.True(fixture.Controller.ApplyAuthoritativePosition( Assert.True(fixture.Controller.ApplyAuthoritativePosition(
record, record,
@ -339,6 +339,7 @@ public sealed class ProjectileControllerTests
Assert.Equal(0xAAB40001u, record.FullCellId); Assert.Equal(0xAAB40001u, record.FullCellId);
Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId); Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId);
Assert.Equal(record.PhysicsBody.Position, entity.Position);
Assert.False(record.IsSpatiallyVisible); Assert.False(record.IsSpatiallyVisible);
Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyProjected);
Assert.False(record.PhysicsBody.InWorld); Assert.False(record.PhysicsBody.InWorld);
@ -514,7 +515,7 @@ public sealed class ProjectileControllerTests
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
Vector3 before = record.WorldEntity!.Position; Vector3 before = record.WorldEntity!.Position;
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; IRuntimeProjectile runtime = record.ProjectileRuntime!;
PhysicsBody body = runtime.Body; PhysicsBody body = runtime.Body;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
@ -565,7 +566,7 @@ public sealed class ProjectileControllerTests
cellLocalPosition: new Vector3(191f, 10f, 50f), cellLocalPosition: new Vector3(191f, 10f, 50f),
velocity: new Vector3(40f, 0f, 0f)); velocity: new Vector3(40f, 0f, 0f));
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(0.5f), 0.0)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(0.5f), 0.0));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; IRuntimeProjectile runtime = record.ProjectileRuntime!;
fixture.Controller.Tick(0.05, 0xA9, 0xB4, playerWorldPosition: null); fixture.Controller.Tick(0.05, 0xA9, 0xB4, playerWorldPosition: null);
Assert.Equal(destinationCell, record.FullCellId); Assert.Equal(destinationCell, record.FullCellId);
@ -988,7 +989,7 @@ public sealed class ProjectileControllerTests
Assert.Equal(MissileState, record.PhysicsBody!.State); Assert.Equal(MissileState, record.PhysicsBody!.State);
Assert.True(double.IsFinite(record.PhysicsBody.LastUpdateTime)); Assert.True(double.IsFinite(record.PhysicsBody.LastUpdateTime));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; IRuntimeProjectile runtime = record.ProjectileRuntime!;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
record, MissileState, 0.1, 1, 1)); record, MissileState, 0.1, 1, 1));
Assert.Same(runtime, record.ProjectileRuntime); Assert.Same(runtime, record.ProjectileRuntime);
@ -1320,14 +1321,14 @@ public sealed class ProjectileControllerTests
LiveEntityRecord first = fixture.Spawn(instance: 7); LiveEntityRecord first = fixture.Spawn(instance: 7);
Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0)); Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0));
WorldEntity firstEntity = first.WorldEntity!; WorldEntity firstEntity = first.WorldEntity!;
ILiveEntityProjectileRuntime firstRuntime = first.ProjectileRuntime!; IRuntimeProjectile firstRuntime = first.ProjectileRuntime!;
Assert.True(fixture.Live.UnregisterLiveEntity( Assert.True(fixture.Live.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, InstanceSequence: 7), new DeleteObject.Parsed(Guid, InstanceSequence: 7),
isLocalPlayer: false)); isLocalPlayer: false));
LiveEntityRecord second = fixture.Spawn(instance: 8); LiveEntityRecord second = fixture.Spawn(instance: 8);
Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0)); Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0));
ILiveEntityProjectileRuntime secondRuntime = second.ProjectileRuntime!; IRuntimeProjectile secondRuntime = second.ProjectileRuntime!;
Assert.False(fixture.Controller.TryBind(first, ProjectileSetup(), 3.0)); Assert.False(fixture.Controller.TryBind(first, ProjectileSetup(), 3.0));
@ -1457,12 +1458,14 @@ public sealed class ProjectileControllerTests
{ {
if (loadInitialLandblock) if (loadInitialLandblock)
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Live = LiveEntityRuntimeFixture.Create(Spatial, Resources);
Engine = physicsEngine ?? new PhysicsEngine(); Engine = physicsEngine ?? new PhysicsEngine();
Live = LiveEntityRuntimeFixture.Create(
Spatial,
Resources,
Engine);
Origin.Recenter(1, 1); Origin.Recenter(1, 1);
Controller = new ProjectileController( Controller = new ProjectileController(
Live, Live,
Engine,
new SetupResolver(() => ResolvedSetup), new SetupResolver(() => ResolvedSetup),
new RootPosePublisher( new RootPosePublisher(
publishRootPose ?? new Action<WorldEntity>( publishRootPose ?? new Action<WorldEntity>(

View file

@ -71,14 +71,6 @@ public sealed class RemoteTeleportControllerTests
public void LeaveGround() { } public void LeaveGround() { }
} }
private sealed class AlternatingProjectileRuntime(
PhysicsBody first,
PhysicsBody later) : ILiveEntityProjectileRuntime
{
private int _reads;
public PhysicsBody Body => _reads++ == 0 ? first : later;
}
[Fact] [Fact]
public void PendingDestination_ParksAuthoritativeFrameThenResolvesContactOnLoad() public void PendingDestination_ParksAuthoritativeFrameThenResolvesContactOnLoad()
{ {
@ -867,8 +859,10 @@ public sealed class RemoteTeleportControllerTests
Assert.Same(canonicalBody, record.PhysicsBody); Assert.Same(canonicalBody, record.PhysicsBody);
Assert.Same(remote, record.RemoteMotionRuntime); Assert.Same(remote, record.RemoteMotionRuntime);
var projectile = new AlternatingProjectileRuntime(canonicalBody, otherBody); IRuntimeProjectile projectile = fixture.Live.BindProjectileRuntime(
fixture.Live.SetProjectileRuntime(RollbackFixture.Guid, projectile); RollbackFixture.Guid,
canonicalBody,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
Assert.Same(canonicalBody, record.PhysicsBody); Assert.Same(canonicalBody, record.PhysicsBody);
Assert.Same(projectile, record.ProjectileRuntime); Assert.Same(projectile, record.ProjectileRuntime);

View file

@ -234,8 +234,8 @@ public sealed class LiveEntityAnimationSchedulerTests
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames( var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid, RemoteGuid,
rootOrigin: Vector3.Zero); rootOrigin: Vector3.Zero);
var physics = new PhysicsEngine(); PhysicsEngine physics = live.Physics.Engine;
var projectiles = new ProjectileController(live, physics); var projectiles = new ProjectileController(live);
record.FinalPhysicsState = ProjectileState; record.FinalPhysicsState = ProjectileState;
Assert.True(record.ObjectClock.IsActive); Assert.True(record.ObjectClock.IsActive);
Assert.True(projectiles.TryBind( Assert.True(projectiles.TryBind(
@ -287,8 +287,8 @@ public sealed class LiveEntityAnimationSchedulerTests
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames( var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid, RemoteGuid,
rootOrigin: Vector3.Zero); rootOrigin: Vector3.Zero);
var physics = new PhysicsEngine(); PhysicsEngine physics = live.Physics.Engine;
var projectiles = new ProjectileController(live, physics); var projectiles = new ProjectileController(live);
record.FinalPhysicsState = ProjectileState; record.FinalPhysicsState = ProjectileState;
var remote = new AcDream.Runtime.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
@ -642,7 +642,7 @@ public sealed class LiveEntityAnimationSchedulerTests
new AnimationHookRouter(), new AnimationHookRouter(),
poses)) poses))
: new TestAnimationHookCaptureSink(captureHooks); : new TestAnimationHookCaptureSink(captureHooks);
projectiles ??= new ProjectileController(live, physics); projectiles ??= new ProjectileController(live);
return new LiveEntityAnimationScheduler( return new LiveEntityAnimationScheduler(
live, live,
identity, identity,

View file

@ -3,6 +3,7 @@ using System.Reflection;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.World; namespace AcDream.App.Tests.World;
@ -161,6 +162,18 @@ public sealed class GameWindowLiveEntityCompositionTests
Assert.Equal( Assert.Equal(
typeof(LiveWorldOriginState), typeof(LiveWorldOriginState),
Assert.Single(projectileFields, field => field.Name == "_origin").FieldType); Assert.Single(projectileFields, field => field.Name == "_origin").FieldType);
Assert.Equal(
typeof(RuntimeProjectilePhysicsUpdater),
Assert.Single(
projectileFields,
field => field.Name == "_runtimeUpdater").FieldType);
Assert.DoesNotContain(
projectileFields,
field => field.FieldType == typeof(PhysicsEngine)
|| field.FieldType == typeof(ProjectilePhysicsStepper)
|| field.FieldType.IsGenericType
&& field.FieldType.GetGenericTypeDefinition()
== typeof(Dictionary<,>));
Assert.DoesNotContain( Assert.DoesNotContain(
projectileFields, projectileFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType) field => typeof(Delegate).IsAssignableFrom(field.FieldType)

View file

@ -422,7 +422,7 @@ public sealed class LiveEntityLifecycleStressTests
randomUnit: () => 0.5, randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
EntityObjects = new RuntimeEntityObjectLifetime(); EntityObjects = new RuntimeEntityObjectLifetime(Engine);
Runtime = new LiveEntityRuntime( Runtime = new LiveEntityRuntime(
Spatial, Spatial,
new DelegateLiveEntityResourceLifecycle( new DelegateLiveEntityResourceLifecycle(
@ -463,7 +463,7 @@ public sealed class LiveEntityLifecycleStressTests
Poses); Poses);
_effects = Effects; _effects = Effects;
Router.Register(Effects); Router.Register(Effects);
Projectiles = new ProjectileController(Runtime, Engine); Projectiles = new ProjectileController(Runtime);
LiveLights = new LiveEntityLightController( LiveLights = new LiveEntityLightController(
Runtime, Runtime,
Poses, Poses,

View file

@ -199,10 +199,10 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
Live = LiveEntityRuntimeFixture.Create( Live = LiveEntityRuntimeFixture.Create(
Spatial, Spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
lifecycle); lifecycle,
Projectiles = new ProjectileController(
Live,
Physics); Physics);
Projectiles = new ProjectileController(
Live);
Controller = new LiveEntityProjectionWithdrawalController( Controller = new LiveEntityProjectionWithdrawalController(
Live, Live,
Projectiles, Projectiles,

View file

@ -53,11 +53,6 @@ public sealed class LiveEntityRuntimeTests
public PhysicsBody Body { get; } = new(); public PhysicsBody Body { get; } = new();
} }
private sealed class ProjectileRuntime(PhysicsBody body) : ILiveEntityProjectileRuntime
{
public PhysicsBody Body { get; } = body;
}
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
{ {
public int RegisterCount { get; private set; } public int RegisterCount { get; private set; }
@ -427,10 +422,12 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
var animation = new AnimationRuntime(entity); var animation = new AnimationRuntime(entity);
var remote = new RemoteMotionRuntime(); var remote = new RemoteMotionRuntime();
var projectile = new ProjectileRuntime(remote.Body);
runtime.SetAnimationRuntime(guid, animation); runtime.SetAnimationRuntime(guid, animation);
runtime.SetRemoteMotionRuntime(guid, remote); runtime.SetRemoteMotionRuntime(guid, remote);
runtime.SetProjectileRuntime(guid, projectile); IRuntimeProjectile projectile = runtime.BindProjectileRuntime(
guid,
remote.Body,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
Assert.True(runtime.IsCurrentSpatialAnimation(record, animation)); Assert.True(runtime.IsCurrentSpatialAnimation(record, animation));
Assert.True(runtime.IsCurrentSpatialRemoteMotion(record, remote)); Assert.True(runtime.IsCurrentSpatialRemoteMotion(record, remote));

View file

@ -86,6 +86,201 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(0, lifetime.Physics.SpatialRootCount); Assert.Equal(0, lifetime.Physics.SpatialRootCount);
} }
[Fact]
public void CanonicalRecordAndPhysicsOwnerOwnProjectileComponentAndWorkset()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000031u, 1));
lifetime.Entities.SetFullCell(record, 0x01010001u, 0x0101FFFFu);
lifetime.Entities.SetFinalPhysicsState(
record,
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped);
var body = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState = TransientStateFlags.Active,
};
body.SnapToCell(
record.FullCellId,
body.Position,
body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
var sphere = new ProjectileCollisionSphere(
new Vector3(0.1f, 0f, 0f),
0.25f);
IRuntimeProjectile projectile = lifetime.Physics.BindProjectile(
record,
body,
sphere);
lifetime.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
Assert.Same(projectile, record.Projectile);
Assert.Same(body, projectile.Body);
Assert.Equal(sphere, projectile.CollisionSphere);
Assert.True(
lifetime.Physics.IsSpatialProjectile(record, projectile));
Assert.Equal(1, lifetime.Physics.SpatialProjectileCount);
Assert.Equal(
1,
lifetime.Physics.CaptureOwnership().SpatialProjectileCount);
Assert.Same(
projectile,
lifetime.Physics.BindProjectile(record, body, sphere));
Assert.Throws<InvalidOperationException>(() =>
lifetime.Physics.BindProjectile(
record,
new PhysicsBody(),
sphere));
lifetime.Physics.RemoveSpatialProjection(record);
Assert.Equal(0, lifetime.Physics.SpatialProjectileCount);
Assert.Same(projectile, record.Projectile);
Assert.True(lifetime.Physics.ClearProjectile(record));
Assert.Null(record.Projectile);
}
[Fact]
public void ProjectileAuthoritativeMutationInvalidatesSplitPrediction()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000032u, 1));
lifetime.Entities.SetFullCell(record, 0x01010001u, 0x0101FFFFu);
lifetime.Entities.SetFinalPhysicsState(
record,
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped);
var body = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState = TransientStateFlags.Active,
};
body.set_velocity(new Vector3(10f, 0f, 0f));
body.SnapToCell(
record.FullCellId,
body.Position,
body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
IRuntimeProjectile projectile = lifetime.Physics.BindProjectile(
record,
body,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
lifetime.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
var updater =
new RuntimeProjectilePhysicsUpdater(lifetime.Physics);
Assert.True(updater.TryBegin(
record,
quantum: 0.05f,
record.ObjectClockEpoch,
externalOwnerValid: null,
out RuntimeProjectilePhysicsCommit commit));
lifetime.Entities.AdvanceVectorAuthority(record);
Vector3 correction = new(7f, 8f, 9f);
Assert.True(updater.ApplyAuthoritativeVector(
record,
record.VectorAuthorityVersion,
record.VelocityAuthorityVersion,
correction,
Vector3.UnitZ,
currentTime: 1.0));
Assert.True(
projectile.PredictionAuthorityVersion
> commit.PredictionAuthorityVersion);
Assert.False(updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
_ => true));
Assert.Equal(correction, body.Velocity);
Assert.Equal(Vector3.UnitZ, body.Omega);
}
[Fact]
public void ProjectileQuantumPublishesOneImmutableRuntimeFrame()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000033u, 1));
lifetime.Entities.SetFullCell(record, 0x01010001u, 0x0101FFFFu);
lifetime.Entities.SetFinalPhysicsState(
record,
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.PathClipped);
var body = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState = TransientStateFlags.Active,
};
body.SnapToCell(
record.FullCellId,
body.Position,
body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Physics.BindProjectile(
record,
body,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
lifetime.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
var updater =
new RuntimeProjectilePhysicsUpdater(lifetime.Physics);
Assert.True(updater.TryBegin(
record,
quantum: 0.05f,
record.ObjectClockEpoch,
externalOwnerValid: null,
out RuntimeProjectilePhysicsCommit commit));
int acknowledgements = 0;
RuntimePhysicsFrameSnapshot published = default;
Assert.True(updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
snapshot =>
{
published = snapshot;
acknowledgements++;
return true;
}));
Assert.Equal(1, acknowledgements);
Assert.Equal(body.Position, published.Position);
Assert.Equal(body.Orientation, published.Orientation);
Assert.Equal(record.FullCellId, published.FullCellId);
Assert.Throws<InvalidOperationException>(() =>
updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
_ => true));
}
[Fact] [Fact]
public void EqualLocalKeysInConcurrentRuntimesDoNotShareWorksets() public void EqualLocalKeysInConcurrentRuntimesDoNotShareWorksets()
{ {