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:
Erik 2026-07-26 13:39:57 +02:00
parent 0dc3bfdeff
commit 7e6033d0ad
39 changed files with 3685 additions and 1722 deletions

View file

@ -463,7 +463,7 @@ internal sealed class LivePresentationCompositionPhase
setupId => content.Dats.Get<Setup>(setupId)),
static value => value.Dispose());
var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater(
d.PhysicsEngine,
d.EntityObjects.Physics,
d.MotionBindings.GetSetupCylinder);
var animationScheduler = new LiveEntityAnimationScheduler(
liveEntities,
@ -858,7 +858,7 @@ internal sealed class LivePresentationCompositionPhase
removeEnvCells: envCellLease.Resource.RemoveLandblock,
envCellPublisher: envCellLease.Resource);
var landblockPhysicsPublisher = new LandblockPhysicsPublisher(
d.PhysicsEngine,
d.EntityObjects.Physics,
world.TerrainBuild.HeightTable);
var landblockStaticPublisher =
new LandblockStaticPresentationPublisher(

View file

@ -1,4 +1,15 @@
global using AcDream.Runtime.Gameplay;
global using AcDream.Runtime.Physics;
global using ILiveEntityRemoteMotionRuntime =
AcDream.Runtime.Physics.IRuntimeRemoteMotion;
global using ILiveEntityPhysicsHostConsumer =
AcDream.Runtime.Physics.IRuntimePhysicsHostConsumer;
global using ILiveEntityCanonicalRuntimeConsumer =
AcDream.Runtime.Physics.IRuntimeCanonicalPhysicsConsumer;
global using ILiveEntityCanonicalCellConsumer =
AcDream.Runtime.Physics.IRuntimeCanonicalCellConsumer;
global using ILiveEntityRemotePlacementRuntime =
AcDream.Runtime.Physics.IRuntimeRemotePlacement;
global using LocalPlayerControllerSlot =
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
global using ILocalPlayerControllerSource =

View file

@ -342,7 +342,7 @@ internal sealed class PlayerModeController :
},
interruptCurrentMovement: () => exactMovement.CancelMoveTo(
WeenieError.ActionCancelled));
playerHost = EntityPhysicsHost.SelectStableHostWithoutRebind(
playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities,
playerRecord,
configuredHost);
@ -443,7 +443,7 @@ internal sealed class PlayerModeController :
_camera.EnterChaseMode(legacyCamera, retailCamera);
EntityPhysicsHost stableAfterCamera =
EntityPhysicsHost.SelectStableHostWithoutRebind(
EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities,
playerRecord,
configuredHost);
@ -465,7 +465,7 @@ internal sealed class PlayerModeController :
// DAT, placement, shadow, and camera preparation has succeeded. A
// late preparation failure therefore cannot expose an abandoned
// controller through LiveEntityRecord.PhysicsHost.
EntityPhysicsHost publishedHost = EntityPhysicsHost.InstallOrRebind(
EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind(
_liveEntities,
playerRecord,
configuredHost);

View 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: () => { });
}
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -141,13 +141,14 @@ public sealed class GameWindow :
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12);
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
private AcDream.Core.Physics.PhysicsEngine _physicsEngine =>
_runtimeEntityObjects.Physics.Engine;
// Task 4: physics data cache — BSP trees + collision shapes extracted from
// GfxObj/Setup dats during streaming. Populated on the worker thread;
// ConcurrentDictionary inside makes cross-thread access safe.
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =
AcDream.Core.Physics.PhysicsDataCache.CreateProduction();
private AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =>
_runtimeEntityObjects.Physics.DataCache;
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
// >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion
@ -607,7 +608,7 @@ public sealed class GameWindow :
// setup/motion dependency crosses the fail-fast construction bridge;
// the server-velocity animation cycle is stateless shared policy.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine,
_runtimeEntityObjects.Physics,
_liveEntityMotionBindings.GetSetupCylinder,
AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher(
@ -1120,7 +1121,6 @@ public sealed class GameWindow :
{
// Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions.
_physicsEngine.DataCache = _physicsDataCache;
GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();

View file

@ -8,7 +8,7 @@ using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
using RemoteMotion = AcDream.Runtime.Physics.RemoteMotion;
namespace AcDream.App.Rendering;

View file

@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Physics;
using DatReaderWriter.Types;
namespace AcDream.App.Streaming;
@ -19,7 +20,8 @@ public sealed class LandblockPhysicsPublication
Vector3 origin,
uint currentCellId,
BuildingInfo[] buildings,
uint[] priorStaticOwnerIds)
uint[] priorStaticOwnerIds,
RuntimeCollisionAdmission collisionAdmission)
{
Owner = owner;
Build = build;
@ -27,6 +29,7 @@ public sealed class LandblockPhysicsPublication
CurrentCellId = currentCellId;
Buildings = buildings;
PriorStaticOwnerIds = priorStaticOwnerIds;
CollisionAdmission = collisionAdmission;
}
internal object Owner { get; }
@ -34,6 +37,7 @@ public sealed class LandblockPhysicsPublication
internal uint CurrentCellId { get; }
internal BuildingInfo[] Buildings { get; }
internal uint[] PriorStaticOwnerIds { get; }
internal RuntimeCollisionAdmission CollisionAdmission { get; }
internal SortedSet<uint> GfxObjectIdSet { get; } = new();
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
internal int PreparationCursor { get; set; }
@ -102,6 +106,7 @@ public readonly record struct LandblockPhysicsPublisherDiagnostics(
public sealed class LandblockPhysicsPublisher
{
private readonly object _receiptOwner = new();
private readonly RuntimePhysicsState _physics;
private readonly PhysicsEngine _physicsEngine;
private readonly PhysicsDataCache _physicsDataCache;
private readonly float[] _heightTable;
@ -121,21 +126,19 @@ public sealed class LandblockPhysicsPublisher
private long _fullRemovalCount;
public LandblockPhysicsPublisher(
PhysicsEngine physicsEngine,
RuntimePhysicsState physics,
float[] heightTable)
{
ArgumentNullException.ThrowIfNull(physicsEngine);
ArgumentNullException.ThrowIfNull(physics);
ArgumentNullException.ThrowIfNull(heightTable);
if (heightTable.Length < 256)
throw new ArgumentException(
"The retail terrain height table must contain at least 256 entries.",
nameof(heightTable));
_physicsEngine = physicsEngine;
_physicsDataCache = physicsEngine.DataCache
?? throw new ArgumentException(
"The physics engine must own its canonical data cache before publication wiring.",
nameof(physicsEngine));
_physics = physics;
_physicsEngine = physics.Engine;
_physicsDataCache = physics.DataCache;
_heightTable = (float[])heightTable.Clone();
}
@ -208,6 +211,8 @@ public sealed class LandblockPhysicsPublisher
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
buildings,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
build.Landblock.LandblockId),
_physics.BeginCollisionAdmission(
build.Landblock.LandblockId));
publication.SetupObjectIds = build.Collisions is { } collisions
? [.. collisions.SetupIds]
@ -332,18 +337,16 @@ public sealed class LandblockPhysicsPublisher
}
else if (!publication.BaseCommitted)
{
_physicsEngine.AddLandblock(
landblock.LandblockId,
publication.TerrainSurface,
publication.CellSurfaces,
publication.PortalPlanes,
origin.X,
origin.Y);
if ((publication.CurrentCellId & 0xFFFF0000u)
== (landblock.LandblockId & 0xFFFF0000u))
{
_physicsEngine.UpdatePlayerCurrCell(publication.CurrentCellId);
}
_physics.AdmitCollisionAssets(
publication.CollisionAdmission,
new RuntimeLandblockCollisionAssets(
landblock.LandblockId,
publication.TerrainSurface,
publication.CellSurfaces.ToArray(),
publication.PortalPlanes.ToArray(),
origin.X,
origin.Y,
publication.CurrentCellId));
publication.BaseCommitted = true;
}
else
@ -481,6 +484,8 @@ public sealed class LandblockPhysicsPublisher
}
else
{
_physics.CompleteCollisionAdmission(
publication.CollisionAdmission);
_staticBspOwnerCount += publication.BspOwnerCount;
_staticCylinderOwnerCount += publication.CylinderOwnerCount;
publication.CompletionCommitted = true;
@ -493,13 +498,13 @@ public sealed class LandblockPhysicsPublisher
public void DemoteToTerrain(uint landblockId)
{
_physicsEngine.DemoteLandblockToTerrain(landblockId);
_physics.DemoteCollisionToTerrain(landblockId);
_demotionCount++;
}
public void RemoveLandblock(uint landblockId)
{
_physicsEngine.RemoveLandblock(landblockId);
_physics.WithdrawCollision(landblockId);
_fullRemovalCount++;
}

View file

@ -20,65 +20,6 @@ public interface ILiveEntityAnimationRuntime
uint CurrentMotion { get; }
}
/// <summary>Remote motion state owned by a live object.</summary>
public interface ILiveEntityRemoteMotionRuntime
{
PhysicsBody Body { get; }
}
/// <summary>
/// Optional seam for a remote-motion component that reads the exact
/// incarnation's canonical movement-manager host from
/// <see cref="LiveEntityRecord"/>.
/// </summary>
public interface ILiveEntityPhysicsHostConsumer
{
void BindPhysicsHost(Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> read);
}
/// <summary>
/// Atomic composition seam for a component that consumes both canonical host
/// and cell identity. Implementations validate every delegate before
/// publishing any of them, so a failed bind leaves the component reusable.
/// </summary>
public interface ILiveEntityCanonicalRuntimeConsumer
{
void BindCanonicalRuntime(
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell);
}
/// <summary>
/// Optional seam for a remote-motion component that consumes the canonical
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
/// binds this for every production remote, regardless of whether projectile
/// classification arrives before or after the MovementManager.
/// </summary>
public interface ILiveEntityCanonicalCellConsumer
{
void BindCanonicalCell(Func<uint> read, Action<uint> write);
}
/// <summary>
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
/// placement. A same-incarnation runtime replacement may wrap the same body,
/// but it must preserve this complete placement contract.
/// </summary>
public interface ILiveEntityRemotePlacementRuntime :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalCellConsumer
{
uint CellId { get; set; }
bool Airborne { get; set; }
Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; }
Quaternion LastShadowSyncOrientation { get; set; }
void HitGround();
void LeaveGround();
}
/// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime
{
@ -296,14 +237,26 @@ public sealed class LiveEntityRecord
set => _directory.SetPhysicsBodyAcquisitionInProgress(Canonical, value);
}
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RemoteMotionBindingInProgress { get; set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime
{
get => Canonical.RemoteMotion;
internal set => _directory.SetRemoteMotion(Canonical, value);
}
internal bool RemoteMotionBindingInProgress
{
get => Canonical.RemoteMotionBindingInProgress;
set => _directory.SetRemoteMotionBindingInProgress(Canonical, value);
}
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost
{
get => Canonical.PhysicsHost;
internal set => _directory.SetPhysicsHost(Canonical, value);
}
internal bool RequiresRemotePlacementRuntime { get; set; }
internal bool RequiresRemotePlacementRuntime
{
get => Canonical.RequiresRemotePlacementRuntime;
set => _directory.SetRequiresRemotePlacementRuntime(Canonical, value);
}
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; }
@ -435,6 +388,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeEntityDirectory _directory;
private readonly RuntimePhysicsState _physics;
private readonly LiveEntityProjectionStore _projections;
// Logical components survive retail's 25-second leave-visibility lifetime.
// Frame loops must not scan those retained owners. These component-specific
@ -442,12 +396,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// projection. Hidden and Frozen remain members: their state controls what
// each retail update path advances after the O(active) lookup.
private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new();
private readonly Dictionary<RuntimeEntityKey, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotion = new();
private readonly Dictionary<RuntimeEntityKey, ILiveEntityProjectileRuntime> _spatialProjectiles = new();
// Retail CPhysics::UseTime walks the ordinary object table, not a render-
// animation component table. This index is the loaded/cell-backed root
// workset; component-specific indexes remain lookup accelerators only.
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _spatialRootObjects = new();
private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new();
private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new();
private bool _isClearing;
private bool _sessionClearPendingFinalization;
private bool _isRegisteringResources;
@ -492,8 +443,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_directory = _entityObjects.Entities;
_physics = _entityObjects.Physics;
_projections = new LiveEntityProjectionStore(_directory);
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
_physics.CellCommitted += OnRuntimePhysicsCellCommitted;
}
public int Count => _directory.Count;
@ -506,6 +459,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_projections.VisibleRecords;
internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords =>
_directory.ActiveRecords;
internal RuntimePhysicsState Physics => _physics;
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots;
internal int AnimationRuntimeCount
{
@ -522,9 +476,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
}
internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count;
internal int SpatialRemoteMotionRuntimeCount => _spatialRemoteMotion.Count;
internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount;
internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count;
internal int SpatialRootObjectCount => _spatialRootObjects.Count;
internal int SpatialRootObjectCount => _physics.SpatialRootCount;
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
internal bool TryGetCanonical(
@ -798,10 +752,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
RuntimeEntityKey key = RequireProjectionKey(record);
bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible;
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
key,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
ulong projectionOperation = ++record.ProjectionMutationVersion;
// GpuWorldState reports an intermediate false/true pair while moving
// between two loaded buckets. Suppress those implementation details
@ -936,10 +887,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false;
RuntimeEntityKey key = RequireProjectionKey(record);
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
key,
out LiveEntityRecord? rootRecord)
&& ReferenceEquals(rootRecord, record);
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionOperation = ++record.ProjectionMutationVersion;
Exception? spatialNotificationFailure = null;
@ -1436,43 +1384,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
throw new InvalidOperationException(
$"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized.");
}
if (record.PhysicsBody is { } retained)
return retained;
if (record.PhysicsBodyAcquisitionInProgress)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} physics-body acquisition is already in progress.");
}
record.PhysicsBodyAcquisitionInProgress = true;
try
{
PhysicsBody candidate = factory(record)
?? throw new InvalidOperationException("Physics-body factory returned null.");
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
|| current.WorldEntity is null)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-body acquisition.");
}
if (record.PhysicsBody is { } concurrentlyBound)
{
if (ReferenceEquals(concurrentlyBound, candidate))
return concurrentlyBound;
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} acquired two physics bodies within one incarnation.");
}
record.PhysicsBody = candidate;
candidate.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
return candidate;
}
finally
{
record.PhysicsBodyAcquisitionInProgress = false;
}
bool ProjectionIsCurrent() =>
_projections.TryGetCurrent(
serverGuid,
out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.WorldEntity is not null;
return _physics.GetOrCreatePhysicsBody(
record.Canonical,
_ => factory(record),
ProjectionIsCurrent);
}
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
@ -1480,122 +1401,17 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ArgumentNullException.ThrowIfNull(runtime);
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
if (record.RemoteMotionBindingInProgress)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress.");
PhysicsBody candidateBody = runtime.Body
?? throw new InvalidOperationException("A remote-motion runtime returned no physics body.");
if (ReferenceEquals(record.RemoteMotionRuntime, runtime))
{
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body.");
}
// Idempotent publication is important when motion and vector
// packets converge on the same runtime in one update turn. The
// component seams are incarnation-bound and must never be rebound.
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record);
return;
}
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
}
if (record.RequiresRemotePlacementRuntime
&& runtime is not ILiveEntityRemotePlacementRuntime)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
}
// Bind every possibly-throwing exact-incarnation seam before
// publishing any canonical record/index state. An already-bound
// component from an older GUID generation must fail without poisoning
// the replacement record. The callback is arbitrary App code, so the
// exact record, authority epoch, and expected owners are revalidated
// before commit just like resource/physics-body acquisition.
LiveEntityRecord incarnation = record;
ulong sessionVersion = _directory.SessionLifetimeVersion;
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
PhysicsBody? expectedBody = record.PhysicsBody;
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime;
bool expectedPlacementContract = record.RequiresRemotePlacementRuntime;
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost = () =>
_projections.TryGetCurrent(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
? current.PhysicsHost
: null;
// Reads remain bound to the exact CPhysicsObj incarnation through its
// exit_world notifications. Only writes require active ownership.
Func<uint> readCell = () => incarnation.FullCellId;
Action<uint> writeCell = cellId =>
{
if (cellId != 0
&& _projections.TryGetCurrent(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
{
RebucketLiveEntity(serverGuid, cellId);
}
};
record.RemoteMotionBindingInProgress = true;
try
{
if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer)
{
canonicalConsumer.BindCanonicalRuntime(
readPhysicsHost,
readCell,
writeCell);
}
else
{
bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer;
bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer;
if (consumesHost && consumesCell)
{
throw new InvalidOperationException(
"A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
}
if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer)
hostConsumer.BindPhysicsHost(readPhysicsHost);
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
cellConsumer.BindCanonicalCell(readCell, writeCell);
}
if (_directory.SessionLifetimeVersion != sessionVersion
|| CurrentLifetimeMutation(serverGuid) != lifetimeVersion
|| !_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current)
|| !ReferenceEquals(current, incarnation)
|| !ReferenceEquals(current.PhysicsBody, expectedBody)
|| !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime)
|| current.RequiresRemotePlacementRuntime != expectedPlacementContract)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding.");
}
record.RequiresRemotePlacementRuntime |=
runtime is ILiveEntityRemotePlacementRuntime;
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record);
}
finally
{
record.RemoteMotionBindingInProgress = false;
}
_physics.SetRemoteMotion(
record.Canonical,
runtime,
() => CurrentLifetimeMutation(serverGuid) == lifetimeVersion
&& _projections.TryGetCurrent(
serverGuid,
out LiveEntityRecord? current)
&& ReferenceEquals(current, record));
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record);
}
public void InstallPhysicsHost(
@ -1605,26 +1421,23 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(host);
uint serverGuid = expectedRecord.ServerGuid;
if (host.Id != serverGuid)
throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host));
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| !ReferenceEquals(record, expectedRecord))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation.");
}
if (record.PhysicsHost is not null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host.");
record.PhysicsHost = host;
bool ProjectionIsCurrent() =>
_projections.TryGetCurrent(
serverGuid,
out LiveEntityRecord? current)
&& ReferenceEquals(current, expectedRecord);
_physics.InstallPhysicsHost(
expectedRecord.Canonical,
host,
ProjectionIsCurrent);
}
public bool TryGetPhysicsHost(
uint serverGuid,
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
&& record.PhysicsHost is { } existing)
if (_projections.TryGetCurrent(serverGuid, out _)
&& _physics.TryGetPhysicsHost(serverGuid, out var existing))
{
host = existing;
return true;
@ -1656,7 +1469,8 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.RemoteMotionRuntime is null)
return false;
record.RemoteMotionRuntime = null;
if (!_physics.ClearRemoteMotion(record.Canonical))
return false;
RefreshSpatialRuntimeIndexes(record);
return true;
}
@ -1977,10 +1791,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, LiveEntityRecord indexed) in _spatialRootObjects)
_physics.CopySpatialRootsTo(_spatialRootCanonicalScratch);
for (int index = 0;
index < _spatialRootCanonicalScratch.Count;
index++)
{
if (_projections.TryGet(key, out LiveEntityRecord? current)
&& ReferenceEquals(current, indexed)
RuntimeEntityRecord canonical =
_spatialRootCanonicalScratch[index];
if (_projections.TryGet(
canonical,
out LiveEntityRecord? current)
&& HasSpatialRuntimeProjection(current))
{
destination.Add(current);
@ -1990,9 +1810,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) =>
IsCurrentRecord(record)
&& record.ProjectionKey is { } key
&& _spatialRootObjects.TryGetValue(key, out var indexed)
&& ReferenceEquals(indexed, record)
&& _physics.IsSpatialRoot(record.Canonical)
&& HasSpatialRuntimeProjection(record);
internal bool IsCurrentSpatialAnimation(
@ -2169,12 +1987,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, ILiveEntityRemoteMotionRuntime runtime)
in _spatialRemoteMotion)
_physics.CopySpatialRemotesTo(_spatialRemoteCanonicalScratch);
for (int index = 0;
index < _spatialRemoteCanonicalScratch.Count;
index++)
{
if (_projections.TryGet(key, out LiveEntityRecord? record)
&& ReferenceEquals(record.RemoteMotionRuntime, runtime)
RuntimeEntityRecord canonical =
_spatialRemoteCanonicalScratch[index];
if (_projections.TryGet(
canonical,
out LiveEntityRecord? record)
&& HasSpatialRuntimeProjection(record))
{
destination.Add(record);
@ -2186,10 +2008,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
LiveEntityRecord record,
ILiveEntityRemoteMotionRuntime runtime) =>
IsCurrentRecord(record)
&& ReferenceEquals(record.RemoteMotionRuntime, runtime)
&& record.ProjectionKey is { } key
&& _spatialRemoteMotion.TryGetValue(key, out var indexed)
&& ReferenceEquals(indexed, runtime)
&& _physics.IsSpatialRemote(record.Canonical, runtime)
&& HasSpatialRuntimeProjection(record);
/// <summary>
@ -2623,16 +2442,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return;
}
if (spatial)
{
_spatialRootObjects[key] = record;
}
else if (current
|| (_spatialRootObjects.TryGetValue(key, out var indexedRoot)
&& ReferenceEquals(indexedRoot, record)))
{
_spatialRootObjects.Remove(key);
}
_physics.AcknowledgeSpatialProjection(record.Canonical, spatial);
if (record.WorldEntity is not null)
{
@ -2649,18 +2459,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
}
if (spatial && record.RemoteMotionRuntime is { } remote)
{
_spatialRemoteMotion[key] = remote;
}
else if (current
|| (record.RemoteMotionRuntime is { } retainedRemote
&& _spatialRemoteMotion.TryGetValue(key, out var indexedRemote)
&& ReferenceEquals(indexedRemote, retainedRemote)))
{
_spatialRemoteMotion.Remove(key);
}
if (spatial && record.ProjectileRuntime is { } projectile)
{
_spatialProjectiles[key] = projectile;
@ -2679,11 +2477,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (record.ProjectionKey is not { } key)
return;
if (_spatialRootObjects.TryGetValue(key, out var indexedRoot)
&& ReferenceEquals(indexedRoot, record))
{
_spatialRootObjects.Remove(key);
}
_physics.RemoveSpatialProjection(record.Canonical);
if (record.WorldEntity is not null
&& _spatialAnimations.TryGetValue(key, out var indexedAnimation)
@ -2692,12 +2486,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_spatialAnimations.Remove(key);
}
if (_spatialRemoteMotion.TryGetValue(key, out var indexedRemote)
&& ReferenceEquals(indexedRemote, record.RemoteMotionRuntime))
{
_spatialRemoteMotion.Remove(key);
}
if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile)
&& ReferenceEquals(indexedProjectile, record.ProjectileRuntime))
{
@ -2725,10 +2513,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool wasVisible = record.IsSpatiallyVisible;
if (RequireProjectionKey(record) != key)
return;
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
key,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
record.IsSpatiallyVisible = visible;
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected
@ -2749,6 +2534,25 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
PublishProjectionVisibilityChanged(record, visible);
}
private void OnRuntimePhysicsCellCommitted(
RuntimePhysicsCellCommit commit)
{
if (commit.Record.SpatialAuthorityVersion
!= commit.SpatialAuthorityVersion
|| !_directory.IsCurrent(commit.Record)
|| !_projections.TryGet(
commit.Record,
out LiveEntityRecord? projection)
|| projection.WorldEntity is null)
{
return;
}
RebucketLiveEntity(
commit.Record.ServerGuid,
commit.FullCellId);
}
private static void SynchronizePhysicsBodyActiveState(
LiveEntityRecord record)
{
@ -2877,9 +2681,8 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
_spatialAnimations.Clear();
_spatialRemoteMotion.Clear();
_spatialProjectiles.Clear();
_spatialRootObjects.Clear();
_physics.ClearSpatialWorksets();
_projections.ClearConverged();
_spatial.ClearLiveEntityLifetimeState();
_sessionClearPendingFinalization = false;

View file

@ -1,5 +1,6 @@
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -338,6 +339,30 @@ public sealed class RuntimeEntityDirectory
record.PhysicsBodyAcquisitionInProgress = value;
}
public void SetRemoteMotion(
RuntimeEntityRecord record,
IRuntimeRemoteMotion? remote)
{
EnsureKnown(record);
record.RemoteMotion = remote;
}
public void SetRemoteMotionBindingInProgress(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.RemoteMotionBindingInProgress = value;
}
public void SetRequiresRemotePlacementRuntime(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.RequiresRemotePlacementRuntime = value;
}
public void SetPhysicsHost(
RuntimeEntityRecord record,
AcDream.Core.Physics.Motion.IPhysicsObjHost? host)

View file

@ -2,6 +2,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -74,6 +75,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(Entities);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
}
internal RuntimeEntityObjectLifetime(
PhysicsDataCache physicsDataCache,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
ArgumentNullException.ThrowIfNull(physicsDataCache);
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(Entities, physicsDataCache);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
@ -82,6 +98,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
}
public RuntimeEntityDirectory Entities { get; }
public RuntimePhysicsState Physics { get; }
public ClientObjectTable Objects { get; }
public IRuntimeEntityView EntityView { get; }
public IRuntimeInventoryView InventoryView { get; }
@ -324,6 +341,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RetainTeardown(canonical);
try
{
Physics.RemoveSpatialProjection(canonical);
Entities.SetRemoteMotion(canonical, null);
Entities.SetRemoteMotionBindingInProgress(canonical, false);
Entities.SetRequiresRemotePlacementRuntime(canonical, false);
Entities.SetPhysicsHost(canonical, null);
Entities.SetPhysicsBody(canonical, null);
Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false);
@ -960,6 +981,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
{
_disposed = true;
Events.Dispose();
Physics.Dispose();
}
if (failures is not null)

View file

@ -1,5 +1,6 @@
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -68,6 +69,9 @@ public sealed class RuntimeEntityRecord
public bool HasPartArray { get; internal set; }
public PhysicsBody? PhysicsBody { get; internal set; }
public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
public IRuntimeRemoteMotion? RemoteMotion { get; internal set; }
public bool RemoteMotionBindingInProgress { get; internal set; }
public bool RequiresRemotePlacementRuntime { get; internal set; }
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
public ulong PositionAuthorityVersion { get; private set; }
public ulong StateAuthorityVersion { get; private set; }

View file

@ -2,16 +2,15 @@ using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.App.World;
namespace AcDream.App.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// R5-V2 — the Runtime-owned <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
/// One is retained per accepted object incarnation on
/// <c>RuntimeEntityRecord</c>, so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
@ -22,8 +21,8 @@ namespace AcDream.App.Physics;
/// 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>
/// <see cref="HandleUpdateTarget"/> fan-out are injected by the graphical or
/// no-window host while Runtime retains the host and manager identities.</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
@ -135,7 +134,7 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
/// Applies a freshly composed delegate configuration while retaining this
/// host's exact identity and manager instances.
/// </summary>
private void RebindFrom(EntityPhysicsHost configuration)
internal void RebindFrom(EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration.Id != Id)
@ -156,105 +155,6 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
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();
@ -302,7 +202,7 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
// ── per-tick driver + Runtime-owned lifecycle ──────────────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
@ -321,8 +221,8 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
/// 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
/// not ported yet, so the current host 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.

View file

@ -1,6 +1,4 @@
using AcDream.App.World;
namespace AcDream.App.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
@ -21,11 +19,11 @@ namespace AcDream.App.Physics;
/// </para>
/// </summary>
internal sealed class RemoteMotion :
AcDream.App.World.ILiveEntityRemotePlacementRuntime,
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer
IRuntimeRemotePlacement,
IRuntimeCanonicalPhysicsConsumer
{
public AcDream.Core.Physics.PhysicsBody Body { get; }
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
AcDream.Core.Physics.PhysicsBody IRuntimeRemoteMotion.Body => Body;
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
/// ONE per-entity owner of the interp + moveto pair (acclient.h
@ -82,7 +80,7 @@ internal sealed class RemoteMotion :
// 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
// frame, and OTHER entities resolve this one through RuntimeEntityRecord's
// exact-incarnation PhysicsHost slot.
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
private bool _fullPhysicsHostBound;
@ -164,7 +162,7 @@ internal sealed class RemoteMotion :
/// 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,
/// <see cref="RuntimePhysicsState"/>, so projectile prediction,
/// Movement packets, and spatial rebucketing cannot develop competing
/// cell identities.
/// </summary>
@ -178,40 +176,40 @@ internal sealed class RemoteMotion :
}
}
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
System.Numerics.Vector3 IRuntimeRemotePlacement.LastServerPosition
{
get => LastServerPos;
set => LastServerPos = value;
}
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
double IRuntimeRemotePlacement.LastServerPositionTime
{
get => LastServerPosTime;
set => LastServerPosTime = value;
}
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
System.Numerics.Vector3 IRuntimeRemotePlacement.LastShadowSyncPosition
{
get => LastShadowSyncPos;
set => LastShadowSyncPos = value;
}
System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation
System.Numerics.Quaternion IRuntimeRemotePlacement.LastShadowSyncOrientation
{
get => LastShadowSyncOrientation;
set => LastShadowSyncOrientation = value;
}
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
bool IRuntimeRemotePlacement.Airborne
{
get => Airborne;
set => Airborne = value;
}
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
void IRuntimeRemotePlacement.HitGround() =>
Movement.HitGround();
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
void IRuntimeRemotePlacement.LeaveGround() =>
Motion.LeaveGround();
/// <summary>

View file

@ -0,0 +1,272 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
namespace AcDream.Runtime.Physics;
internal readonly record struct RuntimePhysicsFrameSnapshot(
Vector3 Position,
Quaternion Orientation,
uint FullCellId);
internal sealed class RuntimeOrdinaryPhysicsCommit
{
internal required RuntimeOrdinaryPhysicsUpdater Owner { get; init; }
internal required RuntimeEntityRecord Record { get; init; }
internal required PhysicsBody Body { get; init; }
internal required ulong ObjectClockEpoch { get; init; }
internal required bool FrameChanged { get; init; }
internal required Func<bool>? ExternalOwnerValid { get; init; }
internal bool Completed { get; set; }
internal RuntimePhysicsFrameSnapshot Snapshot { get; init; }
}
/// <summary>
/// Presentation-free, body-backed branch of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (`0x005156B0`). App supplies the
/// completed animation root frame and acknowledges the resulting projection;
/// Runtime owns integration, transition, canonical-body state, and shadows.
/// </summary>
internal sealed class RuntimeOrdinaryPhysicsUpdater
{
private readonly RuntimePhysicsState _physics;
internal RuntimeOrdinaryPhysicsUpdater(RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
internal bool TryBegin(
RuntimeEntityRecord record,
Frame rootFrame,
float objectScale,
float quantum,
float radius,
float height,
ulong objectClockEpoch,
AnimationSequencer? sequencer,
Action<uint, AnimationSequencer> captureAnimationHooks,
Func<bool>? externalOwnerValid,
out RuntimeOrdinaryPhysicsCommit commit)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rootFrame);
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
if (record.PhysicsBody is not { } body
|| !IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
body.State = record.FinalPhysicsState;
Vector3 priorPosition = body.Position;
Quaternion priorOrientation = body.Orientation;
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
// UpdatePositionInternal 0x00512CA1: scale root translation only while
// OnWalkable. Complete root orientation composes regardless.
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);
body.SetFrameInCurrentCell(body.Position, body.Orientation);
if (sequencer is not null)
{
uint localId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
captureAnimationHooks(localId, sequencer);
}
if (!IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
Vector3 integratedPosition = body.Position;
uint sourceCellId = record.FullCellId;
uint resolvedCellId = sourceCellId;
bool frameChanged = integratedPosition != priorPosition
|| body.Orientation != priorOrientation;
uint movingEntityId = record.LocalEntityId ?? 0u;
if (integratedPosition != priorPosition
&& sourceCellId != 0
&& radius >= 0.05f
&& _physics.Engine.LandblockCount > 0)
{
ResolveResult resolved = _physics.Engine.ResolveWithTransition(
priorPosition,
integratedPosition,
sourceCellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: previousOnWalkable,
body: body,
moverFlags: IsPlayerGuid(record.ServerGuid)
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
: ObjectInfoState.EdgeSlide,
movingEntityId: movingEntityId);
if (resolved.Ok)
{
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
{
body.CachedVelocity = Vector3.Zero;
}
}
else
{
body.CachedVelocity = Vector3.Zero;
}
if (!IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
commit = new RuntimeOrdinaryPhysicsCommit
{
Owner = this,
Record = record,
Body = body,
ObjectClockEpoch = objectClockEpoch,
FrameChanged = frameChanged,
ExternalOwnerValid = externalOwnerValid,
Snapshot = new RuntimePhysicsFrameSnapshot(
body.Position,
body.Orientation,
resolvedCellId),
};
return true;
}
internal bool Complete(
RuntimeOrdinaryPhysicsCommit commit,
int liveCenterX,
int liveCenterY,
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection)
{
ArgumentNullException.ThrowIfNull(commit);
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
if (!ReferenceEquals(commit.Owner, this))
{
throw new InvalidOperationException(
"An ordinary-physics commit belongs to another Runtime owner.");
}
if (commit.Completed)
{
throw new InvalidOperationException(
"An ordinary-physics commit has already completed.");
}
commit.Completed = true;
if (!IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid)
|| !_physics.CommitOrdinaryCell(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.Snapshot.FullCellId,
commit.ExternalOwnerValid)
|| !acknowledgeProjection(commit.Snapshot)
|| !IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
if (commit.FrameChanged
&& commit.Record.FullCellId != 0)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
commit.Record.LocalEntityId ?? 0u,
commit.Body.Position,
commit.Body.Orientation,
commit.Record.FullCellId,
liveCenterX,
liveCenterY);
}
return IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid);
}
private bool IsCurrent(
RuntimeEntityRecord record,
PhysicsBody body,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid) =>
_physics.IsSpatialRoot(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotion is null
&& (externalOwnerValid?.Invoke() ?? true);
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
}

View file

@ -0,0 +1,787 @@
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
public readonly record struct RuntimePhysicsOwnershipSnapshot(
int LandblockCount,
int RetainedShadowRegistrationCount,
int SpatialRootCount,
int SpatialRemoteCount,
int CollisionAdmissionCount,
int CollisionGenerationCount,
bool OwnsProductionDataCache,
bool IsDisposed);
public readonly record struct RuntimePhysicsCellCommit(
RuntimeEntityRecord Record,
uint PreviousFullCellId,
uint FullCellId,
ulong SpatialAuthorityVersion);
public sealed record RuntimeLandblockCollisionAssets(
uint LandblockId,
TerrainSurface Terrain,
IReadOnlyList<CellSurface> CellSurfaces,
IReadOnlyList<PortalPlane> PortalPlanes,
float WorldOffsetX,
float WorldOffsetY,
uint CurrentCellId);
public sealed class RuntimeCollisionAdmission
{
internal RuntimeCollisionAdmission(
RuntimePhysicsState owner,
uint landblockId,
ulong generation)
{
Owner = owner;
LandblockId = landblockId;
Generation = generation;
}
internal RuntimePhysicsState Owner { get; }
internal bool AssetsAdmitted { get; set; }
internal bool Completed { get; set; }
public uint LandblockId { get; }
public ulong Generation { get; }
}
public readonly record struct RuntimeCollisionAcknowledgement(
uint LandblockId,
ulong Generation,
bool WasResident);
/// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner.
/// Immutable prepared collision inputs may be supplied by a graphical or
/// no-window host, but the engine, transition scratch, cell graph, and shadow
/// registry are never shared between Runtime instances.
/// </summary>
public sealed class RuntimePhysicsState : IDisposable
{
private readonly Dictionary<RuntimeEntityKey, RuntimeEntityRecord>
_spatialRoots = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
_spatialRemotes = new();
private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new();
private bool _disposed;
public event Action<RuntimePhysicsCellCommit>? CellCommitted;
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
PhysicsDataCache? dataCache = null)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
DataCache = dataCache ?? PhysicsDataCache.CreateProduction();
Engine = new PhysicsEngine
{
DataCache = DataCache,
};
}
internal RuntimeEntityDirectory Entities { get; }
public PhysicsEngine Engine { get; }
public PhysicsDataCache DataCache { get; }
public int SpatialRootCount => _spatialRoots.Count;
public int SpatialRemoteCount => _spatialRemotes.Count;
public RuntimePhysicsOwnershipSnapshot CaptureOwnership() =>
new(
Engine.LandblockCount,
Engine.ShadowObjects.RetainedRegistrationCount,
_spatialRoots.Count,
_spatialRemotes.Count,
_collisionAdmissions.Count,
_collisionGenerations.Count,
ReferenceEquals(Engine.DataCache, DataCache),
_disposed);
public void AcknowledgeSpatialProjection(
RuntimeEntityRecord record,
bool spatial)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key)
{
if (spatial)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot enter the physics workset without a local identity.");
}
return;
}
if (spatial && Entities.IsCurrent(record))
{
_spatialRoots[key] = record;
if (record.RemoteMotion is { } remote)
_spatialRemotes[key] = remote;
else
_spatialRemotes.Remove(key);
return;
}
RemoveSpatialProjection(record);
}
public void RefreshRemoteComponent(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key
|| !_spatialRoots.TryGetValue(key, out RuntimeEntityRecord? root)
|| !ReferenceEquals(root, record)
|| !Entities.IsCurrent(record))
{
RemoveSpatialRemote(record);
return;
}
if (record.RemoteMotion is { } remote)
_spatialRemotes[key] = remote;
else
_spatialRemotes.Remove(key);
}
public void SetRemoteMotion(
RuntimeEntityRecord record,
IRuntimeRemoteMotion runtime,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(runtime);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before remote-motion binding.");
}
if (record.RemoteMotionBindingInProgress)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} remote-motion binding is already in progress.");
}
PhysicsBody candidateBody = runtime.Body
?? throw new InvalidOperationException(
"A remote-motion runtime returned no physics body.");
if (ReferenceEquals(record.RemoteMotion, runtime))
{
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} remote motion changed its canonical physics body.");
}
candidateBody.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
RefreshRemoteComponent(record);
return;
}
if (record.PhysicsBodyAcquisitionInProgress
&& record.PhysicsBody is null)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot bind remote motion during physics-body acquisition.");
}
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot replace its canonical physics body.");
}
if (record.RequiresRemotePlacementRuntime
&& runtime is not IRuntimeRemotePlacement)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot discard its remote-placement contract.");
}
ulong sessionVersion = Entities.SessionLifetimeVersion;
PhysicsBody? expectedBody = record.PhysicsBody;
IRuntimeRemoteMotion? expectedRuntime = record.RemoteMotion;
bool expectedPlacementContract =
record.RequiresRemotePlacementRuntime;
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost =
() => Entities.IsCurrent(record)
&& ReferenceEquals(record.RemoteMotion, runtime)
? record.PhysicsHost
: null;
Func<uint> readCell = () => record.FullCellId;
Action<uint> writeCell = cellId =>
CommitCanonicalCell(record, runtime, cellId);
Entities.SetRemoteMotionBindingInProgress(record, true);
try
{
if (runtime is IRuntimeCanonicalPhysicsConsumer canonicalConsumer)
{
canonicalConsumer.BindCanonicalRuntime(
readPhysicsHost,
readCell,
writeCell);
}
else
{
bool consumesHost = runtime is IRuntimePhysicsHostConsumer;
bool consumesCell = runtime is IRuntimeCanonicalCellConsumer;
if (consumesHost && consumesCell)
{
throw new InvalidOperationException(
"A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
}
if (runtime is IRuntimePhysicsHostConsumer hostConsumer)
hostConsumer.BindPhysicsHost(readPhysicsHost);
if (runtime is IRuntimeCanonicalCellConsumer cellConsumer)
cellConsumer.BindCanonicalCell(readCell, writeCell);
}
if (Entities.SessionLifetimeVersion != sessionVersion
|| !Entities.IsCurrent(record)
|| !ReferenceEquals(record.PhysicsBody, expectedBody)
|| !ReferenceEquals(record.RemoteMotion, expectedRuntime)
|| record.RequiresRemotePlacementRuntime
!= expectedPlacementContract
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during remote-motion binding.");
}
Entities.SetRequiresRemotePlacementRuntime(
record,
expectedPlacementContract
|| runtime is IRuntimeRemotePlacement);
Entities.SetRemoteMotion(record, runtime);
Entities.SetPhysicsBody(record, candidateBody);
candidateBody.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
RefreshRemoteComponent(record);
}
finally
{
if (Entities.IsCurrent(record))
{
Entities.SetRemoteMotionBindingInProgress(record, false);
}
}
}
/// <summary>
/// Acquires the one retail <c>CPhysicsObj</c> body for an exact accepted
/// object incarnation. Factories may cross a host boundary, so ownership
/// is revalidated after the callback before the body becomes canonical.
/// </summary>
public PhysicsBody GetOrCreatePhysicsBody(
RuntimeEntityRecord record,
Func<RuntimeEntityRecord, PhysicsBody> factory,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(factory);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before physics-body acquisition.");
}
if (record.PhysicsBody is { } retained)
return retained;
if (record.PhysicsBodyAcquisitionInProgress)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} physics-body acquisition is already in progress.");
}
Entities.SetPhysicsBodyAcquisitionInProgress(record, true);
try
{
PhysicsBody candidate = factory(record)
?? throw new InvalidOperationException(
"Physics-body factory returned null.");
if (!Entities.IsCurrent(record)
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during physics-body acquisition.");
}
if (record.PhysicsBody is { } concurrentlyBound)
{
if (ReferenceEquals(concurrentlyBound, candidate))
return concurrentlyBound;
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} acquired two physics bodies within one incarnation.");
}
Entities.SetPhysicsBody(record, candidate);
candidate.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
return candidate;
}
finally
{
// A callback can retire this incarnation. The in-progress bit
// belongs to the record itself and must not strand its teardown.
if (Entities.IsCurrent(record))
Entities.SetPhysicsBodyAcquisitionInProgress(record, false);
else
record.PhysicsBodyAcquisitionInProgress = false;
}
}
public void InstallPhysicsHost(
RuntimeEntityRecord record,
AcDream.Core.Physics.Motion.IPhysicsObjHost host,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(host);
EnsureCurrent(record);
if (host.Id != record.ServerGuid)
{
throw new ArgumentException(
"A physics host must match its runtime entity GUID.",
nameof(host));
}
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before physics-host installation.");
}
if (record.PhysicsHost is not null)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} already owns its incarnation-stable physics host.");
}
Entities.SetPhysicsHost(record, host);
if (!Entities.IsCurrent(record)
|| !(externalOwnerValid?.Invoke() ?? true))
{
if (ReferenceEquals(record.PhysicsHost, host))
record.PhysicsHost = null;
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during physics-host installation.");
}
}
public EntityPhysicsHost InstallOrRebindPhysicsHost(
RuntimeEntityRecord record,
EntityPhysicsHost configuration,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(configuration);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before physics-host composition.");
}
if (record.PhysicsHost is null)
{
InstallPhysicsHost(record, configuration, externalOwnerValid);
return configuration;
}
if (record.PhysicsHost is not EntityPhysicsHost existing)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} owns an incompatible physics-host implementation.");
}
existing.RebindFrom(configuration);
if (!Entities.IsCurrent(record)
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during physics-host composition.");
}
return existing;
}
public EntityPhysicsHost SelectStablePhysicsHostWithoutRebind(
RuntimeEntityRecord record,
EntityPhysicsHost configuration,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(configuration);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership during physics-host preparation.");
}
return record.PhysicsHost switch
{
null => configuration,
EntityPhysicsHost existing => existing,
_ => throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} owns an incompatible physics-host implementation."),
};
}
public bool TryGetPhysicsHost(
uint serverGuid,
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
EnsureNotDisposed();
if (Entities.TryGetActive(serverGuid, out RuntimeEntityRecord record)
&& record.PhysicsHost is { } existing)
{
host = existing;
return true;
}
host = null!;
return false;
}
public bool ClearRemoteMotion(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (!Entities.IsCurrent(record)
|| record.RemoteMotion is null)
{
return false;
}
Entities.SetRemoteMotion(record, null);
RefreshRemoteComponent(record);
return true;
}
public void RemoveSpatialProjection(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key)
return;
if (_spatialRoots.TryGetValue(key, out RuntimeEntityRecord? root)
&& ReferenceEquals(root, record))
{
_spatialRoots.Remove(key);
}
RemoveSpatialRemote(record);
}
public bool IsSpatialRoot(RuntimeEntityRecord record) =>
record.Key is { } key
&& Entities.IsCurrent(record)
&& _spatialRoots.TryGetValue(key, out RuntimeEntityRecord? indexed)
&& ReferenceEquals(indexed, record);
public bool IsSpatialRemote(
RuntimeEntityRecord record,
IRuntimeRemoteMotion remote) =>
IsSpatialRoot(record)
&& ReferenceEquals(record.RemoteMotion, remote)
&& record.Key is { } key
&& _spatialRemotes.TryGetValue(key, out IRuntimeRemoteMotion? indexed)
&& ReferenceEquals(indexed, remote);
public void CopySpatialRootsTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, RuntimeEntityRecord record)
in _spatialRoots)
{
if (record.Key == key
&& Entities.IsCurrent(record))
{
destination.Add(record);
}
}
}
public void CopySpatialRemotesTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, IRuntimeRemoteMotion remote)
in _spatialRemotes)
{
if (Entities.TryGetByLocalId(
key.LocalEntityId,
out RuntimeEntityRecord record)
&& record.Key == key
&& ReferenceEquals(record.RemoteMotion, remote)
&& IsSpatialRoot(record))
{
destination.Add(record);
}
}
}
internal bool CommitOrdinaryCell(
RuntimeEntityRecord record,
PhysicsBody body,
ulong objectClockEpoch,
uint fullCellId,
Func<bool>? externalOwnerValid)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
bool IsExactOwner() =>
IsSpatialRoot(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotion is null
&& (externalOwnerValid?.Invoke() ?? true);
return CommitCanonicalCell(
record,
fullCellId,
IsExactOwner);
}
public void ClearSpatialWorksets()
{
EnsureNotDisposed();
_spatialRemotes.Clear();
_spatialRoots.Clear();
}
public RuntimeCollisionAdmission BeginCollisionAdmission(
uint landblockId)
{
EnsureNotDisposed();
uint canonical = CanonicalLandblock(landblockId);
ulong generation = _collisionGenerations.TryGetValue(
canonical,
out ulong current)
? checked(current + 1UL)
: 1UL;
_collisionGenerations[canonical] = generation;
var admission = new RuntimeCollisionAdmission(
this,
canonical,
generation);
_collisionAdmissions[canonical] = admission;
return admission;
}
public void AdmitCollisionAssets(
RuntimeCollisionAdmission admission,
RuntimeLandblockCollisionAssets assets)
{
ValidateAdmission(admission);
ArgumentNullException.ThrowIfNull(assets);
if (CanonicalLandblock(assets.LandblockId)
!= admission.LandblockId)
{
throw new ArgumentException(
"Collision assets do not match their admission landblock.",
nameof(assets));
}
if (admission.Completed)
{
throw new InvalidOperationException(
"A completed collision admission cannot publish more assets.");
}
if (admission.AssetsAdmitted)
{
throw new InvalidOperationException(
"Collision assets were already admitted by this receipt.");
}
Engine.AddLandblock(
admission.LandblockId,
assets.Terrain,
assets.CellSurfaces,
assets.PortalPlanes,
assets.WorldOffsetX,
assets.WorldOffsetY);
if ((assets.CurrentCellId & 0xFFFF0000u)
== (admission.LandblockId & 0xFFFF0000u))
{
Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
}
admission.AssetsAdmitted = true;
}
public RuntimeCollisionAcknowledgement CompleteCollisionAdmission(
RuntimeCollisionAdmission admission)
{
ValidateAdmission(admission);
if (!admission.AssetsAdmitted)
{
throw new InvalidOperationException(
"Collision admission cannot complete before its assets publish.");
}
if (admission.Completed)
{
throw new InvalidOperationException(
"Collision admission has already completed.");
}
admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId);
return new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId));
}
public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain(
uint landblockId)
{
EnsureNotDisposed();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
Engine.DemoteLandblockToTerrain(canonical);
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
}
public RuntimeCollisionAcknowledgement WithdrawCollision(
uint landblockId)
{
EnsureNotDisposed();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
Engine.RemoveLandblock(canonical);
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
}
public void Dispose()
{
if (_disposed)
return;
_spatialRemotes.Clear();
_spatialRoots.Clear();
_collisionAdmissions.Clear();
_collisionGenerations.Clear();
CellCommitted = null;
_disposed = true;
}
private void CommitCanonicalCell(
RuntimeEntityRecord record,
IRuntimeRemoteMotion expectedRuntime,
uint fullCellId)
{
_ = CommitCanonicalCell(
record,
fullCellId,
() => Entities.IsCurrent(record)
&& ReferenceEquals(record.RemoteMotion, expectedRuntime));
}
private bool CommitCanonicalCell(
RuntimeEntityRecord record,
uint fullCellId,
Func<bool> exactOwnerValid)
{
if (fullCellId == 0 || !exactOwnerValid())
return false;
if (fullCellId == record.FullCellId)
return true;
uint previousCellId = record.FullCellId;
Entities.SetFullCell(
record,
fullCellId,
(fullCellId & 0xFFFF0000u) | 0xFFFFu);
CellCommitted?.Invoke(
new RuntimePhysicsCellCommit(
record,
previousCellId,
fullCellId,
record.SpatialAuthorityVersion));
return exactOwnerValid()
&& record.FullCellId == fullCellId;
}
private void RemoveSpatialRemote(RuntimeEntityRecord record)
{
if (record.Key is not { } key)
return;
if (_spatialRemotes.TryGetValue(
key,
out IRuntimeRemoteMotion? remote)
&& (record.RemoteMotion is null
|| ReferenceEquals(remote, record.RemoteMotion)
|| !Entities.IsCurrent(record)))
{
_spatialRemotes.Remove(key);
}
}
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private void EnsureCurrent(RuntimeEntityRecord record)
{
if (!Entities.IsCurrent(record))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} is not current.");
}
}
private void ValidateAdmission(RuntimeCollisionAdmission admission)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(admission);
if (!ReferenceEquals(admission.Owner, this)
|| !_collisionAdmissions.TryGetValue(
admission.LandblockId,
out RuntimeCollisionAdmission? current)
|| !ReferenceEquals(current, admission)
|| !_collisionGenerations.TryGetValue(
admission.LandblockId,
out ulong generation)
|| generation != admission.Generation)
{
throw new InvalidOperationException(
"Collision admission is stale or belongs to another Runtime.");
}
}
private void InvalidateCollisionAdmission(uint landblockId)
{
ulong generation = _collisionGenerations.TryGetValue(
landblockId,
out ulong current)
? checked(current + 1UL)
: 1UL;
_collisionGenerations[landblockId] = generation;
_collisionAdmissions.Remove(landblockId);
}
private static uint CanonicalLandblock(uint value) =>
(value & 0xFFFF0000u) | 0xFFFFu;
private static void SynchronizeBodyActiveState(RuntimeEntityRecord record)
{
if (record.PhysicsBody is not { } body)
return;
if (record.ObjectClock.IsActive)
body.TransientState |= TransientStateFlags.Active;
else
body.TransientState &= ~TransientStateFlags.Active;
}
}

View file

@ -0,0 +1,42 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Runtime.Physics;
public interface IRuntimeRemoteMotion
{
PhysicsBody Body { get; }
}
public interface IRuntimePhysicsHostConsumer
{
void BindPhysicsHost(Func<IPhysicsObjHost?> read);
}
public interface IRuntimeCanonicalPhysicsConsumer
{
void BindCanonicalRuntime(
Func<IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell);
}
public interface IRuntimeCanonicalCellConsumer
{
void BindCanonicalCell(Func<uint> read, Action<uint> write);
}
public interface IRuntimeRemotePlacement :
IRuntimeRemoteMotion,
IRuntimeCanonicalCellConsumer
{
uint CellId { get; set; }
bool Airborne { get; set; }
Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; }
Quaternion LastShadowSyncOrientation { get; set; }
void HitGround();
void LeaveGround();
}

View file

@ -0,0 +1,900 @@
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal readonly record struct RuntimeRemotePhysicsSnapshot(
System.Numerics.Vector3 Position,
System.Numerics.Quaternion Orientation,
uint FullCellId);
/// <summary>
/// #184 Slice 2a extracted the per-remote dead-reckoning physics tick
/// verbatim from the former graphical frame owner. Slice J5.5 moved that
/// presentation-free simulation under the per-session Runtime physics owner.
/// It is called once per eligible remote entity per retail object quantum,
/// preserving the same guard
/// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 0</c>).
/// Hidden remotes use a separate live-entity pass because retail keeps their
/// PositionManager alive even when the object has no render-animation owner.
///
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
///
/// <para>Shared policy arrives through focused Physics delegates:
/// DAT-derived shape dimensions and animation-cycle projection arrive through
/// the App adapter in a graphical host. <c>SyncRemoteShadowToBody</c>
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
/// callers and moved here outright.</para>
/// </summary>
internal sealed class RuntimeRemotePhysicsUpdater
{
// Preserved from #184 Slice 2a; the remote tick remains its only caller.
private const double ServerControlledVelocityStaleSeconds = 0.60;
private readonly RuntimePhysicsState _physics;
internal RuntimeRemotePhysicsUpdater(
RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
// Retail GUID classification used only for collision flags.
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
/// <summary>
/// Canonical ordinary-object tick. Render animation is optional: retail
/// walks <c>CPhysics::object_maint</c>, so a live object with a
/// MovementManager or PositionManager must continue even when it has no
/// render-animation presentation component.
/// </summary>
internal bool Tick(
RuntimeEntityRecord record,
RemoteMotion rm,
float objectScale,
AcDream.Core.Physics.AnimationSequencer? sequencer,
float dt,
ulong objectClockEpoch,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
float radius,
float height,
int liveCenterX,
int liveCenterY,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
System.Action<System.Numerics.Vector3>? applyStaleVelocityCycle = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
uint serverGuid = record.ServerGuid;
uint localEntityId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{serverGuid:X8}/{record.Incarnation} has no local identity.");
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
// problem" premise) is GONE — every remote now runs the SAME catch-up +
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
// player/remote fork; only the stale animation-cycle stop below remains
// player/NPC-specific.
//
// Stop detection stays explicit on packet receipt (UpdateMotion
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
// The bare block scopes this update's locals (formerly the else body).
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales
// the CSequence root displacement by m_scale only while the body
// is OnWalkable; otherwise it clears the displacement. Grounded
// remotes below are explicitly OnWalkable and airborne remotes use
// their authoritative velocity/gravity arc, so this is the same
// branch expressed through our retained runtime state.
System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
? rootMotionLocalFrame.Origin * objectScale
: System.Numerics.Vector3.Zero;
// Step 1: re-apply current motion commands → body.Velocity.
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
// simulate airborne physics for them).
//
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
// Otherwise the force-OnWalkable + apply_current_movement
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
// and gravity never gets to integrate the arc. The airborne
// body keeps the launch velocity from the VectorUpdate;
// UpdatePhysicsInternal below applies gravity each tick;
// the next UpdatePosition snaps to the new ground location
// and re-grounds.
if (!rm.Airborne)
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
// #184 (2026-07-07): a grounded remote carries NO translation
// velocity. Its per-tick movement is the interp CATCH-UP toward
// the MoveOrTeleport-queued server waypoint (computed at the
// sticky-compose site below), which the KEPT ResolveWithTransition
// sweep de-overlaps against neighbours — and the resolved position
// is written back into the SHADOW (below) so the de-overlap
// persists and neighbours collide against the resolved body, not
// the raw server pos. This REPLACES the old synth-velocity model
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
// leg — a remote translates by adjust_offset and the UP is a gentle
// target. As of #184 Slice 2b this grounded model is the SINGLE
// remote path (players + NPCs) — retail has no fork.
rm.Body.Velocity = System.Numerics.Vector3.Zero;
// Stale server-velocity → stop the locomotion CYCLE (the legs).
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
// so a scripted-path NPC that stops server-side drops out of its
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
// anim from ServerVelocity, independent of Body.Velocity.
bool moveToArmed = rm.MoveTo is
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
bool stickyArmed =
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
&& !moveToArmed && !stickyArmed)
{
double velocityAge = nowSec - rm.LastServerPosTime;
if (velocityAge > ServerControlledVelocityStaleSeconds)
{
rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false;
applyStaleVelocityCycle?.Invoke(
System.Numerics.Vector3.Zero);
}
}
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
// MovementManager::UseTime per tick, UpdateObjectInternal call
// @0x00515998) — UseTime runs HandleMoveToPosition /
// HandleTurnToHeading (steering + arrival + fail-distance),
// dispatching its per-node locomotion (turn / RunForward) through
// the sink (the LEGS). Position comes from the catch-up; legs from
// this per-node dispatch + the funnel. The #170-deleted per-frame
// apply_current_movement is NOT reintroduced.
}
else
{
// Airborne — keep Active flag (so UpdatePhysicsInternal
// doesn't early-return) but DON'T set Contact / OnWalkable.
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 2: CSequence's complete Frame carries motion-table omega through
// the same compose as root translation. PhysicsBody.Omega remains
// reserved for the object's physical angular velocity and is
// integrated once by UpdatePhysicsInternal below.
// Step 3: integrate physics — retail FUN_005111D0
// UpdatePhysicsInternal. Pure Euler:
// position += velocity × dt + 0.5 × accel × dt²
//
// Call UpdatePhysicsInternal DIRECTLY rather than via
// PhysicsBody.update_object (FUN_00515020). update_object gates
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
// deltaTime < MinQuantum → early return AND LastUpdateTime is
// NOT advanced. Net effect: position never integrates between
// UpdatePositions and the only Body.Position changes come
// from the UP hard-snap, producing a visible teleport-stride
// on slopes (the "staircase" the user reported).
//
// PlayerMovementController calls UpdatePhysicsInternal directly
// for the same reason. Remote motion mirrors that. CSequence's
// authored orientation has already entered the shared delta Frame;
// Body.Omega remains the separate physical angular-velocity source.
var preIntegratePos = rm.Body.Position;
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
// sweep so collision resolves whichever movement won (preIntegratePos
// captured first — the sweep covers it).
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
// the movement source is the adjust_offset walk toward the
// MoveOrTeleport-queued server waypoint, exactly like Path A
// (:10173). StickyManager::adjust_offset then OVERWRITES the
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
// dichotomy), so a stuck monster still steers via #171.
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
// from velocity + gravity, unchanged).
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
// adds no translation on top of the catch-up — no double-move.
if (rm.Host is { } npcHost)
{
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physics.Engine.SampleTerrainNormal(
rm.Body.Position.X,
rm.Body.Position.Y)
: null;
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
pmDelta,
rm.Interp,
maxSpeedNpc,
pmDelta,
terrainNormalNpc,
inContact: rm.Body.InContact);
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
else
{
// No PositionManager host yet (pre-binding): apply the catch-up
// directly, matching Path A's fallback (:10202).
// Airborne suppresses only CPartArray Origin. Retail keeps the
// complete root orientation live across the OnWalkable scale
// gate in UpdatePositionInternal (0x00512C30).
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physics.Engine.SampleTerrainNormal(
rm.Body.Position.X,
rm.Body.Position.Y)
: null;
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
pmDelta,
rm.Interp,
maxSpeedNpc,
pmDelta,
terrainNormalNpc,
inContact: rm.Body.InContact);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt);
if (sequencer is { } hookSequencer)
processAnimationHooks?.Invoke(localEntityId, hookSequencer);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
var postIntegratePos = rm.Body.Position;
uint committedCellId = rm.CellId;
// Step 4: collision sweep — retail FUN_00514B90 →
// FUN_005148A0 → Transition::FindTransitionalPosition.
// Projects the sphere from preIntegratePos to postIntegratePos
// through the BSP + terrain, resolving:
// - terrain Z snap along the slope (fixes the "staircase" where
// horizontal Euler motion up a slope sinks into rising ground
// until the next UP pops it up)
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
// - object collisions via ShadowObjectRegistry
// - step-up / step-down against walkable ledges
// ResolveWithTransition is the same call PlayerMovementController
// uses for the local player; remotes now get the full retail
// treatment between UpdatePositions instead of pure kinematics.
//
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
// a SpherePath without a starting cell). One-frame grace until
// the first UP arrives; harmless because the entity is
// server-freshly-spawned at a valid Z anyway.
if (rm.CellId != 0 && _physics.Engine.LandblockCount > 0)
{
// #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
// creatures de-overlap at their TRUE radii (a big monster
// spreads wider, a small one tighter), not the hardcoded
// human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
// setup.Height) × ObjScale — the creature's own dat Setup
// scaled by its wire ObjScale, the same source the local
// player + moveto/sticky use, and consistent with the
// spawn-time shadow registration's entScale. Retail seeds
// the transition from the object's own Setup sphere list ×
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
// ObjScale from set_description 0x00514f40). This narrows
// TS-46 (remotes no longer use human dims); the two-scalar
// API is still a lossy stand-in for retail's full (≤2)
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
// those from the Setup too — an adjacent divergence left as-is).
// Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep.
float deR = radius;
float deH = height;
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
var resolveResult = _physics.Engine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: deR,
sphereHeight: deH,
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
// airborne remotes must NOT pre-seed the
// ContactPlane, otherwise AdjustOffset's snap-to-plane
// branch zeroes the +Z offset every step (same bug
// we hit on the local jump).
isOnGround: !rm.Airborne,
body: rm.Body, // persist ContactPlane across frames for slope tracking
// Retail default physics state includes EdgeSlide; remote DR
// should exercise the same edge/cliff branch as local movement.
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
// CollisionExemption's PvP block fires exactly as it does for the
// LOCAL player (PlayerMovementController :920) — two non-PK players
// WALK THROUGH each other (retail sets IsPlayer on every object's
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
// non-PK player pair). Without IsPlayer the mover would de-overlap
// two players — MORE solid than retail (you can stand inside another
// non-PK player in AC). Players still COLLIDE with monsters (target
// not IsPlayer → no exemption) + terrain + walls. PK/PKLite/
// Impenetrable are NOT plumbed onto the remote mover yet, so a PK
// pair walks through where retail collides — the SAME M1.5 gap the
// local player carries (see TS-23; PlayerDescription PK status
// unparsed).
moverFlags: IsPlayerGuid(serverGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
// Fix #42 (2026-05-05): skip the moving remote's
// own ShadowEntry. _animatedEntities is keyed by
// entity.Id so kv.Key matches the EntityId the
// ShadowObjectRegistry has for this remote.
// Without this, the airborne sweep collides with
// the remote's own cylinder and produces ~1m of
// horizontal drift on the first jump frame
// (validated by [SWEEP-OBJ] traces).
movingEntityId: localEntityId);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
committedCellId = resolveResult.CellId;
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
// re-registers a moved object's shadow every transition step
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
// 0x00515330) so its m_position — the RESOLVED position — is what
// OTHER creatures collide against. acdream's shadow otherwise only
// syncs to the RAW server pos on UpdatePosition, so neighbours would
// de-overlap against each other's OVERLAPPING shadows and any spread
// would be discarded on the next UP (never accumulating), AND the
// player would collide with a shadow offset from where the monster
// renders (the reverted attempt's "stuck on an invisible monster").
// Syncing the shadow to the resolved body every tick makes the
// de-overlap PERSIST and keeps collision == render. Re-flood is
// POSE/CELL-GATED (#184 review): re-flood only when the resolved
// body moved > ~1 cm, rotated, or entered another cell. This is
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
// players too — every remote's shadow (player + NPC) is written ONLY
// by this loop + the UP-branch tail, both to the resolved body, so a
// net-stationary (de-overlapped, sweep-
// blocked) creature keeps its correct shadow and never re-floods,
// while a moving/de-overlapping crowd (which moves every tick) still
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
// to actually-moving remotes — the perf risk the review flagged for
// a packed town. (In-place shadow-move + cell-relink-on-change is a
// further optimization if profiling still shows churn.)
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc
// is ordinary object physics in retail. acdream ported the
// velocity reflection for the LOCAL player only (L.3a,
// PlayerMovementController ~:940), so a remote jumping into
// a dungeon ceiling had its POSITION pinned by the sweep
// while its +Z velocity kept integrating — the char hovered
// at the roof until gravity burned the arc off, landing
// late (user report, 0x0007 dungeon). Mirror the local
// site exactly:
// v_new = v (1 + elasticity)·dot(v, n)·n
// with the AD-25 suppression (bounce only when airborne
// before AND after — corridor slides and landings don't
// reflect; the landing snap below keeps its
// `Velocity.Z <= 0` gate intact). Inelastic movers
// (missiles, later) zero out instead.
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = rm.Body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
bool applyBounce = rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
if (applyBounce)
{
if (rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
{
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
var vRem = rm.Body.Velocity;
var nRem = resolveResult.CollisionNormal;
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
if (dotVN < 0f)
{
rm.Body.Velocity =
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine(
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
}
}
}
}
// K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors
// PlayerMovementController's local-player landing
// path: when the resolver says we're on ground AND
// velocity is no longer pointing up, transition
// back to grounded — clear Airborne, restore
// Contact + OnWalkable, remove Gravity, zero any
// residual downward velocity, and trigger
// HitGround so the sequencer can swap from
// Falling → idle/locomotion. Without this, an
// airborne remote falls through the floor (gravity
// keeps building Velocity.Z negative until the
// sphere-sweep clamps each frame, but Airborne
// stays true forever).
if (rm.Airborne
&& resolveResult.IsOnGround
&& rm.Body.Velocity.Z <= 0f)
{
rm.Airborne = false;
// #184 (2026-07-07): clear the interp queue on landing (mirrors
// the player-remote landing). Airborne UPs hard-snap and never
// Enqueue, so any pre-jump waypoints are stale; without this the
// first grounded catch-up after touchdown chases them backward.
rm.Interp.Clear();
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
// #161: HitGround MUST run with the Gravity state
// bit still set — CMotionInterp::HitGround
// (0x00528ac0) gates on state&0x400 (retail never
// clears GRAVITY on landing; it's a persistent
// object property). Clearing it first made this
// re-apply a silent no-op, which is why the
// falling pose never exited. The re-apply
// dispatches the PRESERVED pre-fall forward
// command through the funnel → the motion table
// plays the Falling→X landing link. (The old
// K-fix17 forced SetCycle is deleted: it read the
// then-clobbered InterpretedState.ForwardCommand
// — 0x40000015 — and re-set the very Falling
// cycle it meant to clear.)
// R4-V5 (closes the V4 wiring-contract gap the
// adversarial review caught): retail order —
// minterp first, then moveto (MovementManager::
// HitGround 0x00524300, §2d — the R5-V5 facade
// relay). Re-arms a moveto suspended by the
// airborne UseTime contact gate; without it a
// chasing NPC that lands stalls until ACE's
// ~1 Hz re-emit.
ulong landingStateAuthorityVersion =
record.StateAuthorityVersion;
rm.Movement.HitGround();
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body.
if (record.StateAuthorityVersion
== landingStateAuthorityVersion)
{
rm.Body.State &=
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
}
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
}
// SetPositionInternal commits the resolved body/contact result and
// root frame as one object before changing cell membership. The
// canonical CellId writer can synchronously rebucket loaded to
// pending, delete, or replace this GUID, so it is the transaction's
// final callback boundary before shadow publication.
if (!(acknowledgeProjection?.Invoke(
new RuntimeRemotePhysicsSnapshot(
rm.Body.Position,
rm.Body.Orientation,
committedCellId)) ?? true)
|| !IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
bool cellChanged = committedCellId != 0
&& committedCellId != rm.CellId;
if (cellChanged)
rm.CellId = committedCellId;
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
// #184: shadow follows the resolved body, but only after the
// canonical rebucket proves this exact owner is still spatially
// resident. A pending destination keeps its retained registration
// suspended; a callback-created replacement owns another local ID.
if (ShouldSynchronizeShadow(
cellChanged,
rm.Body.Position,
rm.Body.Orientation,
rm.LastShadowSyncPos,
rm.LastShadowSyncOrientation))
{
SyncRemoteShadowToBody(
localEntityId,
rm,
liveCenterX,
liveCenterY);
}
}
// R5-V3 (#171): retail UpdateObjectInternal tail —
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
// right after CPartArray::HandleMovement, UNCONDITIONAL for
// every entity in both grounded and airborne branches): the
// sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck.
AcDream.Core.Physics.RetailObjectManagerTail.Run(
rm.Host?.TargetManager,
rm.Movement,
sequencer?.Manager,
rm.Host?.PositionManager);
return IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid);
}
/// <summary>
/// Retail hidden-object slice of <c>CPhysicsObj::UpdatePositionInternal</c>
/// (<c>0x00512C30</c>) plus the manager tail of
/// <c>UpdateObjectInternal</c> (<c>0x005156B0</c>). Hidden skips
/// <c>CPartArray::Update</c> and <c>UpdatePhysicsInternal</c>, but the
/// PositionManager offset is still composed and the target, movement, and
/// position managers still consume time. Physics-script and particle owners
/// tick later in the shared frame pipeline.
/// </summary>
internal bool TickHidden(
RuntimeEntityRecord record,
RemoteMotion rm,
float dt,
ulong objectClockEpoch,
float radius,
float height,
AcDream.Core.Physics.Motion.MotionTableManager?
partArrayHandleMovement = null,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rm);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
uint localEntityId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
// The part-array contribution is the identity frame while Hidden.
// Interpolation is the first PositionManager stage in retail; acdream
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
rm.PositionManagerDeltaScratch;
positionDelta.Reset();
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
positionDelta,
rm.Interp,
rm.Motion.GetMaxSpeed(),
positionDelta,
inContact: rm.Body.InContact);
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
ApplyPositionManagerDelta(rm.Body, positionDelta);
// Hidden suppresses CPartArray::Update, but process_hooks remains the
// final UpdatePositionInternal step. Drain any hook already pending on
// the retained sequence before transition/manager time, exactly like
// the visible path above.
if (sequencer is not null)
processAnimationHooks?.Invoke(localEntityId, sequencer);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
System.Numerics.Vector3 composedPosition = rm.Body.Position;
uint committedCellId = rm.CellId;
if (rm.CellId != 0
&& composedPosition != preComposePosition
&& _physics.Engine.LandblockCount > 0)
{
if (radius < 0.05f)
{
radius = 0.48f;
height = 1.835f;
}
bool previousContact = rm.Body.InContact;
bool previousOnWalkable = rm.Body.OnWalkable;
var resolved = _physics.Engine.ResolveWithTransition(
preComposePosition,
composedPosition,
rm.CellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: previousOnWalkable,
body: rm.Body,
moverFlags: IsPlayerGuid(record.ServerGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
movingEntityId: localEntityId);
rm.Body.Position = resolved.Position;
if (resolved.CellId != 0)
committedCellId = resolved.CellId;
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
rm.Movement.HitGround,
rm.Motion.LeaveGround,
() => IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid)))
{
return false;
}
rm.Airborne = !rm.Body.OnWalkable;
}
// Hidden suppresses mesh/part updates, not SetPositionInternal. Commit
// the resolved root before the canonical cell writer enters the same
// re-entrant rebucket boundary as the visible path.
if (!(acknowledgeProjection?.Invoke(
new RuntimeRemotePhysicsSnapshot(
rm.Body.Position,
rm.Body.Orientation,
committedCellId)) ?? true)
|| !IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
if (committedCellId != 0 && committedCellId != rm.CellId)
rm.CellId = committedCellId;
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
AcDream.Core.Physics.RetailObjectManagerTail.Run(
rm.Host?.TargetManager,
rm.Movement,
partArrayHandleMovement,
rm.Host?.PositionManager);
return IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid);
}
private bool IsCurrentOwner(
RuntimeEntityRecord record,
RemoteMotion remote,
ulong objectClockEpoch,
System.Func<bool>? externalOwnerValid) =>
_physics.IsSpatialRemote(record, remote)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, remote.Body)
&& (externalOwnerValid?.Invoke() ?? true);
/// <summary>
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
/// stand-in for retail's <c>Frame::combine</c> in
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
/// out by the body's current orientation and post-multiplies the complete
/// relative orientation. The remote tick is its only caller.
/// </summary>
private static void ApplyPositionManagerDelta(
AcDream.Core.Physics.PhysicsBody body,
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
{
if (delta.Origin != System.Numerics.Vector3.Zero)
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
if (!delta.Orientation.IsIdentity)
body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
body.Position,
body.Orientation,
body.Orientation * delta.Orientation);
}
/// <summary>
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
/// de-overlap / collide against where the monster actually IS (== where it
/// renders), not the raw overlapping server position. Retail re-registers a
/// moved object's shadow every accepted transition step (SetPositionInternal
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
/// rather than snapshotted, since it moves on recentre. Updates
/// <see cref="RemoteMotion.LastShadowSyncPos"/> and
/// <see cref="RemoteMotion.LastShadowSyncOrientation"/> so callers can
/// pose-gate. Rotation matters because Setup collision geometry may be
/// multipart or offset from the root.
/// Called by the remote tick and the authoritative-position tail.
/// </summary>
internal void SyncRemoteShadowToBody(
uint entityId,
AcDream.Runtime.Physics.IRuntimeRemotePlacement rm,
int liveCenterX,
int liveCenterY,
uint? authoritativeCellId = null)
{
SyncRemoteShadowToBody(
entityId,
rm.Body,
liveCenterX,
liveCenterY,
authoritativeCellId ?? rm.CellId);
rm.LastShadowSyncPosition = rm.Body.Position;
rm.LastShadowSyncOrientation = rm.Body.Orientation;
}
internal static bool ShouldSynchronizeShadowPose(
System.Numerics.Vector3 currentPosition,
System.Numerics.Quaternion currentOrientation,
System.Numerics.Vector3 lastPosition,
System.Numerics.Quaternion lastOrientation)
{
if (System.Numerics.Vector3.DistanceSquared(
currentPosition,
lastPosition) > 1e-4f)
{
return true;
}
float currentLengthSquared = currentOrientation.LengthSquared();
float lastLengthSquared = lastOrientation.LengthSquared();
if (!float.IsFinite(currentLengthSquared)
|| !float.IsFinite(lastLengthSquared)
|| currentLengthSquared < 1e-12f
|| lastLengthSquared < 1e-12f)
{
return true;
}
float normalizedDot = MathF.Abs(
System.Numerics.Quaternion.Dot(
currentOrientation,
lastOrientation)
/ MathF.Sqrt(currentLengthSquared * lastLengthSquared));
return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f;
}
internal static bool ShouldSynchronizeShadow(
bool cellChanged,
System.Numerics.Vector3 currentPosition,
System.Numerics.Quaternion currentOrientation,
System.Numerics.Vector3 lastPosition,
System.Numerics.Quaternion lastOrientation) =>
cellChanged
|| ShouldSynchronizeShadowPose(
currentPosition,
currentOrientation,
lastPosition,
lastOrientation);
internal void SyncRemoteShadowToBody(
uint entityId,
AcDream.Core.Physics.PhysicsBody body,
int liveCenterX,
int liveCenterY,
uint authoritativeCellId)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
entityId,
body.Position,
body.Orientation,
authoritativeCellId,
liveCenterX,
liveCenterY);
}
}

View file

@ -1,7 +1,7 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// Shared host adapter for retail's moved-object shadow re-registration