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

@ -0,0 +1,200 @@
# Slice J5.5 — per-session physics and remote-simulation ownership
Date: 2026-07-26
Scope: ownership relocation only; no retail algorithm or gameplay-policy change
## Objective
Make one `AcDream.Runtime` instance own one complete mutable physics world:
- one `PhysicsEngine`, its transition-scratch arena, and its shadow registry;
- one production `PhysicsDataCache`/cell graph;
- canonical physics bodies and physics hosts;
- canonical remote movement/interpolation state;
- loaded/cell-backed ordinary and remote update worksets.
App remains responsible for DAT/package preparation, streaming policy, animation
pose production, render projection, effects, meshes, debug drawing, and UI. It
publishes immutable prepared collision records and animation-root inputs to the
Runtime owner, then applies committed Runtime snapshots to graphical objects.
This is the J5.5 boundary from
`docs/plans/2026-07-26-modern-runtime-slice-j5.md`. Projectile component
ownership and prediction remain explicitly deferred to J5.6.
## Named-retail oracle
Primary source:
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
- `CPhysics::UseTime` `0x00509950`
- `CPhysicsObj::UpdatePositionInternal` `0x00512C30`
- `CPhysicsObj::transition` `0x00512DC0`
- `CPhysicsObj::SetPositionInternal(CTransition const*)` `0x00515330`
- `CPhysicsObj::UpdateObjectInternal` `0x005156B0`
- `CPhysicsObj::update_object` `0x00515D10`
- `CPhysicsObj::MoveOrTeleport` `0x00516330`
- `CPhysicsObj::add_shadows_to_cells` `0x00514AE0`
The complete translated control flow is already pinned in
`docs/research/2026-07-19-r6-update-object-order-pseudocode.md`. The current
Core/App implementation was cross-checked there and in the R6/physics digest
against ACE's `PhysicsObj` interpretation and the extracted WorldBuilder
collision/content pipeline. J5.5 reuses that port unchanged.
## Preserved pseudocode
```text
for each ordinary object in the active object table:
elapsed = current physics time - object update time
apply retail epsilon / stale-quantum / subdivision gates
if object is Active:
if not Hidden:
advance PartArray and obtain the complete root-motion Frame
apply PositionManager correction to that Frame
compose the candidate position and orientation
if not Hidden:
integrate acceleration, velocity, and omega
drain object animation hooks
if the candidate frame moved:
run Transition against the object's current full cell
commit accepted frame, contact state, and full cell
update the object's collision shadows
DetectionManager.CheckDetection
TargetManager.HandleTargetting
MovementManager.UseTime
PartArray.HandleMovement
PositionManager.UseTime
ParticleManager.UpdateParticles
ScriptManager.UpdateScripts
```
The App/Runtime seam may split pose production from simulation, but it must not
change this observable ordering. In particular:
- root motion and the position-manager delta are complete before collision;
- the transition's swept full cell is canonical;
- a Hidden object is not the same as an inactive or deleted object;
- exact record/incarnation, authority version, and object-clock epoch are
revalidated after every callback;
- remote shadows follow the committed body frame, never a raw server sample;
- `CellGraph.CurrCell` remains local-player-only state.
## Baseline ownership gap
The canonical `RuntimeEntityRecord` already owns `PhysicsBody`,
`IPhysicsObjHost`, full cell, final physics state, accepted authority versions,
and `RetailObjectQuantumClock`. The remaining duplicate boundary is:
- `GameWindow` constructs the session `PhysicsEngine` and production
`PhysicsDataCache`;
- App `LiveEntityRecord` stores the remote-motion component;
- App `LiveEntityRuntime` stores remote and ordinary spatial workset indexes;
- App updaters both mutate canonical simulation and project `WorldEntity`;
- App streaming mutates the engine directly.
That baseline shape prevented a no-window Runtime from owning the same
simulation graph and made two simultaneous sessions harder to prove isolated.
J5.5 retires this gap.
## Target ownership
`RuntimeEntityObjectLifetime` constructs one `RuntimePhysicsState` beside its
entity directory. `RuntimePhysicsState` owns:
- the exact `PhysicsEngine` and production cache;
- remote components and physics hosts stored on `RuntimeEntityRecord`;
- keyed ordinary/remote worksets containing exact `RuntimeEntityKey` values;
- typed collision admission/withdrawal receipts;
- simulation commits and immutable presentation snapshots;
- reset/disposal convergence and an ownership ledger.
App acknowledgements contain no gameplay policy:
```text
prepared collision publication -> Runtime typed admission -> receipt
render spatial visibility edge -> Runtime workset acknowledgement
animated root Frame -> Runtime remote/ordinary tick
Runtime committed snapshot -> App WorldEntity/rebucket/pose projection
```
The immutable prepared collision payload may originate in App streaming. The
engine, transition scratch, cell graph, body, shadow registry, interpolation,
manager state, and workset are never shared across Runtime instances.
## Implemented boundary
- `RuntimeEntityObjectLifetime` constructs and disposes exactly one
`RuntimePhysicsState`.
- `RuntimePhysicsState` constructs the production `PhysicsDataCache` and the
sole `PhysicsEngine`, including its ten-deep reusable transition scratch and
shadow registry. App borrows both objects and cannot construct alternatives.
- `RuntimeEntityRecord` owns the canonical `PhysicsBody`, `RemoteMotion`,
`EntityPhysicsHost`, full cell, object clock, and exact-incarnation binding
guards.
- `RuntimePhysicsState` owns keyed ordinary/remote worksets, body acquisition,
host installation/rebinding/lookup, remote component binding, and typed
canonical cell commits.
- `RuntimeRemotePhysicsUpdater` and `RuntimeOrdinaryPhysicsUpdater` contain the
unchanged presentation-free retail simulation. Their App adapters supply
DAT-derived shape/animation inputs and project immutable pose snapshots only.
- `LandblockPhysicsPublisher` publishes the prepared collision root through a
generation-scoped `RuntimeCollisionAdmission`; demotion and withdrawal cross
the same typed Runtime boundary.
- Runtime cell commits synchronously publish
`RuntimePhysicsCellCommit`. The graphical subscriber revalidates the exact
record and spatial-authority version before rebucketing the render
projection.
- Two Runtime instances own distinct engines, caches, cell graphs, scratch
arenas, shadow registries, components, hosts, bodies, and worksets.
No retail algorithm, constant, order, packet, or gameplay policy changed, and
this ownership relocation introduces no retail-divergence row.
## Staged implementation boundary
1. Construct engine/cache under `RuntimePhysicsState`; make App borrow them.
2. Move presentation-free `RemoteMotion` and `EntityPhysicsHost` types into
Runtime and store the remote component on `RuntimeEntityRecord`.
3. Move remote/ordinary keyed worksets and their visibility acknowledgements
into `RuntimePhysicsState`.
4. Split remote/ordinary update into Runtime simulation commits and App
presentation acknowledgements without changing update order.
5. Replace direct streaming mutation with typed Runtime
admission/withdrawal receipts.
6. Remove App simulation mirrors and prove reset/two-instance isolation.
Each intermediate state must build and retain exact behavior. The production
commit remains independently revertible as the single J5.5 ownership unit.
## Acceptance evidence
- existing flat-collision conformance corpus remains bit-identical;
- reused versus fresh transition scratch stays equivalent and allocation-free;
- local and remote position/cell/orientation fixtures remain exact;
- crowd, doors, stairs, cell seams, Hidden, teleport, delete, and GUID-reuse
fixtures pass;
- two Runtime instances have distinct engine, cache, scratch, shadows, bodies,
hosts, remote components, and worksets;
- App production source cannot construct a `PhysicsEngine`,
`PhysicsDataCache`, canonical remote component, or simulation workset;
- Release build, focused Runtime/App suites, complete Release suite, connected
lifecycle/reconnect, and the canonical movement route pass.
Automated gate on the J5.5 production tree:
- Release solution build: zero warnings and zero errors.
- Core collision/physics suite: 3,273 passed / 2 skipped.
- Runtime suite: 314 passed.
- App suite: 3,718 passed / 3 skipped.
- Complete solution: 8,588 passed / 5 skipped.
- Source ownership guards prove App does not construct a `PhysicsEngine` or
production cache, does not contain the remote/ordinary transition solver,
and cannot directly admit/remove streamed landblock collision.
Connected lifecycle/reconnect and the canonical movement/collision route are
the exact-binary post-commit gate.

View file

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

View file

@ -1,4 +1,15 @@
global using AcDream.Runtime.Gameplay; 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 = global using LocalPlayerControllerSlot =
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState; AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
global using ILocalPlayerControllerSource = global using ILocalPlayerControllerSource =

View file

@ -342,7 +342,7 @@ internal sealed class PlayerModeController :
}, },
interruptCurrentMovement: () => exactMovement.CancelMoveTo( interruptCurrentMovement: () => exactMovement.CancelMoveTo(
WeenieError.ActionCancelled)); WeenieError.ActionCancelled));
playerHost = EntityPhysicsHost.SelectStableHostWithoutRebind( playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities, _liveEntities,
playerRecord, playerRecord,
configuredHost); configuredHost);
@ -443,7 +443,7 @@ internal sealed class PlayerModeController :
_camera.EnterChaseMode(legacyCamera, retailCamera); _camera.EnterChaseMode(legacyCamera, retailCamera);
EntityPhysicsHost stableAfterCamera = EntityPhysicsHost stableAfterCamera =
EntityPhysicsHost.SelectStableHostWithoutRebind( EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
_liveEntities, _liveEntities,
playerRecord, playerRecord,
configuredHost); configuredHost);
@ -465,7 +465,7 @@ internal sealed class PlayerModeController :
// DAT, placement, shadow, and camera preparation has succeeded. A // DAT, placement, shadow, and camera preparation has succeeded. A
// late preparation failure therefore cannot expose an abandoned // late preparation failure therefore cannot expose an abandoned
// controller through LiveEntityRecord.PhysicsHost. // controller through LiveEntityRecord.PhysicsHost.
EntityPhysicsHost publishedHost = EntityPhysicsHost.InstallOrRebind( EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind(
_liveEntities, _liveEntities,
playerRecord, playerRecord,
configuredHost); 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), handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled)); AcDream.Core.Physics.WeenieError.ActionCancelled));
host = EntityPhysicsHost.InstallOrRebind( host = EntityPhysicsHostComposition.InstallOrRebind(
liveEntities, liveEntities,
hostRecord, hostRecord,
configuredHost); configuredHost);
@ -242,7 +242,7 @@ internal sealed class LiveEntityMotionRuntimeController
return existing; return existing;
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
var minimal = EntityPhysicsHost.CreateMinimal( var minimal = EntityPhysicsHostComposition.CreateMinimal(
activeRecord, activeRecord,
ResolvePhysicsHost, ResolvePhysicsHost,
NowSeconds); NowSeconds);

View file

@ -1,30 +1,29 @@
using System.Numerics;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Physics;
using DatReaderWriter.Types; using DatReaderWriter.Types;
namespace AcDream.App.Physics; namespace AcDream.App.Physics;
/// <summary> /// <summary>
/// Owns the body-backed, manager-less branch of retail /// Projects the presentation-free Runtime result of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0). A retained canonical /// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) into App's
/// body can temporarily outlive its RemoteMotion or projectile component; it /// <see cref="WorldEntity"/> and spatial buckets.
/// must still compose the complete PartArray Frame, integrate physics, sweep
/// through Transition, commit its cell, and move its collision shadow.
/// </summary> /// </summary>
internal sealed class LiveEntityOrdinaryPhysicsUpdater internal sealed class LiveEntityOrdinaryPhysicsUpdater
{ {
private readonly PhysicsEngine _physics; private readonly RuntimeOrdinaryPhysicsUpdater _runtime;
private readonly Func<uint, WorldEntity, (float Radius, float Height)> private readonly Func<uint, WorldEntity, (float Radius, float Height)>
_getSetupCylinder; _getSetupCylinder;
public LiveEntityOrdinaryPhysicsUpdater( public LiveEntityOrdinaryPhysicsUpdater(
PhysicsEngine physics, RuntimePhysicsState physics,
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder) 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 _getSetupCylinder = getSetupCylinder
?? throw new ArgumentNullException(nameof(getSetupCylinder)); ?? throw new ArgumentNullException(nameof(getSetupCylinder));
} }
@ -47,165 +46,47 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(rootFrame); ArgumentNullException.ThrowIfNull(rootFrame);
ArgumentNullException.ThrowIfNull(captureAnimationHooks); ArgumentNullException.ThrowIfNull(captureAnimationHooks);
if (record.PhysicsBody is not { } body if (record.PhysicsBody is not { } body)
|| !IsCurrent(
runtime,
record,
entity,
body,
objectClockEpoch))
{
return false; 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); var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
bool ExternalOwnerValid() =>
IsCurrent(
runtime,
record,
entity,
body,
objectClockEpoch);
if (integratedPosition != priorPosition if (!_runtime.TryBegin(
&& sourceCellId != 0 record.Canonical,
&& radius >= 0.05f rootFrame,
&& _physics.LandblockCount > 0) objectScale,
{ quantum,
ResolveResult resolved = _physics.ResolveWithTransition(
priorPosition,
integratedPosition,
sourceCellId,
radius, radius,
height, height,
stepUpHeight: 0.4f, objectClockEpoch,
stepDownHeight: 0.4f, sequencer,
isOnGround: previousOnWalkable, captureAnimationHooks,
body: body, ExternalOwnerValid,
moverFlags: IsPlayerGuid(record.ServerGuid) out RuntimeOrdinaryPhysicsCommit commit))
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide {
: ObjectInfoState.EdgeSlide, return false;
movingEntityId: entity.Id); }
if (resolved.Ok) return _runtime.Complete(
commit,
liveCenterX,
liveCenterY,
snapshot =>
{ {
resolvedCellId = resolved.CellId != 0 if (!ExternalOwnerValid())
? resolved.CellId return false;
: 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 (!IsCurrent( entity.SetPosition(snapshot.Position);
runtime, entity.Rotation = snapshot.Orientation;
record, entity.ParentCellId = snapshot.FullCellId;
entity, return ExternalOwnerValid();
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);
} }
private static bool IsCurrent( private static bool IsCurrent(
@ -220,7 +101,4 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
&& ReferenceEquals(record.PhysicsBody, body) && ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotionRuntime is null && record.RemoteMotionRuntime is null
&& record.ProjectileRuntime 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.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using RemoteMotion = AcDream.App.Physics.RemoteMotion; using RemoteMotion = AcDream.Runtime.Physics.RemoteMotion;
namespace AcDream.App.Physics; 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 = private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12); new(nearRadius: 4, farRadius: 12);
// Phase B.3: physics engine — populated from the streaming pipeline. // 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 // Task 4: physics data cache — BSP trees + collision shapes extracted from
// GfxObj/Setup dats during streaming. Populated on the worker thread; // GfxObj/Setup dats during streaming. Populated on the worker thread;
// ConcurrentDictionary inside makes cross-thread access safe. // ConcurrentDictionary inside makes cross-thread access safe.
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = private AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =>
AcDream.Core.Physics.PhysicsDataCache.CreateProduction(); _runtimeEntityObjects.Physics.DataCache;
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the // #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
// >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion // >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; // setup/motion dependency crosses the fail-fast construction bridge;
// the server-velocity animation cycle is stateless shared policy. // the server-velocity animation cycle is stateless shared policy.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine, _runtimeEntityObjects.Physics,
_liveEntityMotionBindings.GetSetupCylinder, _liveEntityMotionBindings.GetSetupCylinder,
AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply); AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher( _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 // Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions. // run narrow-phase BSP tests during FindObjCollisions.
_physicsEngine.DataCache = _physicsDataCache;
GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform(); GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();

View file

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

View file

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

View file

@ -20,65 +20,6 @@ public interface ILiveEntityAnimationRuntime
uint CurrentMotion { get; } 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> /// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime public interface ILiveEntityProjectileRuntime
{ {
@ -296,14 +237,26 @@ public sealed class LiveEntityRecord
set => _directory.SetPhysicsBodyAcquisitionInProgress(Canonical, value); set => _directory.SetPhysicsBodyAcquisitionInProgress(Canonical, value);
} }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime
internal bool RemoteMotionBindingInProgress { get; set; } {
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 public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost
{ {
get => Canonical.PhysicsHost; get => Canonical.PhysicsHost;
internal set => _directory.SetPhysicsHost(Canonical, value); 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 ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; } public bool ResourcesRegistered { get; internal set; }
@ -435,6 +388,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle; private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
private readonly RuntimeEntityObjectLifetime _entityObjects; private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeEntityDirectory _directory; private readonly RuntimeEntityDirectory _directory;
private readonly RuntimePhysicsState _physics;
private readonly LiveEntityProjectionStore _projections; private readonly LiveEntityProjectionStore _projections;
// Logical components survive retail's 25-second leave-visibility lifetime. // Logical components survive retail's 25-second leave-visibility lifetime.
// Frame loops must not scan those retained owners. These component-specific // 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 // projection. Hidden and Frozen remain members: their state controls what
// each retail update path advances after the O(active) lookup. // each retail update path advances after the O(active) lookup.
private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new(); private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new();
private readonly Dictionary<RuntimeEntityKey, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotion = new();
private readonly Dictionary<RuntimeEntityKey, ILiveEntityProjectileRuntime> _spatialProjectiles = new(); private readonly Dictionary<RuntimeEntityKey, ILiveEntityProjectileRuntime> _spatialProjectiles = new();
// Retail CPhysics::UseTime walks the ordinary object table, not a render- private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new();
// animation component table. This index is the loaded/cell-backed root private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new();
// workset; component-specific indexes remain lookup accelerators only.
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _spatialRootObjects = new();
private bool _isClearing; private bool _isClearing;
private bool _sessionClearPendingFinalization; private bool _sessionClearPendingFinalization;
private bool _isRegisteringResources; private bool _isRegisteringResources;
@ -492,8 +443,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_entityObjects = entityObjects _entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects)); ?? throw new ArgumentNullException(nameof(entityObjects));
_directory = _entityObjects.Entities; _directory = _entityObjects.Entities;
_physics = _entityObjects.Physics;
_projections = new LiveEntityProjectionStore(_directory); _projections = new LiveEntityProjectionStore(_directory);
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged; _spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
_physics.CellCommitted += OnRuntimePhysicsCellCommitted;
} }
public int Count => _directory.Count; public int Count => _directory.Count;
@ -506,6 +459,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_projections.VisibleRecords; _projections.VisibleRecords;
internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords => internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords =>
_directory.ActiveRecords; _directory.ActiveRecords;
internal RuntimePhysicsState Physics => _physics;
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots; public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots;
internal int AnimationRuntimeCount internal int AnimationRuntimeCount
{ {
@ -522,9 +476,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
} }
internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count; internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count;
internal int SpatialRemoteMotionRuntimeCount => _spatialRemoteMotion.Count; internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount;
internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count; internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count;
internal int SpatialRootObjectCount => _spatialRootObjects.Count; internal int SpatialRootObjectCount => _physics.SpatialRootCount;
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments; public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
internal bool TryGetCanonical( internal bool TryGetCanonical(
@ -798,10 +752,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
RuntimeEntityKey key = RequireProjectionKey(record); RuntimeEntityKey key = RequireProjectionKey(record);
bool wasProjected = record.IsSpatiallyProjected; bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible; bool wasVisible = record.IsSpatiallyVisible;
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
key,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
ulong projectionOperation = ++record.ProjectionMutationVersion; ulong projectionOperation = ++record.ProjectionMutationVersion;
// GpuWorldState reports an intermediate false/true pair while moving // GpuWorldState reports an intermediate false/true pair while moving
// between two loaded buckets. Suppress those implementation details // between two loaded buckets. Suppress those implementation details
@ -936,10 +887,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false; return false;
RuntimeEntityKey key = RequireProjectionKey(record); RuntimeEntityKey key = RequireProjectionKey(record);
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
key,
out LiveEntityRecord? rootRecord)
&& ReferenceEquals(rootRecord, record);
ulong objectClockEpoch = record.ObjectClockEpoch; ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionOperation = ++record.ProjectionMutationVersion; ulong projectionOperation = ++record.ProjectionMutationVersion;
Exception? spatialNotificationFailure = null; Exception? spatialNotificationFailure = null;
@ -1436,43 +1384,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
throw new InvalidOperationException( throw new InvalidOperationException(
$"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized."); $"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized.");
} }
if (record.PhysicsBody is { } retained) bool ProjectionIsCurrent() =>
return retained; _projections.TryGetCurrent(
if (record.PhysicsBodyAcquisitionInProgress) serverGuid,
{ out LiveEntityRecord? current)
throw new InvalidOperationException( && ReferenceEquals(current, record)
$"Live entity 0x{serverGuid:X8} physics-body acquisition is already in progress."); && current.WorldEntity is not null;
} return _physics.GetOrCreatePhysicsBody(
record.Canonical,
record.PhysicsBodyAcquisitionInProgress = true; _ => factory(record),
try ProjectionIsCurrent);
{
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;
}
} }
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
@ -1480,122 +1401,17 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)) if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); 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); ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
PhysicsBody? expectedBody = record.PhysicsBody; _physics.SetRemoteMotion(
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime; record.Canonical,
bool expectedPlacementContract = record.RequiresRemotePlacementRuntime; runtime,
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost = () => () => CurrentLifetimeMutation(serverGuid) == lifetimeVersion
_projections.TryGetCurrent(serverGuid, out var current) && _projections.TryGetCurrent(
&& ReferenceEquals(current, incarnation) serverGuid,
&& ReferenceEquals(current.RemoteMotionRuntime, runtime) out LiveEntityRecord? current)
? current.PhysicsHost && ReferenceEquals(current, record));
: null; SynchronizePhysicsBodyActiveState(record);
// Reads remain bound to the exact CPhysicsObj incarnation through its RefreshSpatialRuntimeIndexes(record);
// 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;
}
} }
public void InstallPhysicsHost( public void InstallPhysicsHost(
@ -1605,26 +1421,23 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ArgumentNullException.ThrowIfNull(expectedRecord); ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(host); ArgumentNullException.ThrowIfNull(host);
uint serverGuid = expectedRecord.ServerGuid; uint serverGuid = expectedRecord.ServerGuid;
if (host.Id != serverGuid) bool ProjectionIsCurrent() =>
throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host)); _projections.TryGetCurrent(
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) serverGuid,
|| !ReferenceEquals(record, expectedRecord)) out LiveEntityRecord? current)
{ && ReferenceEquals(current, expectedRecord);
throw new InvalidOperationException( _physics.InstallPhysicsHost(
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation."); expectedRecord.Canonical,
} host,
if (record.PhysicsHost is not null) ProjectionIsCurrent);
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host.");
record.PhysicsHost = host;
} }
public bool TryGetPhysicsHost( public bool TryGetPhysicsHost(
uint serverGuid, uint serverGuid,
out AcDream.Core.Physics.Motion.IPhysicsObjHost host) out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{ {
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) if (_projections.TryGetCurrent(serverGuid, out _)
&& record.PhysicsHost is { } existing) && _physics.TryGetPhysicsHost(serverGuid, out var existing))
{ {
host = existing; host = existing;
return true; return true;
@ -1656,7 +1469,8 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.RemoteMotionRuntime is null) || record.RemoteMotionRuntime is null)
return false; return false;
record.RemoteMotionRuntime = null; if (!_physics.ClearRemoteMotion(record.Canonical))
return false;
RefreshSpatialRuntimeIndexes(record); RefreshSpatialRuntimeIndexes(record);
return true; return true;
} }
@ -1977,10 +1791,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
destination.Clear(); 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) RuntimeEntityRecord canonical =
&& ReferenceEquals(current, indexed) _spatialRootCanonicalScratch[index];
if (_projections.TryGet(
canonical,
out LiveEntityRecord? current)
&& HasSpatialRuntimeProjection(current)) && HasSpatialRuntimeProjection(current))
{ {
destination.Add(current); destination.Add(current);
@ -1990,9 +1810,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) => internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) =>
IsCurrentRecord(record) IsCurrentRecord(record)
&& record.ProjectionKey is { } key && _physics.IsSpatialRoot(record.Canonical)
&& _spatialRootObjects.TryGetValue(key, out var indexed)
&& ReferenceEquals(indexed, record)
&& HasSpatialRuntimeProjection(record); && HasSpatialRuntimeProjection(record);
internal bool IsCurrentSpatialAnimation( internal bool IsCurrentSpatialAnimation(
@ -2169,12 +1987,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
destination.Clear(); destination.Clear();
_physics.CopySpatialRemotesTo(_spatialRemoteCanonicalScratch);
foreach ((RuntimeEntityKey key, ILiveEntityRemoteMotionRuntime runtime) for (int index = 0;
in _spatialRemoteMotion) index < _spatialRemoteCanonicalScratch.Count;
index++)
{ {
if (_projections.TryGet(key, out LiveEntityRecord? record) RuntimeEntityRecord canonical =
&& ReferenceEquals(record.RemoteMotionRuntime, runtime) _spatialRemoteCanonicalScratch[index];
if (_projections.TryGet(
canonical,
out LiveEntityRecord? record)
&& HasSpatialRuntimeProjection(record)) && HasSpatialRuntimeProjection(record))
{ {
destination.Add(record); destination.Add(record);
@ -2186,10 +2008,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
LiveEntityRecord record, LiveEntityRecord record,
ILiveEntityRemoteMotionRuntime runtime) => ILiveEntityRemoteMotionRuntime runtime) =>
IsCurrentRecord(record) IsCurrentRecord(record)
&& ReferenceEquals(record.RemoteMotionRuntime, runtime) && _physics.IsSpatialRemote(record.Canonical, runtime)
&& record.ProjectionKey is { } key
&& _spatialRemoteMotion.TryGetValue(key, out var indexed)
&& ReferenceEquals(indexed, runtime)
&& HasSpatialRuntimeProjection(record); && HasSpatialRuntimeProjection(record);
/// <summary> /// <summary>
@ -2623,16 +2442,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return; return;
} }
if (spatial) _physics.AcknowledgeSpatialProjection(record.Canonical, spatial);
{
_spatialRootObjects[key] = record;
}
else if (current
|| (_spatialRootObjects.TryGetValue(key, out var indexedRoot)
&& ReferenceEquals(indexedRoot, record)))
{
_spatialRootObjects.Remove(key);
}
if (record.WorldEntity is not null) 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) if (spatial && record.ProjectileRuntime is { } projectile)
{ {
_spatialProjectiles[key] = projectile; _spatialProjectiles[key] = projectile;
@ -2679,11 +2477,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (record.ProjectionKey is not { } key) if (record.ProjectionKey is not { } key)
return; return;
if (_spatialRootObjects.TryGetValue(key, out var indexedRoot) _physics.RemoveSpatialProjection(record.Canonical);
&& ReferenceEquals(indexedRoot, record))
{
_spatialRootObjects.Remove(key);
}
if (record.WorldEntity is not null if (record.WorldEntity is not null
&& _spatialAnimations.TryGetValue(key, out var indexedAnimation) && _spatialAnimations.TryGetValue(key, out var indexedAnimation)
@ -2692,12 +2486,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_spatialAnimations.Remove(key); _spatialAnimations.Remove(key);
} }
if (_spatialRemoteMotion.TryGetValue(key, out var indexedRemote)
&& ReferenceEquals(indexedRemote, record.RemoteMotionRuntime))
{
_spatialRemoteMotion.Remove(key);
}
if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile) if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile)
&& ReferenceEquals(indexedProjectile, record.ProjectileRuntime)) && ReferenceEquals(indexedProjectile, record.ProjectileRuntime))
{ {
@ -2725,10 +2513,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool wasVisible = record.IsSpatiallyVisible; bool wasVisible = record.IsSpatiallyVisible;
if (RequireProjectionKey(record) != key) if (RequireProjectionKey(record) != key)
return; return;
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
key,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
record.IsSpatiallyVisible = visible; record.IsSpatiallyVisible = visible;
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected && record.IsSpatiallyProjected
@ -2749,6 +2534,25 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
PublishProjectionVisibilityChanged(record, visible); 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( private static void SynchronizePhysicsBodyActiveState(
LiveEntityRecord record) LiveEntityRecord record)
{ {
@ -2877,9 +2681,8 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
} }
_spatialAnimations.Clear(); _spatialAnimations.Clear();
_spatialRemoteMotion.Clear();
_spatialProjectiles.Clear(); _spatialProjectiles.Clear();
_spatialRootObjects.Clear(); _physics.ClearSpatialWorksets();
_projections.ClearConverged(); _projections.ClearConverged();
_spatial.ClearLiveEntityLifetimeState(); _spatial.ClearLiveEntityLifetimeState();
_sessionClearPendingFinalization = false; _sessionClearPendingFinalization = false;

View file

@ -1,5 +1,6 @@
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities; namespace AcDream.Runtime.Entities;
@ -338,6 +339,30 @@ public sealed class RuntimeEntityDirectory
record.PhysicsBodyAcquisitionInProgress = value; 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( public void SetPhysicsHost(
RuntimeEntityRecord record, RuntimeEntityRecord record,
AcDream.Core.Physics.Motion.IPhysicsObjHost? host) AcDream.Core.Physics.Motion.IPhysicsObjHost? host)

View file

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

View file

@ -1,5 +1,6 @@
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities; namespace AcDream.Runtime.Entities;
@ -68,6 +69,9 @@ public sealed class RuntimeEntityRecord
public bool HasPartArray { get; internal set; } public bool HasPartArray { get; internal set; }
public PhysicsBody? PhysicsBody { get; internal set; } public PhysicsBody? PhysicsBody { get; internal set; }
public bool PhysicsBodyAcquisitionInProgress { 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 AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
public ulong PositionAuthorityVersion { get; private set; } public ulong PositionAuthorityVersion { get; private set; }
public ulong StateAuthorityVersion { get; private set; } public ulong StateAuthorityVersion { get; private set; }

View file

@ -2,16 +2,15 @@ using System;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using AcDream.App.World;
namespace AcDream.App.Physics; namespace AcDream.Runtime.Physics;
/// <summary> /// <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. /// 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) /// One is retained per accepted object incarnation on
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c> /// <c>RuntimeEntityRecord</c>, so
/// and stored on the exact <c>LiveEntityRecord</c>, so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the /// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system /// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs. /// needs.
@ -22,8 +21,8 @@ namespace AcDream.App.Physics;
/// forward to it exactly as retail's CPhysicsObj does; the movement managers' /// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The /// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the /// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this /// <see cref="HandleUpdateTarget"/> fan-out are injected by the graphical or
/// class stays free of GameWindow's internals (code-structure rule #1).</para> /// no-window host while Runtime retains the host and manager identities.</para>
/// ///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail /// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via /// <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 /// Applies a freshly composed delegate configuration while retaining this
/// host's exact identity and manager instances. /// host's exact identity and manager instances.
/// </summary> /// </summary>
private void RebindFrom(EntityPhysicsHost configuration) internal void RebindFrom(EntityPhysicsHost configuration)
{ {
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
if (configuration.Id != Id) if (configuration.Id != Id)
@ -156,105 +155,6 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
configuration._interruptCurrentMovement); 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 ────────────────────────────────────────── // ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; } public uint Id { get; }
public Position Position => _getPosition(); public Position Position => _getPosition();
@ -302,7 +202,7 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) => public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager.RemoveVoyeur(watcherId, 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 /// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally /// 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 /// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends /// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is /// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the App bridges the same unavailability edge through /// not ported yet, so the current host bridges the same unavailability edge
/// the existing target fan-out. The non-Ok update tears down watcher /// 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 /// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's /// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved. /// own watcher role is deliberately preserved.

View file

@ -1,6 +1,4 @@
using AcDream.App.World; namespace AcDream.Runtime.Physics;
namespace AcDream.App.Physics;
/// <summary> /// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of /// Per-remote-entity physics + motion stack — verbatim application of
@ -21,11 +19,11 @@ namespace AcDream.App.Physics;
/// </para> /// </para>
/// </summary> /// </summary>
internal sealed class RemoteMotion : internal sealed class RemoteMotion :
AcDream.App.World.ILiveEntityRemotePlacementRuntime, IRuntimeRemotePlacement,
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer IRuntimeCanonicalPhysicsConsumer
{ {
public AcDream.Core.Physics.PhysicsBody Body { get; } 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 /// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
/// ONE per-entity owner of the interp + moveto pair (acclient.h /// 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 // voyeur system (retail CPhysicsObj::target_manager). Replaces the
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
// clear_target/quantum seams route here, HandleTargetting ticks per // 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. // exact-incarnation PhysicsHost slot.
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader; private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
private bool _fullPhysicsHostBound; private bool _fullPhysicsHostBound;
@ -164,7 +162,7 @@ internal sealed class RemoteMotion :
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes /// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
/// retain the local backing value. A MovementManager attached to an /// retain the local backing value. A MovementManager attached to an
/// existing projectile delegates reads and writes to /// 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 /// Movement packets, and spatial rebucketing cannot develop competing
/// cell identities. /// cell identities.
/// </summary> /// </summary>
@ -178,40 +176,40 @@ internal sealed class RemoteMotion :
} }
} }
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition System.Numerics.Vector3 IRuntimeRemotePlacement.LastServerPosition
{ {
get => LastServerPos; get => LastServerPos;
set => LastServerPos = value; set => LastServerPos = value;
} }
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime double IRuntimeRemotePlacement.LastServerPositionTime
{ {
get => LastServerPosTime; get => LastServerPosTime;
set => LastServerPosTime = value; set => LastServerPosTime = value;
} }
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition System.Numerics.Vector3 IRuntimeRemotePlacement.LastShadowSyncPosition
{ {
get => LastShadowSyncPos; get => LastShadowSyncPos;
set => LastShadowSyncPos = value; set => LastShadowSyncPos = value;
} }
System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation System.Numerics.Quaternion IRuntimeRemotePlacement.LastShadowSyncOrientation
{ {
get => LastShadowSyncOrientation; get => LastShadowSyncOrientation;
set => LastShadowSyncOrientation = value; set => LastShadowSyncOrientation = value;
} }
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne bool IRuntimeRemotePlacement.Airborne
{ {
get => Airborne; get => Airborne;
set => Airborne = value; set => Airborne = value;
} }
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() => void IRuntimeRemotePlacement.HitGround() =>
Movement.HitGround(); Movement.HitGround();
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() => void IRuntimeRemotePlacement.LeaveGround() =>
Motion.LeaveGround(); Motion.LeaveGround();
/// <summary> /// <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 System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
namespace AcDream.App.Physics; namespace AcDream.Runtime.Physics;
/// <summary> /// <summary>
/// Shared host adapter for retail's moved-object shadow re-registration /// Shared host adapter for retail's moved-object shadow re-registration

View file

@ -1,4 +1,15 @@
global using AcDream.Runtime.Gameplay; 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 = global using LocalPlayerControllerSlot =
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState; AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
global using ILocalPlayerControllerSource = global using ILocalPlayerControllerSource =

View file

@ -19,13 +19,13 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
[Fact] [Fact]
public void CompleteRootFrame_CrossesTransitionAndCommitsCanonicalCell() public void CompleteRootFrame_CrossesTransitionAndCommitsCanonicalCell()
{ {
PhysicsEngine physics = BuildBoundaryEngine();
var spatial = new GpuWorldState(); var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu));
spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
var live = LiveEntityRuntimeFixture.Create( var live = LiveEntityRuntimeFixture.Create(
spatial, spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
PopulateBoundaryEngine(live.Physics.Engine);
LiveEntityRecord record = live.RegisterAndMaterializeProjection( LiveEntityRecord record = live.RegisterAndMaterializeProjection(
Spawn(), Spawn(),
id => new WorldEntity id => new WorldEntity
@ -40,7 +40,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
}); });
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity); WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
var remote = new AcDream.App.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
CellId = SourceCell, CellId = SourceCell,
}; };
@ -56,7 +56,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
ulong epoch = record.ObjectClockEpoch; ulong epoch = record.ObjectClockEpoch;
var updater = new LiveEntityOrdinaryPhysicsUpdater( var updater = new LiveEntityOrdinaryPhysicsUpdater(
physics, live.Physics,
(_, _) => (0.48f, 1.835f)); (_, _) => (0.48f, 1.835f));
var rootFrame = new Frame var rootFrame = new Frame
{ {
@ -106,7 +106,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
ParentCellId = SourceCell, ParentCellId = SourceCell,
}); });
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity); WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
var remote = new AcDream.App.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
CellId = SourceCell, CellId = SourceCell,
}; };
@ -123,7 +123,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
new NullAnimationLoader()); new NullAnimationLoader());
Vector3 retainedEntityPosition = entity.Position; Vector3 retainedEntityPosition = entity.Position;
var updater = new LiveEntityOrdinaryPhysicsUpdater( var updater = new LiveEntityOrdinaryPhysicsUpdater(
new PhysicsEngine(), live.Physics,
(_, _) => (0.48f, 1.835f)); (_, _) => (0.48f, 1.835f));
Assert.False(updater.Tick( Assert.False(updater.Tick(
@ -149,7 +149,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Assert.Equal(retainedEntityPosition, entity.Position); Assert.Equal(retainedEntityPosition, entity.Position);
} }
private static PhysicsEngine BuildBoundaryEngine() private static void PopulateBoundaryEngine(PhysicsEngine engine)
{ {
static TerrainSurface FlatTerrain() static TerrainSurface FlatTerrain()
{ {
@ -159,7 +159,6 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
return new TerrainSurface(heights, table); return new TerrainSurface(heights, table);
} }
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock( engine.AddLandblock(
0xA9B4FFFFu, 0xA9B4FFFFu,
FlatTerrain(), FlatTerrain(),
@ -174,7 +173,6 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Array.Empty<PortalPlane>(), Array.Empty<PortalPlane>(),
192f, 192f,
0f); 0f);
return engine;
} }
private static LoadedLandblock EmptyLandblock(uint landblockId) => private static LoadedLandblock EmptyLandblock(uint landblockId) =>

View file

@ -105,7 +105,7 @@ public sealed class ProjectileControllerTests
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!; WorldEntity entity = record.WorldEntity!;
Assert.Null(record.AnimationRuntime); Assert.Null(record.AnimationRuntime);
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record); RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
remote.Body.Position = entity.Position; remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation; remote.Body.Orientation = entity.Rotation;
@ -124,7 +124,7 @@ public sealed class ProjectileControllerTests
isMovingTo: false, isMovingTo: false,
currentBodyPosition: remote.Body.Position); currentBodyPosition: remote.Body.Position);
var updater = new RemotePhysicsUpdater( var updater = new RemotePhysicsUpdater(
fixture.Engine, fixture.Live.Physics,
(_, _) => (0.48f, 1.835f), (_, _) => (0.48f, 1.835f),
(_, _, _, _) => { }); (_, _, _, _) => { });
@ -685,7 +685,7 @@ public sealed class ProjectileControllerTests
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
PhysicsBody body = record.PhysicsBody!; PhysicsBody body = record.PhysicsBody!;
fixture.Live.SetRemoteMotionRuntime(Guid, new AcDream.App.Physics.RemoteMotion(body)); fixture.Live.SetRemoteMotionRuntime(Guid, new AcDream.Runtime.Physics.RemoteMotion(body));
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
record, record.FinalPhysicsState, 2.0, 1, 1)); record, record.FinalPhysicsState, 2.0, 1, 1));
@ -709,7 +709,7 @@ public sealed class ProjectileControllerTests
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
PhysicsBody body = record.PhysicsBody!; PhysicsBody body = record.PhysicsBody!;
Vector3 position = body.Position; Vector3 position = body.Position;
fixture.Live.SetRemoteMotionRuntime(Guid, new AcDream.App.Physics.RemoteMotion(body)); fixture.Live.SetRemoteMotionRuntime(Guid, new AcDream.Runtime.Physics.RemoteMotion(body));
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
@ -948,7 +948,7 @@ public sealed class ProjectileControllerTests
var fixture = new Fixture(); var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record); RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
fixture.Live.SetRemoteMotionRuntime(Guid, remote); fixture.Live.SetRemoteMotionRuntime(Guid, remote);
@ -1000,7 +1000,7 @@ public sealed class ProjectileControllerTests
var fixture = new Fixture(); var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record); RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
fixture.Live.SetRemoteMotionRuntime(Guid, remote); fixture.Live.SetRemoteMotionRuntime(Guid, remote);
@ -1072,7 +1072,7 @@ public sealed class ProjectileControllerTests
seedCellId: startCell, seedCellId: startCell,
isStatic: false); isStatic: false);
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record); RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
Assert.Equal(new Vector3(40f, 0f, 0f), remote.Body.Velocity); Assert.Equal(new Vector3(40f, 0f, 0f), remote.Body.Velocity);
Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega); Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega);
@ -1138,7 +1138,7 @@ public sealed class ProjectileControllerTests
velocity: new Vector3(float.NaN, 0f, 0f)); velocity: new Vector3(float.NaN, 0f, 0f));
WorldEntity entity = record.WorldEntity!; WorldEntity entity = record.WorldEntity!;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record); RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
remote.Body.Position = entity.Position; remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation; remote.Body.Orientation = entity.Rotation;
@ -1164,7 +1164,7 @@ public sealed class ProjectileControllerTests
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!; WorldEntity entity = record.WorldEntity!;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record); RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
remote.Body.Position = entity.Position; remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation; remote.Body.Orientation = entity.Rotation;
@ -1188,7 +1188,7 @@ public sealed class ProjectileControllerTests
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
PhysicsBody body = record.PhysicsBody!; PhysicsBody body = record.PhysicsBody!;
var remote = new AcDream.App.Physics.RemoteMotion(body); var remote = new AcDream.Runtime.Physics.RemoteMotion(body);
fixture.Live.SetRemoteMotionRuntime(Guid, remote); fixture.Live.SetRemoteMotionRuntime(Guid, remote);
fixture.Live.RebucketLiveEntity(Guid, CellB); fixture.Live.RebucketLiveEntity(Guid, CellB);
@ -1200,7 +1200,7 @@ public sealed class ProjectileControllerTests
Assert.Same(body, record.RemoteMotionRuntime!.Body); Assert.Same(body, record.RemoteMotionRuntime!.Body);
Assert.Same(body, record.ProjectileRuntime!.Body); Assert.Same(body, record.ProjectileRuntime!.Body);
var replacement = new AcDream.App.Physics.RemoteMotion(body); var replacement = new AcDream.Runtime.Physics.RemoteMotion(body);
fixture.Live.SetRemoteMotionRuntime(Guid, replacement); fixture.Live.SetRemoteMotionRuntime(Guid, replacement);
remote.CellId = CellB; remote.CellId = CellB;
Assert.Equal(CellA, record.FullCellId); Assert.Equal(CellA, record.FullCellId);

View file

@ -57,10 +57,9 @@ public sealed class RemotePhysicsUpdaterTests
Quaternion turn = Quaternion.CreateFromAxisAngle( Quaternion turn = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ, Vector3.UnitZ,
MathF.PI / 2f); MathF.PI / 2f);
var engine = new PhysicsEngine();
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 13u, Id = LiveEntityRuntime.FirstLiveEntityId + 13u,
ServerGuid = 0x80000013u, ServerGuid = 0x80000013u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero, Position = Vector3.Zero,
@ -68,7 +67,7 @@ public sealed class RemotePhysicsUpdaterTests
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = cellId, ParentCellId = cellId,
}; };
var motion = new AcDream.App.Physics.RemoteMotion var motion = new AcDream.Runtime.Physics.RemoteMotion
{ {
LastShadowSyncPos = Vector3.Zero, LastShadowSyncPos = Vector3.Zero,
LastShadowSyncOrientation = Quaternion.Identity, LastShadowSyncOrientation = Quaternion.Identity,
@ -76,7 +75,8 @@ public sealed class RemotePhysicsUpdaterTests
motion.Body.Position = Vector3.Zero; motion.Body.Position = Vector3.Zero;
motion.Body.Orientation = Quaternion.Identity; motion.Body.Orientation = Quaternion.Identity;
motion.CellId = cellId; motion.CellId = cellId;
engine.ShadowObjects.RegisterMultiPart( RemoteBinding binding = BindRemote(entity, motion, cellId);
binding.Engine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.Id,
entity.Position, entity.Position,
entity.Rotation, entity.Rotation,
@ -96,12 +96,7 @@ public sealed class RemotePhysicsUpdaterTests
worldOffsetY: 0f, worldOffsetY: 0f,
landblockId: 0x01010000u, landblockId: 0x01010000u,
seedCellId: cellId); seedCellId: cellId);
var updater = new RemotePhysicsUpdater( binding.Updater.Tick(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
updater.Tick(
motion, motion,
entity, entity,
objectScale: 1f, objectScale: 1f,
@ -110,10 +105,13 @@ public sealed class RemotePhysicsUpdaterTests
dt: 0.1f, dt: 0.1f,
new MotionDeltaFrame { Orientation = turn }, new MotionDeltaFrame { Orientation = turn },
liveCenterX: 1, liveCenterX: 1,
liveCenterY: 1); liveCenterY: 1,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
ShadowEntry entry = Assert.Single( ShadowEntry entry = Assert.Single(
engine.ShadowObjects.AllEntriesForDebug(), binding.Engine.ShadowObjects.AllEntriesForDebug(),
candidate => candidate.EntityId == entity.Id); candidate => candidate.EntityId == entity.Id);
Assert.Equal(0f, entry.Position.X, 3); Assert.Equal(0f, entry.Position.X, 3);
Assert.Equal(1f, entry.Position.Y, 3); Assert.Equal(1f, entry.Position.Y, 3);
@ -130,7 +128,7 @@ public sealed class RemotePhysicsUpdaterTests
{ {
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 10u, Id = LiveEntityRuntime.FirstLiveEntityId + 10u,
ServerGuid = 0x80000010u, ServerGuid = 0x80000010u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 20f, 30f), Position = new Vector3(10f, 20f, 30f),
@ -151,7 +149,7 @@ public sealed class RemotePhysicsUpdaterTests
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
}; };
var motion = new AcDream.App.Physics.RemoteMotion(); var motion = new AcDream.Runtime.Physics.RemoteMotion();
motion.Body.Position = entity.Position; motion.Body.Position = entity.Position;
motion.Body.Orientation = entity.Rotation; motion.Body.Orientation = entity.Rotation;
motion.Body.TransientState = TransientStateFlags.Contact motion.Body.TransientState = TransientStateFlags.Contact
@ -164,18 +162,17 @@ public sealed class RemotePhysicsUpdaterTests
Vector3.UnitZ, Vector3.UnitZ,
MathF.PI / 2f), MathF.PI / 2f),
}; };
var updater = new RemotePhysicsUpdater( RemoteBinding binding = BindRemote(entity, motion);
new PhysicsEngine(), binding.Updater.Tick(
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
updater.Tick(
motion, motion,
animated, animated,
dt: 0.1f, dt: 0.1f,
sequenceFrame, sequenceFrame,
liveCenterX: 0, liveCenterX: 0,
liveCenterY: 0); liveCenterY: 0,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
// Current body faces east, so the sequence's local +Y origin moves // Current body faces east, so the sequence's local +Y origin moves
// east before its quarter-turn is composed. No ObservedOmega/manual // east before its quarter-turn is composed. No ObservedOmega/manual
@ -198,7 +195,7 @@ public sealed class RemotePhysicsUpdaterTests
Quaternion rootTurn = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f); Quaternion rootTurn = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 11u, Id = LiveEntityRuntime.FirstLiveEntityId + 11u,
ServerGuid = 0x80000011u, ServerGuid = 0x80000011u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 20f, 30f), Position = new Vector3(10f, 20f, 30f),
@ -217,7 +214,7 @@ public sealed class RemotePhysicsUpdaterTests
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
}; };
var motion = new AcDream.App.Physics.RemoteMotion var motion = new AcDream.Runtime.Physics.RemoteMotion
{ {
Airborne = true, Airborne = true,
}; };
@ -229,12 +226,17 @@ public sealed class RemotePhysicsUpdaterTests
Origin = new Vector3(0f, 10f, 0f), Origin = new Vector3(0f, 10f, 0f),
Orientation = rootTurn, Orientation = rootTurn,
}; };
var updater = new RemotePhysicsUpdater( RemoteBinding binding = BindRemote(entity, motion);
new PhysicsEngine(), binding.Updater.Tick(
(_, _) => (0.48f, 1.835f), motion,
(_, _, _, _) => { }); animated,
0.1f,
updater.Tick(motion, animated, 0.1f, root, 0, 0); root,
0,
0,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
Assert.Equal(new Vector3(10f, 20f, 30f), motion.Body.Position); Assert.Equal(new Vector3(10f, 20f, 30f), motion.Body.Position);
Quaternion expected = Quaternion.Normalize(initial * rootTurn); Quaternion expected = Quaternion.Normalize(initial * rootTurn);
@ -253,7 +255,7 @@ public sealed class RemotePhysicsUpdaterTests
Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f); Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 12u, Id = LiveEntityRuntime.FirstLiveEntityId + 12u,
ServerGuid = 0x80000012u, ServerGuid = 0x80000012u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 20f, 30f), Position = new Vector3(10f, 20f, 30f),
@ -272,7 +274,7 @@ public sealed class RemotePhysicsUpdaterTests
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
}; };
var motion = new AcDream.App.Physics.RemoteMotion(); var motion = new AcDream.Runtime.Physics.RemoteMotion();
motion.Body.Position = entity.Position; motion.Body.Position = entity.Position;
motion.Body.Orientation = initial; motion.Body.Orientation = initial;
motion.Interp.Enqueue( motion.Interp.Enqueue(
@ -285,12 +287,17 @@ public sealed class RemotePhysicsUpdaterTests
Origin = new Vector3(0f, 10f, 0f), Origin = new Vector3(0f, 10f, 0f),
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f), Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f),
}; };
var updater = new RemotePhysicsUpdater( RemoteBinding binding = BindRemote(entity, motion);
new PhysicsEngine(), binding.Updater.Tick(
(_, _) => (0.48f, 1.835f), motion,
(_, _, _, _) => { }); animated,
0.1f,
updater.Tick(motion, animated, 0.1f, root, 0, 0); root,
0,
0,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
// InterpolationManager::adjust_offset clamps the 1 m correction to // InterpolationManager::adjust_offset clamps the 1 m correction to
// maxSpeed (8 m/s) * 0.1 s = 0.8 m and replaces the authored 10 m // maxSpeed (8 m/s) * 0.1 s = 0.8 m and replaces the authored 10 m
@ -308,7 +315,7 @@ public sealed class RemotePhysicsUpdaterTests
[Fact] [Fact]
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics() public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
{ {
var motion = new AcDream.App.Physics.RemoteMotion(); var motion = new AcDream.Runtime.Physics.RemoteMotion();
motion.Body.Position = Vector3.Zero; motion.Body.Position = Vector3.Zero;
motion.Body.Orientation = Quaternion.Identity; motion.Body.Orientation = Quaternion.Identity;
motion.Body.Velocity = new Vector3(4f, 0f, 5f); motion.Body.Velocity = new Vector3(4f, 0f, 5f);
@ -321,7 +328,7 @@ public sealed class RemotePhysicsUpdaterTests
currentBodyPosition: Vector3.Zero); currentBodyPosition: Vector3.Zero);
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 1u, Id = LiveEntityRuntime.FirstLiveEntityId + 1u,
ServerGuid = 0x70000001u, ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero, Position = Vector3.Zero,
@ -329,13 +336,19 @@ public sealed class RemotePhysicsUpdaterTests
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
var updater = new RemotePhysicsUpdater( RemoteBinding binding = BindRemote(
new PhysicsEngine(), entity,
(_, _) => (0.48f, 1.835f), motion,
(_, _, _, _) => { }); motion.CellId);
var poses = new EntityEffectPoseRegistry(); var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, Array.Empty<Matrix4x4>()); poses.Publish(entity, Array.Empty<Matrix4x4>());
updater.TickHidden(motion, entity, 0.1f); binding.Updater.TickHidden(
motion,
entity,
0.1f,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
Assert.True(poses.UpdateRoot(entity)); Assert.True(poses.UpdateRoot(entity));
Assert.InRange(motion.Body.Position.X, 0.01f, 1f); Assert.InRange(motion.Body.Position.X, 0.01f, 1f);
@ -350,29 +363,29 @@ public sealed class RemotePhysicsUpdaterTests
[Fact] [Fact]
public void TickHidden_RunsPartArrayManagerTailWithoutAdvancingSequence() public void TickHidden_RunsPartArrayManagerTailWithoutAdvancingSequence()
{ {
var updater = new RemotePhysicsUpdater( var motion = new AcDream.Runtime.Physics.RemoteMotion();
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new AcDream.App.Physics.RemoteMotion();
motion.Body.Orientation = Quaternion.Identity; motion.Body.Orientation = Quaternion.Identity;
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 2u, Id = LiveEntityRuntime.FirstLiveEntityId + 2u,
ServerGuid = 0x70000002u, ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero, Position = Vector3.Zero,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
RemoteBinding binding = BindRemote(entity, motion);
AnimationSequencer sequencer = CreateSequencer(); AnimationSequencer sequencer = CreateSequencer();
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0); sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
updater.TickHidden( binding.Updater.TickHidden(
motion, motion,
entity, entity,
0.1f, 0.1f,
sequencer.Manager); sequencer.Manager,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
Assert.Empty(sequencer.Manager.PendingAnimations); Assert.Empty(sequencer.Manager.PendingAnimations);
} }
@ -380,26 +393,23 @@ public sealed class RemotePhysicsUpdaterTests
[Fact] [Fact]
public void TickHidden_ProcessesPendingHooksBeforeManagerTail() public void TickHidden_ProcessesPendingHooksBeforeManagerTail()
{ {
var updater = new RemotePhysicsUpdater( var motion = new AcDream.Runtime.Physics.RemoteMotion();
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new AcDream.App.Physics.RemoteMotion();
motion.Body.Orientation = Quaternion.Identity; motion.Body.Orientation = Quaternion.Identity;
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 3u, Id = LiveEntityRuntime.FirstLiveEntityId + 3u,
ServerGuid = 0x70000003u, ServerGuid = 0x70000003u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero, Position = Vector3.Zero,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
RemoteBinding binding = BindRemote(entity, motion);
AnimationSequencer sequencer = CreateSequencer(); AnimationSequencer sequencer = CreateSequencer();
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0); sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
var order = new List<string>(); var order = new List<string>();
updater.TickHidden( binding.Updater.TickHidden(
motion, motion,
entity, entity,
0.1f, 0.1f,
@ -410,7 +420,10 @@ public sealed class RemotePhysicsUpdaterTests
Assert.NotEmpty(sequencer.Manager.PendingAnimations); Assert.NotEmpty(sequencer.Manager.PendingAnimations);
order.Add("hooks"); order.Add("hooks");
}, },
sequencer); sequencer,
binding.Live,
binding.Record,
binding.Record.ObjectClockEpoch);
Assert.Equal(["hooks"], order); Assert.Equal(["hooks"], order);
Assert.Empty(sequencer.Manager.PendingAnimations); Assert.Empty(sequencer.Manager.PendingAnimations);
@ -419,27 +432,13 @@ public sealed class RemotePhysicsUpdaterTests
[Fact] [Fact]
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide() public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
{ {
var engine = BuildBoundaryEngine(); var motion = new AcDream.Runtime.Physics.RemoteMotion();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new AcDream.App.Physics.RemoteMotion();
motion.Body.Position = new Vector3(191f, 10f, 50f); motion.Body.Position = new Vector3(191f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity; motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Contact motion.Body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable; | TransientStateFlags.OnWalkable;
uint canonicalCell = 0xA9B40039u; uint canonicalCell = 0xA9B40039u;
int canonicalWrites = 0;
motion.BindCanonicalCell(
() => canonicalCell,
value =>
{
canonicalCell = value;
canonicalWrites++;
});
motion.CellId = canonicalCell; motion.CellId = canonicalCell;
canonicalWrites = 0;
motion.Interp.Enqueue( motion.Interp.Enqueue(
new Vector3(193f, 10f, 50f), new Vector3(193f, 10f, 50f),
heading: 0f, heading: 0f,
@ -447,31 +446,41 @@ public sealed class RemotePhysicsUpdaterTests
currentBodyPosition: motion.Body.Position); currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 1u, Id = LiveEntityRuntime.FirstLiveEntityId + 1u,
ServerGuid = 0x70000002u, ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position, Position = motion.Body.Position,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
RemoteBinding binding = BindRemote(
entity,
motion,
canonicalCell);
PopulateBoundaryEngine(binding.Engine);
ulong spatialAuthority = binding.Record.Canonical
.SpatialAuthorityVersion;
updater.TickHidden(motion, entity, 2f); binding.Updater.TickHidden(
motion,
entity,
2f,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
Assert.Equal(0xAAB40001u, canonicalCell); Assert.Equal(0xAAB40001u, binding.Record.FullCellId);
Assert.Equal(canonicalCell, entity.ParentCellId); Assert.Equal(binding.Record.FullCellId, entity.ParentCellId);
Assert.True(canonicalWrites > 0); Assert.True(
binding.Record.Canonical.SpatialAuthorityVersion
> spatialAuthority);
Assert.True(entity.Position.X > 192f); Assert.True(entity.Position.X > 192f);
} }
[Fact] [Fact]
public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding() public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding()
{ {
var engine = BuildBoundaryEngine(); var motion = new AcDream.Runtime.Physics.RemoteMotion
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new AcDream.App.Physics.RemoteMotion
{ {
Airborne = true, Airborne = true,
}; };
@ -486,15 +495,26 @@ public sealed class RemotePhysicsUpdaterTests
currentBodyPosition: motion.Body.Position); currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 3u, Id = LiveEntityRuntime.FirstLiveEntityId + 3u,
ServerGuid = 0x70000003u, ServerGuid = 0x70000003u,
SourceGfxObjOrSetupId = 0x02000001u, SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position, Position = motion.Body.Position,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
RemoteBinding binding = BindRemote(
entity,
motion,
motion.CellId);
PopulateBoundaryEngine(binding.Engine);
updater.TickHidden(motion, entity, 0.1f); binding.Updater.TickHidden(
motion,
entity,
0.1f,
ownerRuntime: binding.Live,
ownerRecord: binding.Record,
ownerClockEpoch: binding.Record.ObjectClockEpoch);
Assert.False(motion.Body.InContact); Assert.False(motion.Body.InContact);
Assert.False(motion.Body.OnWalkable); Assert.False(motion.Body.OnWalkable);
@ -591,7 +611,7 @@ public sealed class RemotePhysicsUpdaterTests
using var fixture = new BoundaryRemoteFixture(); using var fixture = new BoundaryRemoteFixture();
LiveEntityRecord oldRecord = fixture.Record; LiveEntityRecord oldRecord = fixture.Record;
WorldEntity oldEntity = fixture.Entity; WorldEntity oldEntity = fixture.Entity;
AcDream.App.Physics.RemoteMotion oldRemote = fixture.Remote; AcDream.Runtime.Physics.RemoteMotion oldRemote = fixture.Remote;
ulong oldAuthority = oldRecord.PositionAuthorityVersion; ulong oldAuthority = oldRecord.PositionAuthorityVersion;
int publications = 0; int publications = 0;
@ -628,9 +648,9 @@ public sealed class RemotePhysicsUpdaterTests
using var fixture = new BoundaryRemoteFixture(); using var fixture = new BoundaryRemoteFixture();
LiveEntityRecord oldRecord = fixture.Record; LiveEntityRecord oldRecord = fixture.Record;
WorldEntity oldEntity = fixture.Entity; WorldEntity oldEntity = fixture.Entity;
AcDream.App.Physics.RemoteMotion oldRemote = fixture.Remote; AcDream.Runtime.Physics.RemoteMotion oldRemote = fixture.Remote;
WorldEntity? replacementEntity = null; WorldEntity? replacementEntity = null;
AcDream.App.Physics.RemoteMotion? replacementRemote = null; AcDream.Runtime.Physics.RemoteMotion? replacementRemote = null;
ulong clockEpoch = oldRecord.ObjectClockEpoch; ulong clockEpoch = oldRecord.ObjectClockEpoch;
fixture.Live.ProjectionVisibilityChanged += (record, visible) => fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
@ -700,7 +720,7 @@ public sealed class RemotePhysicsUpdaterTests
Assert.Equal(2, live.SpatialRemoteMotionRuntimeCount); Assert.Equal(2, live.SpatialRemoteMotionRuntimeCount);
var updater = new RemotePhysicsUpdater( var updater = new RemotePhysicsUpdater(
new PhysicsEngine(), live.Physics,
(_, _) => (0.48f, 1.835f), (_, _) => (0.48f, 1.835f),
(_, _, _, _) => { }); (_, _, _, _) => { });
var published = new List<uint>(); var published = new List<uint>();
@ -724,7 +744,7 @@ public sealed class RemotePhysicsUpdaterTests
Assert.Equal(1, live.SpatialRemoteMotionRuntimeCount); Assert.Equal(1, live.SpatialRemoteMotionRuntimeCount);
} }
private static PhysicsEngine BuildBoundaryEngine() private static void PopulateBoundaryEngine(PhysicsEngine engine)
{ {
static TerrainSurface FlatTerrain() static TerrainSurface FlatTerrain()
{ {
@ -734,7 +754,6 @@ public sealed class RemotePhysicsUpdaterTests
return new TerrainSurface(heights, table); return new TerrainSurface(heights, table);
} }
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock( engine.AddLandblock(
0xA9B4FFFFu, 0xA9B4FFFFu,
FlatTerrain(), FlatTerrain(),
@ -749,9 +768,119 @@ public sealed class RemotePhysicsUpdaterTests
Array.Empty<PortalPlane>(), Array.Empty<PortalPlane>(),
192f, 192f,
0f); 0f);
return engine;
} }
private static RemoteBinding BindRemote(
WorldEntity entity,
AcDream.Runtime.Physics.RemoteMotion remote,
uint fullCellId = 0x01010001u)
{
entity.ParentCellId = fullCellId;
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
(fullCellId & 0xFFFF0000u) | 0xFFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
LiveEntityRuntime live = LiveEntityRuntimeFixture.Create(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
firstLocalEntityId: entity.Id);
LiveEntityRecord record = live.RegisterAndMaterializeProjection(
SpawnRemote(
entity.ServerGuid,
fullCellId,
entity.Position),
localId =>
{
Assert.Equal(entity.Id, localId);
return entity;
});
remote.CellId = fullCellId;
live.SetRemoteMotionRuntime(entity.ServerGuid, remote);
return new RemoteBinding(
live,
record,
live.Physics.Engine,
new RemotePhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { }));
}
private static WorldSession.EntitySpawn SpawnRemote(
uint guid,
uint fullCellId,
Vector3 position)
{
const PhysicsStateFlags state =
PhysicsStateFlags.ReportCollisions;
var serverPosition = new CreateObject.ServerPosition(
fullCellId,
position.X,
position.Y,
position.Z,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
1,
1,
1,
1,
0,
1,
0,
1,
1);
var physics = new PhysicsSpawnData(
RawState: (uint)state,
Position: serverPosition,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
serverPosition,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"remote physics fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)state,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed record RemoteBinding(
LiveEntityRuntime Live,
LiveEntityRecord Record,
PhysicsEngine Engine,
RemotePhysicsUpdater Updater);
private static void BindHiddenRemote(LiveEntityRuntime live, uint guid) private static void BindHiddenRemote(LiveEntityRuntime live, uint guid)
{ {
const uint cellId = 0x01010001u; const uint cellId = 0x01010001u;
@ -813,7 +942,7 @@ public sealed class RemotePhysicsUpdaterTests
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = cellId, ParentCellId = cellId,
})!; })!;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.Position = entity.Position; remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation; remote.Body.Orientation = entity.Rotation;
remote.CellId = cellId; remote.CellId = cellId;
@ -835,7 +964,7 @@ public sealed class RemotePhysicsUpdaterTests
public BoundaryRemoteFixture() public BoundaryRemoteFixture()
{ {
Engine = BuildBoundaryEngine(); Engine = null!;
Spatial.AddLandblock(new LoadedLandblock( Spatial.AddLandblock(new LoadedLandblock(
0xA9B4FFFFu, 0xA9B4FFFFu,
new LandBlock(), new LandBlock(),
@ -849,6 +978,8 @@ public sealed class RemotePhysicsUpdaterTests
Engine.ShadowObjects.Deregister(entity.Id); Engine.ShadowObjects.Deregister(entity.Id);
_presentation?.Forget(record); _presentation?.Forget(record);
}); });
Engine = Live.Physics.Engine;
PopulateBoundaryEngine(Engine);
(Record, Entity, Remote) = SpawnAndBind( (Record, Entity, Remote) = SpawnAndBind(
instanceSequence: 1, instanceSequence: 1,
new Vector3(191f, 10f, 50f), new Vector3(191f, 10f, 50f),
@ -862,7 +993,7 @@ public sealed class RemotePhysicsUpdaterTests
RegisterShadow(Entity, Remote); RegisterShadow(Entity, Remote);
Assert.True(_presentation.OnLiveEntityReady(Guid)); Assert.True(_presentation.OnLiveEntityReady(Guid));
Updater = new RemotePhysicsUpdater( Updater = new RemotePhysicsUpdater(
Engine, Live.Physics,
(_, _) => (0.48f, 1.835f), (_, _) => (0.48f, 1.835f),
(_, _, _, _) => { }); (_, _, _, _) => { });
} }
@ -873,7 +1004,7 @@ public sealed class RemotePhysicsUpdaterTests
public RemotePhysicsUpdater Updater { get; } public RemotePhysicsUpdater Updater { get; }
public LiveEntityRecord Record { get; } public LiveEntityRecord Record { get; }
public WorldEntity Entity { get; } public WorldEntity Entity { get; }
public AcDream.App.Physics.RemoteMotion Remote { get; } public AcDream.Runtime.Physics.RemoteMotion Remote { get; }
public void HydrateDestination() => public void HydrateDestination() =>
Spatial.AddLandblock(new LoadedLandblock( Spatial.AddLandblock(new LoadedLandblock(
@ -882,7 +1013,7 @@ public sealed class RemotePhysicsUpdaterTests
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
public (LiveEntityRecord Record, WorldEntity Entity, public (LiveEntityRecord Record, WorldEntity Entity,
AcDream.App.Physics.RemoteMotion Remote) ReplaceAtSource() AcDream.Runtime.Physics.RemoteMotion Remote) ReplaceAtSource()
{ {
Assert.True(Live.UnregisterLiveEntity( Assert.True(Live.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, InstanceSequence: 1), new DeleteObject.Parsed(Guid, InstanceSequence: 1),
@ -903,7 +1034,7 @@ public sealed class RemotePhysicsUpdaterTests
} }
private (LiveEntityRecord Record, WorldEntity Entity, private (LiveEntityRecord Record, WorldEntity Entity,
AcDream.App.Physics.RemoteMotion Remote) SpawnAndBind( AcDream.Runtime.Physics.RemoteMotion Remote) SpawnAndBind(
ushort instanceSequence, ushort instanceSequence,
Vector3 position, Vector3 position,
bool enqueueDestination) bool enqueueDestination)
@ -921,7 +1052,7 @@ public sealed class RemotePhysicsUpdaterTests
ParentCellId = SourceCell, ParentCellId = SourceCell,
}); });
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity); WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.Position = position; remote.Body.Position = position;
remote.Body.Orientation = Quaternion.Identity; remote.Body.Orientation = Quaternion.Identity;
remote.CellId = SourceCell; remote.CellId = SourceCell;
@ -941,7 +1072,7 @@ public sealed class RemotePhysicsUpdaterTests
private void RegisterShadow( private void RegisterShadow(
WorldEntity entity, WorldEntity entity,
AcDream.App.Physics.RemoteMotion remote) AcDream.Runtime.Physics.RemoteMotion remote)
{ {
Engine.ShadowObjects.Register( Engine.ShadowObjects.Register(
entity.Id, entity.Id,

View file

@ -101,7 +101,7 @@ public sealed class RemoteTeleportControllerTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
})!; })!;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.SnapToCell( remote.Body.SnapToCell(
sourceCell, sourceCell,
entity.Position, entity.Position,
@ -242,7 +242,7 @@ public sealed class RemoteTeleportControllerTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
})!; })!;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position);
live.SetRemoteMotionRuntime(guid, remote); live.SetRemoteMotionRuntime(guid, remote);
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
@ -333,7 +333,7 @@ public sealed class RemoteTeleportControllerTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
})!; })!;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.SnapToCell(sourceCell, sourcePosition, sourcePosition); remote.Body.SnapToCell(sourceCell, sourcePosition, sourcePosition);
remote.Body.TransientState = TransientStateFlags.Contact remote.Body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable; | TransientStateFlags.OnWalkable;
@ -774,13 +774,13 @@ public sealed class RemoteTeleportControllerTests
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
fixture.Live.SetRemoteMotionRuntime( fixture.Live.SetRemoteMotionRuntime(
RollbackFixture.Guid, RollbackFixture.Guid,
new AcDream.App.Physics.RemoteMotion())); new AcDream.Runtime.Physics.RemoteMotion()));
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
fixture.Live.SetRemoteMotionRuntime( fixture.Live.SetRemoteMotionRuntime(
RollbackFixture.Guid, RollbackFixture.Guid,
new BodyOnlyRemote(fixture.Remote.Body))); new BodyOnlyRemote(fixture.Remote.Body)));
var replacement = new AcDream.App.Physics.RemoteMotion(fixture.Remote.Body); var replacement = new AcDream.Runtime.Physics.RemoteMotion(fixture.Remote.Body);
fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, replacement); fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, replacement);
fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock));
@ -811,7 +811,7 @@ public sealed class RemoteTeleportControllerTests
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
fixture.Live.SetRemoteMotionRuntime( fixture.Live.SetRemoteMotionRuntime(
RollbackFixture.Guid, RollbackFixture.Guid,
new AcDream.App.Physics.RemoteMotion())); new AcDream.Runtime.Physics.RemoteMotion()));
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
fixture.Live.SetRemoteMotionRuntime( fixture.Live.SetRemoteMotionRuntime(
RollbackFixture.Guid, RollbackFixture.Guid,
@ -1115,7 +1115,7 @@ public sealed class RemoteTeleportControllerTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
})!; })!;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position);
live.SetRemoteMotionRuntime(guid, remote); live.SetRemoteMotionRuntime(guid, remote);
Assert.True(live.TryGetRecord(guid, out owner)); Assert.True(live.TryGetRecord(guid, out owner));
@ -1193,7 +1193,7 @@ public sealed class RemoteTeleportControllerTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
})!; })!;
var remote = new AcDream.App.Physics.RemoteMotion(); var remote = new AcDream.Runtime.Physics.RemoteMotion();
remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position);
live.SetRemoteMotionRuntime(guid, remote); live.SetRemoteMotionRuntime(guid, remote);
return new LoadedRemoteFixture( return new LoadedRemoteFixture(
@ -1238,7 +1238,7 @@ public sealed class RemoteTeleportControllerTests
private readonly record struct LoadedRemoteFixture( private readonly record struct LoadedRemoteFixture(
LiveEntityRuntime Live, LiveEntityRuntime Live,
WorldEntity Entity, WorldEntity Entity,
AcDream.App.Physics.RemoteMotion Remote, AcDream.Runtime.Physics.RemoteMotion Remote,
PhysicsEngine Engine); PhysicsEngine Engine);
private static void AddDestination(PhysicsEngine engine) => private static void AddDestination(PhysicsEngine engine) =>
@ -1397,7 +1397,7 @@ public sealed class RemoteTeleportControllerTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
})!; })!;
Remote = new AcDream.App.Physics.RemoteMotion(); Remote = new AcDream.Runtime.Physics.RemoteMotion();
Remote.Body.SnapToCell( Remote.Body.SnapToCell(
celllessSource ? 0u : SourceCell, celllessSource ? 0u : SourceCell,
SourcePosition, SourcePosition,
@ -1479,7 +1479,7 @@ public sealed class RemoteTeleportControllerTests
internal GpuWorldState Spatial { get; } = new(); internal GpuWorldState Spatial { get; } = new();
internal LiveEntityRuntime Live { get; } internal LiveEntityRuntime Live { get; }
internal WorldEntity Entity { get; } internal WorldEntity Entity { get; }
internal AcDream.App.Physics.RemoteMotion Remote { get; } internal AcDream.Runtime.Physics.RemoteMotion Remote { get; }
internal PhysicsEngine Engine { get; } = new() { DataCache = new PhysicsDataCache() }; internal PhysicsEngine Engine { get; } = new() { DataCache = new PhysicsDataCache() };
internal RemoteTeleportController Controller { get; } internal RemoteTeleportController Controller { get; }
internal LiveEntityPresentationController Presentation { get; } internal LiveEntityPresentationController Presentation { get; }

View file

@ -19,7 +19,7 @@ public sealed class RemoteTeleportPlacementTests
0x01010001u, 0x01010001u,
new Vector3(3f, 4f, 5f), new Vector3(3f, 4f, 5f),
new Vector3(3f, 4f, 5f)); new Vector3(3f, 4f, 5f));
var remote = new AcDream.App.Physics.RemoteMotion(body); var remote = new AcDream.Runtime.Physics.RemoteMotion(body);
Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
RemoteTeleportPlacement.Apply( RemoteTeleportPlacement.Apply(
@ -54,7 +54,7 @@ public sealed class RemoteTeleportPlacementTests
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
}; };
var contactPlane = new Plane(Vector3.UnitZ, 0f); var contactPlane = new Plane(Vector3.UnitZ, 0f);
var remote = new AcDream.App.Physics.RemoteMotion(body) var remote = new AcDream.Runtime.Physics.RemoteMotion(body)
{ {
Airborne = true, Airborne = true,
}; };
@ -97,7 +97,7 @@ public sealed class RemoteTeleportPlacementTests
// landblock is absent. SetPositionInternal must still receive the // landblock is absent. SetPositionInternal must still receive the
// grounded source edge captured before parking. // grounded source edge captured before parking.
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
var remote = new AcDream.App.Physics.RemoteMotion(body) var remote = new AcDream.Runtime.Physics.RemoteMotion(body)
{ {
Airborne = true, Airborne = true,
}; };
@ -141,7 +141,7 @@ public sealed class RemoteTeleportPlacementTests
// calc_acceleration, which clears grounded angular drift, before // calc_acceleration, which clears grounded angular drift, before
// set_on_walkable installs the steep destination's false value. // set_on_walkable installs the steep destination's false value.
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
var remote = new AcDream.App.Physics.RemoteMotion(body); var remote = new AcDream.Runtime.Physics.RemoteMotion(body);
RemoteTeleportPlacement.Apply( RemoteTeleportPlacement.Apply(
remote, remote,
@ -171,7 +171,7 @@ public sealed class RemoteTeleportPlacementTests
{ {
var original = new Vector3(7f, 8f, 9f); var original = new Vector3(7f, 8f, 9f);
var body = new PhysicsBody { Position = original }; var body = new PhysicsBody { Position = original };
var remote = new AcDream.App.Physics.RemoteMotion(body); var remote = new AcDream.Runtime.Physics.RemoteMotion(body);
Assert.Throws<ArgumentOutOfRangeException>(() => Assert.Throws<ArgumentOutOfRangeException>(() =>
RemoteTeleportPlacement.Apply( RemoteTeleportPlacement.Apply(

View file

@ -56,7 +56,7 @@ public sealed class LiveEntityAnimationSchedulerTests
public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum() public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum()
{ {
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid); var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
var remote = new AcDream.App.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
CellId = Cell, CellId = Cell,
}; };
@ -173,7 +173,7 @@ public sealed class LiveEntityAnimationSchedulerTests
var (live, record, entity) = BuildMaterialized( var (live, record, entity) = BuildMaterialized(
RemoteGuid, RemoteGuid,
PhysicsStateFlags.Gravity); PhysicsStateFlags.Gravity);
var remote = new AcDream.App.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
CellId = Cell, CellId = Cell,
Airborne = true, Airborne = true,
@ -290,7 +290,7 @@ public sealed class LiveEntityAnimationSchedulerTests
var physics = new PhysicsEngine(); var physics = new PhysicsEngine();
var projectiles = new ProjectileController(live, physics); var projectiles = new ProjectileController(live, physics);
record.FinalPhysicsState = ProjectileState; record.FinalPhysicsState = ProjectileState;
var remote = new AcDream.App.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
CellId = Cell, CellId = Cell,
Airborne = false, Airborne = false,
@ -602,9 +602,9 @@ public sealed class LiveEntityAnimationSchedulerTests
Assert.Equal(1, rootPublishes); Assert.Equal(1, rootPublishes);
} }
private static AcDream.App.Physics.RemoteMotion BuildRemote(WorldEntity entity) private static AcDream.Runtime.Physics.RemoteMotion BuildRemote(WorldEntity entity)
{ {
var remote = new AcDream.App.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
{ {
CellId = Cell, CellId = Cell,
}; };
@ -625,13 +625,13 @@ public sealed class LiveEntityAnimationSchedulerTests
PhysicsEngine? physics = null, PhysicsEngine? physics = null,
ProjectileController? projectiles = null) ProjectileController? projectiles = null)
{ {
physics ??= new PhysicsEngine(); physics ??= live.Physics.Engine;
var remotePhysics = new RemotePhysicsUpdater( var remotePhysics = new RemotePhysicsUpdater(
physics, live.Physics,
(_, _) => (0.48f, 1.835f), (_, _) => (0.48f, 1.835f),
(_, _, _, _) => { }); (_, _, _, _) => { });
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater( var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
physics, live.Physics,
(_, _) => (0.48f, 1.835f)); (_, _) => (0.48f, 1.835f));
var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid }; var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid };
var poses = new EntityEffectPoseRegistry(); var poses = new EntityEffectPoseRegistry();

View file

@ -0,0 +1,148 @@
using System.Text.RegularExpressions;
namespace AcDream.App.Tests.Runtime;
public sealed class RuntimePhysicsOwnershipTests
{
[Fact]
public void ProductionAppBorrowsTheRuntimePhysicsWorld()
{
string root = FindRepositoryRoot();
string appRoot = Path.Combine(root, "src", "AcDream.App");
string gameWindow = File.ReadAllText(Path.Combine(
appRoot,
"Rendering",
"GameWindow.cs"));
string app = string.Join(
"\n",
Directory.EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText));
Assert.Empty(Regex.Matches(
app,
@"new\s+(?:AcDream\.Core\.Physics\.)?PhysicsEngine\s*(?:\(|\{)"));
Assert.DoesNotContain(
"PhysicsDataCache.CreateProduction()",
app,
StringComparison.Ordinal);
Assert.Contains(
"_runtimeEntityObjects.Physics.Engine",
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
"_runtimeEntityObjects.Physics.DataCache",
gameWindow,
StringComparison.Ordinal);
}
[Fact]
public void AppPhysicsFilesArePresentationAndPreparedAssetAdapters()
{
string root = FindRepositoryRoot();
string appRoot = Path.Combine(root, "src", "AcDream.App");
string runtimeRoot = Path.Combine(root, "src", "AcDream.Runtime");
string remoteAdapter = File.ReadAllText(Path.Combine(
appRoot,
"Physics",
"RemotePhysicsUpdater.cs"));
string ordinaryAdapter = File.ReadAllText(Path.Combine(
appRoot,
"Physics",
"LiveEntityOrdinaryPhysicsUpdater.cs"));
string liveRuntime = File.ReadAllText(Path.Combine(
appRoot,
"World",
"LiveEntityRuntime.cs"));
string collisionPublisher = File.ReadAllText(Path.Combine(
appRoot,
"Streaming",
"LandblockPhysicsPublisher.cs"));
string runtimeRemote = File.ReadAllText(Path.Combine(
runtimeRoot,
"Physics",
"RuntimeRemotePhysicsUpdater.cs"));
string runtimeOrdinary = File.ReadAllText(Path.Combine(
runtimeRoot,
"Physics",
"RuntimeOrdinaryPhysicsUpdater.cs"));
string runtimePhysics = File.ReadAllText(Path.Combine(
runtimeRoot,
"Physics",
"RuntimePhysicsState.cs"));
Assert.DoesNotContain(
"ResolveWithTransition(",
remoteAdapter,
StringComparison.Ordinal);
Assert.DoesNotContain(
"RetailObjectManagerTail.Run(",
remoteAdapter,
StringComparison.Ordinal);
Assert.Contains(
"ResolveWithTransition(",
runtimeRemote,
StringComparison.Ordinal);
Assert.Contains(
"RetailObjectManagerTail.Run(",
runtimeRemote,
StringComparison.Ordinal);
Assert.DoesNotContain(
"ResolveWithTransition(",
ordinaryAdapter,
StringComparison.Ordinal);
Assert.DoesNotContain(
"RebucketLiveEntity(",
ordinaryAdapter,
StringComparison.Ordinal);
Assert.Contains(
"ResolveWithTransition(",
runtimeOrdinary,
StringComparison.Ordinal);
Assert.Contains(
"_physics.GetOrCreatePhysicsBody(",
liveRuntime,
StringComparison.Ordinal);
Assert.Contains(
"_physics.TryGetPhysicsHost(",
liveRuntime,
StringComparison.Ordinal);
Assert.Contains(
"BeginCollisionAdmission(",
collisionPublisher,
StringComparison.Ordinal);
Assert.Contains(
"AdmitCollisionAssets(",
collisionPublisher,
StringComparison.Ordinal);
Assert.DoesNotContain(
".AddLandblock(",
collisionPublisher,
StringComparison.Ordinal);
Assert.DoesNotContain(
".RemoveLandblock(",
collisionPublisher,
StringComparison.Ordinal);
Assert.Contains(
"public PhysicsBody GetOrCreatePhysicsBody(",
runtimePhysics,
StringComparison.Ordinal);
Assert.Contains(
"public bool TryGetPhysicsHost(",
runtimePhysics,
StringComparison.Ordinal);
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("AcDream.slnx was not found.");
}
}

View file

@ -504,8 +504,11 @@ public sealed class LandblockConcretePresentationPipelineTests
var state = new GpuWorldState( var state = new GpuWorldState(
new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)), new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)),
entityScriptActivator: activator); entityScriptActivator: activator);
var cache = new PhysicsDataCache(); var entityObjects =
var engine = new PhysicsEngine { DataCache = cache }; new AcDream.Runtime.Entities.RuntimeEntityObjectLifetime(
new PhysicsDataCache());
PhysicsDataCache cache = entityObjects.Physics.DataCache;
PhysicsEngine engine = entityObjects.Physics.Engine;
var render = new LandblockRenderPublisher( var render = new LandblockRenderPublisher(
(_, _, _) => calls.Add("terrain"), (_, _, _) => calls.Add("terrain"),
removeTerrain ?? (_ => calls.Add("terrain-remove")), removeTerrain ?? (_ => calls.Add("terrain-remove")),
@ -513,7 +516,9 @@ public sealed class LandblockConcretePresentationPipelineTests
state, state,
commitEnvCells: commitEnvCells, commitEnvCells: commitEnvCells,
removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove"))); removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove")));
var physics = new LandblockPhysicsPublisher(engine, new float[256]); var physics = new LandblockPhysicsPublisher(
entityObjects.Physics,
new float[256]);
var lights = new LightManager(); var lights = new LightManager();
var lighting = new LightingHookSink( var lighting = new LightingHookSink(
lights, lights,

View file

@ -4,6 +4,7 @@ using System.Reflection;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -22,17 +23,16 @@ public sealed class LandblockPhysicsPublisherTests
public void Constructor_ClonesHeightTableAndRejectsIncompleteInput() public void Constructor_ClonesHeightTableAndRejectsIncompleteInput()
{ {
Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher( Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher(
new PhysicsEngine(), new RuntimeEntityObjectLifetime().Physics,
new float[255])); new float[255]));
Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher(
new PhysicsEngine(),
HeightTable));
float[] mutable = HeightTable.ToArray(); float[] mutable = HeightTable.ToArray();
var engine = new PhysicsEngine(); var lifetime = new RuntimeEntityObjectLifetime(
var cache = new PhysicsDataCache(); new PhysicsDataCache());
engine.DataCache = cache; PhysicsEngine engine = lifetime.Physics.Engine;
var publisher = new LandblockPhysicsPublisher(engine, mutable); var publisher = new LandblockPhysicsPublisher(
lifetime.Physics,
mutable);
mutable[0] = 999f; mutable[0] = 999f;
LandblockPhysicsPublication receipt = Begin( LandblockPhysicsPublication receipt = Begin(
@ -794,11 +794,12 @@ public sealed class LandblockPhysicsPublisherTests
PhysicsEngine Engine, PhysicsEngine Engine,
PhysicsDataCache Cache) Fixture() PhysicsDataCache Cache) Fixture()
{ {
var engine = new PhysicsEngine(); var lifetime = new RuntimeEntityObjectLifetime(
var cache = new PhysicsDataCache(); new PhysicsDataCache());
engine.DataCache = cache; PhysicsEngine engine = lifetime.Physics.Engine;
PhysicsDataCache cache = lifetime.Physics.DataCache;
return ( return (
new LandblockPhysicsPublisher(engine, HeightTable), new LandblockPhysicsPublisher(lifetime.Physics, HeightTable),
engine, engine,
cache); cache);
} }

View file

@ -191,15 +191,20 @@ public sealed class LandblockStaticPresentationPublisherTests
private static FixtureState Fixture() private static FixtureState Fixture()
{ {
var cache = new PhysicsDataCache(); var entityObjects =
var engine = new PhysicsEngine { DataCache = cache }; new AcDream.Runtime.Entities.RuntimeEntityObjectLifetime(
new PhysicsDataCache());
PhysicsDataCache cache = entityObjects.Physics.DataCache;
PhysicsEngine engine = entityObjects.Physics.Engine;
var gpuState = new GpuWorldState(); var gpuState = new GpuWorldState();
var render = new LandblockRenderPublisher( var render = new LandblockRenderPublisher(
static (_, _, _) => { }, static (_, _, _) => { },
static _ => { }, static _ => { },
new CellVisibility(), new CellVisibility(),
gpuState); gpuState);
var physics = new LandblockPhysicsPublisher(engine, HeightTable); var physics = new LandblockPhysicsPublisher(
entityObjects.Physics,
HeightTable);
var lights = new LightManager(); var lights = new LightManager();
var lighting = new LightingHookSink(lights, new NullPoseSource()); var lighting = new LightingHookSink(lights, new NullPoseSource());
var translucency = new TranslucencyFadeManager(); var translucency = new TranslucencyFadeManager();

View file

@ -99,7 +99,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
watcherGuid, watcherGuid,
resolve: Resolve, resolve: Resolve,
position: new Vector3(9f, 0f, 0f)); position: new Vector3(9f, 0f, 0f));
EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind( EntityPhysicsHost rebound = EntityPhysicsHostComposition.InstallOrRebind(
runtime, runtime,
watcherRecord, watcherRecord,
configured); configured);
@ -119,7 +119,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
position: new Vector3(12f, 0f, 0f)); position: new Vector3(12f, 0f, 0f));
Assert.Same( Assert.Same(
watcher, watcher,
EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration)); EntityPhysicsHostComposition.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
Assert.Same(targetManager, watcher.TargetManager); Assert.Same(targetManager, watcher.TargetManager);
Assert.Same(positionManager, watcher.PositionManager); Assert.Same(positionManager, watcher.PositionManager);
Assert.Equal(12f, watcher.Position.Frame.Origin.X); Assert.Equal(12f, watcher.Position.Frame.Origin.X);
@ -139,7 +139,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
guid, guid,
position: new Vector3(9f, 0f, 0f)); position: new Vector3(9f, 0f, 0f));
EntityPhysicsHost stable = EntityPhysicsHost.SelectStableHostWithoutRebind( EntityPhysicsHost stable = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
runtime, runtime,
record, record,
configuration); configuration);
@ -151,7 +151,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.Same(original, record.PhysicsHost); Assert.Same(original, record.PhysicsHost);
Assert.Equal(1f, original.Position.Frame.Origin.X); Assert.Equal(1f, original.Position.Frame.Origin.X);
EntityPhysicsHost committed = EntityPhysicsHost.InstallOrRebind( EntityPhysicsHost committed = EntityPhysicsHostComposition.InstallOrRebind(
runtime, runtime,
record, record,
configuration); configuration);
@ -209,7 +209,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f)); EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f));
Assert.Same( Assert.Same(
minimal, minimal,
EntityPhysicsHost.InstallOrRebind(runtime, record, configured)); EntityPhysicsHostComposition.InstallOrRebind(runtime, record, configured));
Assert.Same(minimal, motion.Host); Assert.Same(minimal, motion.Host);
Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X); Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X);
@ -507,7 +507,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f), Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f),
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(), MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
}; };
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); EntityPhysicsHost host = EntityPhysicsHostComposition.CreateMinimal(record, _ => null, () => 0);
Assert.Equal(record.FullCellId, host.Position.ObjCellId); Assert.Equal(record.FullCellId, host.Position.ObjCellId);
Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin); Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin);
@ -611,7 +611,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
const uint guid = 0x7000004Bu; const uint guid = 0x7000004Bu;
var runtime = Runtime(); var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); EntityPhysicsHost host = EntityPhysicsHostComposition.CreateMinimal(record, _ => null, () => 0);
runtime.InstallPhysicsHost(record, host); runtime.InstallPhysicsHost(record, host);
Assert.Empty(runtime.VisibleRecords); Assert.Empty(runtime.VisibleRecords);

View file

@ -663,9 +663,9 @@ public sealed class UpdateFrameOrchestratorTests
playerModeSource, playerModeSource,
"controller.PreparePositionForCommit(", "controller.PreparePositionForCommit(",
"_camera.EnterChaseMode(legacyCamera, retailCamera);", "_camera.EnterChaseMode(legacyCamera, retailCamera);",
"EntityPhysicsHost.SelectStableHostWithoutRebind(", "EntityPhysicsHostComposition.SelectStableHostWithoutRebind(",
"_shadow.SyncPose(", "_shadow.SyncPose(",
"EntityPhysicsHost.InstallOrRebind(", "EntityPhysicsHostComposition.InstallOrRebind(",
"playerEntity.SetPosition(initial.Position);", "playerEntity.SetPosition(initial.Position);",
"controller.CommitPreparedPosition();", "controller.CommitPreparedPosition();",
"_controllerSlot.Controller = controller;", "_controllerSlot.Controller = controller;",

View file

@ -5,6 +5,7 @@ using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Physics;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;

View file

@ -81,7 +81,7 @@ internal sealed class RemoteChaseHarness
public const float OwnRadius = 0.3f; public const float OwnRadius = 0.3f;
public const float StickyTargetRadius = 0.5f; public const float StickyTargetRadius = 0.5f;
// Field-for-field mirror of AcDream.App.Physics.RemoteMotion construction: // Field-for-field mirror of AcDream.Runtime.Physics.RemoteMotion construction:
// Contact+OnWalkable+Active, InWorld=true (the // Contact+OnWalkable+Active, InWorld=true (the
// R4-V5 door fix — without it the interp's detached-object guard strips // R4-V5 door fix — without it the interp's detached-object guard strips
// every dispatched transition link), and the R4-V5 #160 RemoteWeenie // every dispatched transition link), and the R4-V5 #160 RemoteWeenie
@ -98,7 +98,7 @@ internal sealed class RemoteChaseHarness
public readonly AnimationSequencer Seq; public readonly AnimationSequencer Seq;
public readonly MotionTableDispatchSink Sink; public readonly MotionTableDispatchSink Sink;
/// <summary>R5-V5: AcDream.App.Physics.RemoteMotion.Movement twin — the ONE /// <summary>R5-V5: AcDream.Runtime.Physics.RemoteMotion.Movement twin — the ONE
/// per-entity MovementManager facade owning Interp + the MoveToManager /// per-entity MovementManager facade owning Interp + the MoveToManager
/// (retail CPhysicsObj::movement_manager).</summary> /// (retail CPhysicsObj::movement_manager).</summary>
public readonly MovementManager Movement; public readonly MovementManager Movement;
@ -167,7 +167,7 @@ internal sealed class RemoteChaseHarness
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
// ── R5-V5: the MovementManager facade owns Interp + the moveto — // ── R5-V5: the MovementManager facade owns Interp + the moveto —
// AcDream.App.Physics.RemoteMotion ctor + EnsureRemoteMotionBindings // AcDream.Runtime.Physics.RemoteMotion ctor + EnsureRemoteMotionBindings
// factory shape verbatim (sticky binds inside the factory; // factory shape verbatim (sticky binds inside the factory;
// MakeMoveToManager after the host/Pm exist). ── // MakeMoveToManager after the host/Pm exist). ──
Movement = new MovementManager(Interp) Movement = new MovementManager(Interp)

View file

@ -0,0 +1,415 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using AcDream.Core.Physics.Motion;
using System.Numerics;
namespace AcDream.Runtime.Tests.Physics;
public sealed class RuntimePhysicsStateTests
{
[Fact]
public void EntityLifetimeConstructsOneCanonicalProductionPhysicsWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
Assert.Same(
lifetime.Physics.DataCache,
lifetime.Physics.Engine.DataCache);
Assert.True(lifetime.Physics.CaptureOwnership().OwnsProductionDataCache);
Assert.Equal(0, lifetime.Physics.CaptureOwnership().LandblockCount);
Assert.False(lifetime.Physics.CaptureOwnership().IsDisposed);
}
[Fact]
public void ConcurrentRuntimeLifetimesDoNotShareMutablePhysicsState()
{
using var first = new RuntimeEntityObjectLifetime();
using var second = new RuntimeEntityObjectLifetime();
Assert.NotSame(first.Physics, second.Physics);
Assert.NotSame(first.Physics.Engine, second.Physics.Engine);
Assert.NotSame(first.Physics.DataCache, second.Physics.DataCache);
Assert.NotSame(
first.Physics.Engine.ShadowObjects,
second.Physics.Engine.ShadowObjects);
Assert.NotSame(
first.Physics.DataCache.CellGraph,
second.Physics.DataCache.CellGraph);
}
[Fact]
public void PhysicsOwnerDisposesWithItsEntityLifetime()
{
var lifetime = new RuntimeEntityObjectLifetime();
var physics = lifetime.Physics;
lifetime.Dispose();
Assert.True(physics.CaptureOwnership().IsDisposed);
}
[Fact]
public void CanonicalRecordAndPhysicsOwnerOwnRemoteComponentAndWorksets()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000001u, 1));
var remote = new TestRemoteMotion();
lifetime.Entities.SetPhysicsBody(record, remote.Body);
lifetime.Entities.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
Assert.Same(remote, record.RemoteMotion);
Assert.Same(remote.Body, record.PhysicsBody);
Assert.True(lifetime.Physics.IsSpatialRoot(record));
Assert.True(lifetime.Physics.IsSpatialRemote(record, remote));
Assert.Equal(1, lifetime.Physics.SpatialRootCount);
Assert.Equal(1, lifetime.Physics.SpatialRemoteCount);
var roots = new List<RuntimeEntityRecord>();
var remotes = new List<RuntimeEntityRecord>();
lifetime.Physics.CopySpatialRootsTo(roots);
lifetime.Physics.CopySpatialRemotesTo(remotes);
Assert.Same(record, Assert.Single(roots));
Assert.Same(record, Assert.Single(remotes));
lifetime.Entities.SetRemoteMotion(record, null);
lifetime.Physics.RefreshRemoteComponent(record);
Assert.True(lifetime.Physics.IsSpatialRoot(record));
Assert.Equal(0, lifetime.Physics.SpatialRemoteCount);
lifetime.Physics.RemoveSpatialProjection(record);
Assert.Equal(0, lifetime.Physics.SpatialRootCount);
}
[Fact]
public void EqualLocalKeysInConcurrentRuntimesDoNotShareWorksets()
{
using var first = new RuntimeEntityObjectLifetime();
using var second = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord firstRecord =
first.Entities.AddActive(Spawn(0x70000002u, 1));
RuntimeEntityRecord secondRecord =
second.Entities.AddActive(Spawn(0x70000003u, 1));
Assert.Equal(firstRecord.Key, secondRecord.Key);
first.Physics.AcknowledgeSpatialProjection(firstRecord, spatial: true);
Assert.True(first.Physics.IsSpatialRoot(firstRecord));
Assert.False(second.Physics.IsSpatialRoot(secondRecord));
Assert.Equal(1, first.Physics.SpatialRootCount);
Assert.Equal(0, second.Physics.SpatialRootCount);
}
[Fact]
public void CollisionAdmissionIsExactRuntimeScopedAndWithdrawalIsTyped()
{
using var first = new RuntimeEntityObjectLifetime();
using var second = new RuntimeEntityObjectLifetime();
RuntimeCollisionAdmission admission =
first.Physics.BeginCollisionAdmission(0xA9B4FFFFu);
RuntimeCollisionAdmission newer =
first.Physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.AdmitCollisionAssets(
admission,
CollisionAssets(0xA9B4FFFFu)));
Assert.Throws<InvalidOperationException>(() =>
second.Physics.AdmitCollisionAssets(
newer,
CollisionAssets(0xA9B4FFFFu)));
first.Physics.AdmitCollisionAssets(
newer,
CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionAcknowledgement completed =
first.Physics.CompleteCollisionAdmission(newer);
Assert.True(completed.WasResident);
Assert.Equal(1, first.Physics.Engine.LandblockCount);
Assert.Equal(
0,
first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.CompleteCollisionAdmission(newer));
RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu);
Assert.True(withdrawn.WasResident);
Assert.True(withdrawn.Generation > completed.Generation);
Assert.Equal(0, first.Physics.Engine.LandblockCount);
}
[Fact]
public void RemoteBindingOwnsCanonicalBodyCellAndTypedCellPublication()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000021u, 1));
var remote = new RemoteMotion();
var commits = new List<RuntimePhysicsCellCommit>();
lifetime.Physics.CellCommitted += commits.Add;
lifetime.Physics.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
remote.CellId = 0x02020001u;
Assert.Same(remote, record.RemoteMotion);
Assert.Same(remote.Body, record.PhysicsBody);
Assert.Equal(0x02020001u, record.FullCellId);
RuntimePhysicsCellCommit commit = Assert.Single(commits);
Assert.Same(record, commit.Record);
Assert.Equal(0x0101FFFFu, commit.PreviousFullCellId);
Assert.Equal(0x02020001u, commit.FullCellId);
Assert.True(lifetime.Physics.IsSpatialRemote(record, remote));
Assert.True(lifetime.Physics.ClearRemoteMotion(record));
Assert.Null(record.RemoteMotion);
Assert.Equal(0, lifetime.Physics.SpatialRemoteCount);
}
[Fact]
public void RemotePhysicsTickUsesCanonicalRuntimeAndPublishesPoseSnapshot()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000022u, 1));
var remote = new RemoteMotion();
remote.Body.Position = new Vector3(10f, 20f, 5f);
remote.Body.Orientation = Quaternion.Identity;
remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
lifetime.Physics.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
var updater = new RuntimeRemotePhysicsUpdater(lifetime.Physics);
RuntimeRemotePhysicsSnapshot snapshot = default;
int publishes = 0;
Assert.True(updater.Tick(
record,
remote,
objectScale: 1f,
sequencer: null,
dt: 0.1f,
objectClockEpoch: record.ObjectClockEpoch,
new MotionDeltaFrame
{
Origin = Vector3.UnitX,
Orientation = Quaternion.Identity,
},
radius: 0.48f,
height: 1.835f,
liveCenterX: 1,
liveCenterY: 1,
acknowledgeProjection: value =>
{
snapshot = value;
publishes++;
return true;
}));
Assert.Equal(1, publishes);
Assert.True(snapshot.Position.X > 10f);
Assert.Equal(remote.Body.Position, snapshot.Position);
Assert.Equal(record.FullCellId, snapshot.FullCellId);
}
[Fact]
public void PhysicsBodyAcquisitionIsCanonicalAndRejectsGuidReuse()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord first =
lifetime.Entities.AddActive(Spawn(0x70000023u, 1));
var body = new PhysicsBody();
Assert.Same(
body,
lifetime.Physics.GetOrCreatePhysicsBody(first, _ => body));
Assert.Same(
body,
lifetime.Physics.GetOrCreatePhysicsBody(
first,
_ => throw new InvalidOperationException(
"The factory must not replay.")));
RuntimeEntityRecord stale =
lifetime.Entities.AddActive(Spawn(0x70000024u, 1));
Assert.Throws<InvalidOperationException>(() =>
lifetime.Physics.GetOrCreatePhysicsBody(
stale,
_ =>
{
Assert.True(lifetime.Entities.RemoveActive(stale));
lifetime.Entities.AddActive(Spawn(0x70000024u, 2));
return new PhysicsBody();
}));
Assert.Null(stale.PhysicsBody);
Assert.False(stale.PhysicsBodyAcquisitionInProgress);
}
[Fact]
public void PhysicsHostIdentityAndLookupBelongToExactRuntimeIncarnation()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000025u, 1));
EntityPhysicsHost initial = Host(record.ServerGuid, 1f);
EntityPhysicsHost replacement = Host(record.ServerGuid, 2f);
Assert.Same(
initial,
lifetime.Physics.InstallOrRebindPhysicsHost(record, initial));
Assert.True(
lifetime.Physics.TryGetPhysicsHost(
record.ServerGuid,
out IPhysicsObjHost resolved));
Assert.Same(initial, resolved);
Assert.Same(
initial,
lifetime.Physics.InstallOrRebindPhysicsHost(record, replacement));
Assert.Equal(2f, initial.Position.Frame.Origin.X);
Assert.True(lifetime.Entities.RemoveActive(record));
RuntimeEntityRecord next =
lifetime.Entities.AddActive(Spawn(record.ServerGuid, 2));
Assert.False(
lifetime.Physics.TryGetPhysicsHost(
record.ServerGuid,
out _));
Assert.Null(next.PhysicsHost);
}
[Fact]
public void OrdinaryCellCommitIsCanonicalBeforePublication()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000026u, 1));
var body = new PhysicsBody();
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
RuntimePhysicsCellCommit observed = default;
lifetime.Physics.CellCommitted += value =>
{
Assert.Equal(value.FullCellId, value.Record.FullCellId);
observed = value;
};
Assert.True(lifetime.Physics.CommitOrdinaryCell(
record,
body,
record.ObjectClockEpoch,
0x02020001u,
externalOwnerValid: null));
Assert.Equal(0x02020001u, record.FullCellId);
Assert.Same(record, observed.Record);
Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion);
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId)
{
var heights = new byte[81];
var table = new float[256];
return new RuntimeLandblockCollisionAssets(
landblockId,
new TerrainSurface(heights, table),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f,
0u);
}
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
{
var position = new CreateObject.ServerPosition(
0x0101FFFFu,
10f,
20f,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: instance);
var physics = new PhysicsSpawnData(
RawState: 0x408u,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: 0x408u,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static EntityPhysicsHost Host(uint guid, float x) =>
new(
guid,
getPosition: () => new Position(
0x0101FFFFu,
new Vector3(x, 0f, 0f),
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.48f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: () => 0d,
physicsTimerTime: () => 0d,
getObjectA: _ => null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
private sealed class TestRemoteMotion : IRuntimeRemoteMotion
{
public PhysicsBody Body { get; } = new();
}
}