refactor(runtime): own per-session physics simulation
Move the sole PhysicsEngine, production cache, collision admissions, canonical bodies and hosts, remote components, ordinary/remote worksets, simulation, cell commits, and shadow synchronization under RuntimeEntityObjectLifetime. Keep App as the prepared-asset, animation-input, and render-projection adapter while preserving the named-retail update and collision order. Add exact-incarnation, object-clock, callback-reentrancy, GUID-reuse, two-runtime isolation, source ownership, collision publication, and graphical projection coverage. Release build and the complete 8,588-test solution pass. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
0dc3bfdeff
commit
7e6033d0ad
39 changed files with 3685 additions and 1722 deletions
200
docs/research/2026-07-26-slice-j5-5-physics-remote-ownership.md
Normal file
200
docs/research/2026-07-26-slice-j5-5-physics-remote-ownership.md
Normal 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.
|
||||
|
|
@ -463,7 +463,7 @@ internal sealed class LivePresentationCompositionPhase
|
|||
setupId => content.Dats.Get<Setup>(setupId)),
|
||||
static value => value.Dispose());
|
||||
var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
d.PhysicsEngine,
|
||||
d.EntityObjects.Physics,
|
||||
d.MotionBindings.GetSetupCylinder);
|
||||
var animationScheduler = new LiveEntityAnimationScheduler(
|
||||
liveEntities,
|
||||
|
|
@ -858,7 +858,7 @@ internal sealed class LivePresentationCompositionPhase
|
|||
removeEnvCells: envCellLease.Resource.RemoveLandblock,
|
||||
envCellPublisher: envCellLease.Resource);
|
||||
var landblockPhysicsPublisher = new LandblockPhysicsPublisher(
|
||||
d.PhysicsEngine,
|
||||
d.EntityObjects.Physics,
|
||||
world.TerrainBuild.HeightTable);
|
||||
var landblockStaticPublisher =
|
||||
new LandblockStaticPresentationPublisher(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
global using AcDream.Runtime.Gameplay;
|
||||
global using AcDream.Runtime.Physics;
|
||||
global using ILiveEntityRemoteMotionRuntime =
|
||||
AcDream.Runtime.Physics.IRuntimeRemoteMotion;
|
||||
global using ILiveEntityPhysicsHostConsumer =
|
||||
AcDream.Runtime.Physics.IRuntimePhysicsHostConsumer;
|
||||
global using ILiveEntityCanonicalRuntimeConsumer =
|
||||
AcDream.Runtime.Physics.IRuntimeCanonicalPhysicsConsumer;
|
||||
global using ILiveEntityCanonicalCellConsumer =
|
||||
AcDream.Runtime.Physics.IRuntimeCanonicalCellConsumer;
|
||||
global using ILiveEntityRemotePlacementRuntime =
|
||||
AcDream.Runtime.Physics.IRuntimeRemotePlacement;
|
||||
global using LocalPlayerControllerSlot =
|
||||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
|
||||
global using ILocalPlayerControllerSource =
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ internal sealed class PlayerModeController :
|
|||
},
|
||||
interruptCurrentMovement: () => exactMovement.CancelMoveTo(
|
||||
WeenieError.ActionCancelled));
|
||||
playerHost = EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
|
|
@ -443,7 +443,7 @@ internal sealed class PlayerModeController :
|
|||
_camera.EnterChaseMode(legacyCamera, retailCamera);
|
||||
|
||||
EntityPhysicsHost stableAfterCamera =
|
||||
EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
|
|
@ -465,7 +465,7 @@ internal sealed class PlayerModeController :
|
|||
// DAT, placement, shadow, and camera preparation has succeeded. A
|
||||
// late preparation failure therefore cannot expose an abandoned
|
||||
// controller through LiveEntityRecord.PhysicsHost.
|
||||
EntityPhysicsHost publishedHost = EntityPhysicsHost.InstallOrRebind(
|
||||
EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
|
|
|
|||
82
src/AcDream.App/Physics/EntityPhysicsHostComposition.cs
Normal file
82
src/AcDream.App/Physics/EntityPhysicsHostComposition.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Graphical configuration factory for Runtime's presentation-free
|
||||
/// <see cref="EntityPhysicsHost"/>. Runtime owns exact-incarnation
|
||||
/// installation, stable identity, manager lifetime, and lookup.
|
||||
/// </summary>
|
||||
internal static class EntityPhysicsHostComposition
|
||||
{
|
||||
internal static EntityPhysicsHost InstallOrRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
bool ProjectionIsCurrent() =>
|
||||
runtime.TryGetRecord(
|
||||
expectedRecord.ServerGuid,
|
||||
out LiveEntityRecord current)
|
||||
&& ReferenceEquals(current, expectedRecord);
|
||||
return runtime.Physics.InstallOrRebindPhysicsHost(
|
||||
expectedRecord.Canonical,
|
||||
configuration,
|
||||
ProjectionIsCurrent);
|
||||
}
|
||||
|
||||
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
bool ProjectionIsCurrent() =>
|
||||
runtime.TryGetRecord(
|
||||
expectedRecord.ServerGuid,
|
||||
out LiveEntityRecord current)
|
||||
&& ReferenceEquals(current, expectedRecord);
|
||||
return runtime.Physics.SelectStablePhysicsHostWithoutRebind(
|
||||
expectedRecord.Canonical,
|
||||
configuration,
|
||||
ProjectionIsCurrent);
|
||||
}
|
||||
|
||||
internal static EntityPhysicsHost CreateMinimal(
|
||||
LiveEntityRecord record,
|
||||
Func<uint, IPhysicsObjHost?> resolve,
|
||||
Func<double> now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
ArgumentNullException.ThrowIfNull(now);
|
||||
return new EntityPhysicsHost(
|
||||
record.ServerGuid,
|
||||
getPosition: () => new Position(
|
||||
record.FullCellId,
|
||||
record.WorldEntity?.Position
|
||||
?? record.PhysicsBody?.Position
|
||||
?? Vector3.Zero,
|
||||
record.WorldEntity?.Rotation
|
||||
?? record.PhysicsBody?.Orientation
|
||||
?? Quaternion.Identity),
|
||||
getVelocity: () => record.PhysicsBody?.Velocity ?? Vector3.Zero,
|
||||
getRadius: () => 0f,
|
||||
inContact: () => record.PhysicsBody?.InContact ?? true,
|
||||
minterpMaxSpeed: () => null,
|
||||
curTime: now,
|
||||
physicsTimerTime: now,
|
||||
getObjectA: resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
}
|
||||
}
|
||||
|
|
@ -181,7 +181,7 @@ internal sealed class LiveEntityMotionRuntimeController
|
|||
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
|
||||
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
|
||||
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||||
host = EntityPhysicsHost.InstallOrRebind(
|
||||
host = EntityPhysicsHostComposition.InstallOrRebind(
|
||||
liveEntities,
|
||||
hostRecord,
|
||||
configuredHost);
|
||||
|
|
@ -242,7 +242,7 @@ internal sealed class LiveEntityMotionRuntimeController
|
|||
return existing;
|
||||
|
||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
var minimal = EntityPhysicsHost.CreateMinimal(
|
||||
var minimal = EntityPhysicsHostComposition.CreateMinimal(
|
||||
activeRecord,
|
||||
ResolvePhysicsHost,
|
||||
NowSeconds);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,29 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the body-backed, manager-less branch of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0). A retained canonical
|
||||
/// body can temporarily outlive its RemoteMotion or projectile component; it
|
||||
/// must still compose the complete PartArray Frame, integrate physics, sweep
|
||||
/// through Transition, commit its cell, and move its collision shadow.
|
||||
/// Projects the presentation-free Runtime result of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) into App's
|
||||
/// <see cref="WorldEntity"/> and spatial buckets.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly RuntimeOrdinaryPhysicsUpdater _runtime;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
|
||||
_getSetupCylinder;
|
||||
|
||||
public LiveEntityOrdinaryPhysicsUpdater(
|
||||
PhysicsEngine physics,
|
||||
RuntimePhysicsState physics,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_runtime = new RuntimeOrdinaryPhysicsUpdater(
|
||||
physics ?? throw new ArgumentNullException(nameof(physics)));
|
||||
_getSetupCylinder = getSetupCylinder
|
||||
?? throw new ArgumentNullException(nameof(getSetupCylinder));
|
||||
}
|
||||
|
|
@ -47,165 +46,47 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
|||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(rootFrame);
|
||||
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
|
||||
if (record.PhysicsBody is not { } body
|
||||
|| !IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
{
|
||||
if (record.PhysicsBody is not { } body)
|
||||
return false;
|
||||
}
|
||||
|
||||
body.State = record.FinalPhysicsState;
|
||||
Vector3 priorPosition = body.Position;
|
||||
Quaternion priorOrientation = body.Orientation;
|
||||
bool previousContact = body.InContact;
|
||||
bool previousOnWalkable = body.OnWalkable;
|
||||
|
||||
// UpdatePositionInternal 0x00512CA1: PartArray root translation is
|
||||
// scaled only while transient OnWalkable is set. Its orientation stays
|
||||
// in the complete Frame and composes regardless of that translation
|
||||
// gate.
|
||||
Vector3 candidatePosition = priorPosition;
|
||||
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
|
||||
{
|
||||
candidatePosition += Vector3.Transform(
|
||||
rootFrame.Origin * objectScale,
|
||||
priorOrientation);
|
||||
}
|
||||
|
||||
Quaternion candidateOrientation = priorOrientation;
|
||||
if (!rootFrame.Orientation.IsIdentity)
|
||||
{
|
||||
candidateOrientation = FrameOps.SetRotate(
|
||||
candidatePosition,
|
||||
priorOrientation,
|
||||
priorOrientation * rootFrame.Orientation);
|
||||
}
|
||||
|
||||
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
|
||||
body.calc_acceleration();
|
||||
body.UpdatePhysicsInternal(quantum);
|
||||
// Omega integration writes Orientation directly; mirror it into the
|
||||
// retained Position frame before Transition consumes the candidate.
|
||||
body.SetFrameInCurrentCell(body.Position, body.Orientation);
|
||||
|
||||
// UpdatePositionInternal processes PartArray hooks after physics but
|
||||
// before UpdateObjectInternal performs its transition sweep.
|
||||
if (sequencer is not null)
|
||||
captureAnimationHooks(entity.Id, sequencer);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
Vector3 integratedPosition = body.Position;
|
||||
uint sourceCellId = record.FullCellId;
|
||||
uint resolvedCellId = sourceCellId;
|
||||
bool frameChanged = integratedPosition != priorPosition
|
||||
|| body.Orientation != priorOrientation;
|
||||
var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
|
||||
bool ExternalOwnerValid() =>
|
||||
IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch);
|
||||
|
||||
if (integratedPosition != priorPosition
|
||||
&& sourceCellId != 0
|
||||
&& radius >= 0.05f
|
||||
&& _physics.LandblockCount > 0)
|
||||
{
|
||||
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||
priorPosition,
|
||||
integratedPosition,
|
||||
sourceCellId,
|
||||
if (!_runtime.TryBegin(
|
||||
record.Canonical,
|
||||
rootFrame,
|
||||
objectScale,
|
||||
quantum,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: IsPlayerGuid(record.ServerGuid)
|
||||
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
|
||||
: ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: entity.Id);
|
||||
objectClockEpoch,
|
||||
sequencer,
|
||||
captureAnimationHooks,
|
||||
ExternalOwnerValid,
|
||||
out RuntimeOrdinaryPhysicsCommit commit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolved.Ok)
|
||||
return _runtime.Complete(
|
||||
commit,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
snapshot =>
|
||||
{
|
||||
resolvedCellId = resolved.CellId != 0
|
||||
? resolved.CellId
|
||||
: sourceCellId;
|
||||
body.CommitTransitionPosition(resolvedCellId, resolved.Position);
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable);
|
||||
body.CachedVelocity = quantum > 0f
|
||||
? (body.Position - priorPosition) / quantum
|
||||
: Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
// transition() returned null: UpdateObjectInternal keeps the
|
||||
// integrated frame in the current cell and reports no realized
|
||||
// transition velocity.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The no-PartArray-sphere arm calls set_frame and writes zero
|
||||
// cached_velocity rather than fabricating a collision shape.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
if (!ExternalOwnerValid())
|
||||
return false;
|
||||
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != sourceCellId
|
||||
&& !runtime.RebucketLiveEntity(record.ServerGuid, resolvedCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
if (frameChanged && record.IsSpatiallyVisible && record.FullCellId != 0)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.ShadowObjects,
|
||||
entity.Id,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
|
||||
return IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch);
|
||||
entity.SetPosition(snapshot.Position);
|
||||
entity.Rotation = snapshot.Orientation;
|
||||
entity.ParentCellId = snapshot.FullCellId;
|
||||
return ExternalOwnerValid();
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsCurrent(
|
||||
|
|
@ -220,7 +101,4 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
|||
&& ReferenceEquals(record.PhysicsBody, body)
|
||||
&& record.RemoteMotionRuntime is null
|
||||
&& record.ProjectileRuntime is null;
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
|
||||
using RemoteMotion = AcDream.Runtime.Physics.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -141,13 +141,14 @@ public sealed class GameWindow :
|
|||
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
|
||||
new(nearRadius: 4, farRadius: 12);
|
||||
// Phase B.3: physics engine — populated from the streaming pipeline.
|
||||
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
|
||||
private AcDream.Core.Physics.PhysicsEngine _physicsEngine =>
|
||||
_runtimeEntityObjects.Physics.Engine;
|
||||
|
||||
// Task 4: physics data cache — BSP trees + collision shapes extracted from
|
||||
// GfxObj/Setup dats during streaming. Populated on the worker thread;
|
||||
// ConcurrentDictionary inside makes cross-thread access safe.
|
||||
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =
|
||||
AcDream.Core.Physics.PhysicsDataCache.CreateProduction();
|
||||
private AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =>
|
||||
_runtimeEntityObjects.Physics.DataCache;
|
||||
|
||||
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
|
||||
// >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion
|
||||
|
|
@ -607,7 +608,7 @@ public sealed class GameWindow :
|
|||
// setup/motion dependency crosses the fail-fast construction bridge;
|
||||
// the server-velocity animation cycle is stateless shared policy.
|
||||
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
|
||||
_physicsEngine,
|
||||
_runtimeEntityObjects.Physics,
|
||||
_liveEntityMotionBindings.GetSetupCylinder,
|
||||
AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
|
||||
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher(
|
||||
|
|
@ -1120,7 +1121,6 @@ public sealed class GameWindow :
|
|||
{
|
||||
// Task 7: wire the physics data cache into the engine so Transition can
|
||||
// run narrow-phase BSP tests during FindObjCollisions.
|
||||
_physicsEngine.DataCache = _physicsDataCache;
|
||||
|
||||
GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ using AcDream.Core.Physics.Motion;
|
|||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Entities;
|
||||
using DatReaderWriter.Types;
|
||||
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
|
||||
using RemoteMotion = AcDream.Runtime.Physics.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -19,7 +20,8 @@ public sealed class LandblockPhysicsPublication
|
|||
Vector3 origin,
|
||||
uint currentCellId,
|
||||
BuildingInfo[] buildings,
|
||||
uint[] priorStaticOwnerIds)
|
||||
uint[] priorStaticOwnerIds,
|
||||
RuntimeCollisionAdmission collisionAdmission)
|
||||
{
|
||||
Owner = owner;
|
||||
Build = build;
|
||||
|
|
@ -27,6 +29,7 @@ public sealed class LandblockPhysicsPublication
|
|||
CurrentCellId = currentCellId;
|
||||
Buildings = buildings;
|
||||
PriorStaticOwnerIds = priorStaticOwnerIds;
|
||||
CollisionAdmission = collisionAdmission;
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
|
|
@ -34,6 +37,7 @@ public sealed class LandblockPhysicsPublication
|
|||
internal uint CurrentCellId { get; }
|
||||
internal BuildingInfo[] Buildings { get; }
|
||||
internal uint[] PriorStaticOwnerIds { get; }
|
||||
internal RuntimeCollisionAdmission CollisionAdmission { get; }
|
||||
internal SortedSet<uint> GfxObjectIdSet { get; } = new();
|
||||
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
|
||||
internal int PreparationCursor { get; set; }
|
||||
|
|
@ -102,6 +106,7 @@ public readonly record struct LandblockPhysicsPublisherDiagnostics(
|
|||
public sealed class LandblockPhysicsPublisher
|
||||
{
|
||||
private readonly object _receiptOwner = new();
|
||||
private readonly RuntimePhysicsState _physics;
|
||||
private readonly PhysicsEngine _physicsEngine;
|
||||
private readonly PhysicsDataCache _physicsDataCache;
|
||||
private readonly float[] _heightTable;
|
||||
|
|
@ -121,21 +126,19 @@ public sealed class LandblockPhysicsPublisher
|
|||
private long _fullRemovalCount;
|
||||
|
||||
public LandblockPhysicsPublisher(
|
||||
PhysicsEngine physicsEngine,
|
||||
RuntimePhysicsState physics,
|
||||
float[] heightTable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsEngine);
|
||||
ArgumentNullException.ThrowIfNull(physics);
|
||||
ArgumentNullException.ThrowIfNull(heightTable);
|
||||
if (heightTable.Length < 256)
|
||||
throw new ArgumentException(
|
||||
"The retail terrain height table must contain at least 256 entries.",
|
||||
nameof(heightTable));
|
||||
|
||||
_physicsEngine = physicsEngine;
|
||||
_physicsDataCache = physicsEngine.DataCache
|
||||
?? throw new ArgumentException(
|
||||
"The physics engine must own its canonical data cache before publication wiring.",
|
||||
nameof(physicsEngine));
|
||||
_physics = physics;
|
||||
_physicsEngine = physics.Engine;
|
||||
_physicsDataCache = physics.DataCache;
|
||||
_heightTable = (float[])heightTable.Clone();
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +211,8 @@ public sealed class LandblockPhysicsPublisher
|
|||
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
|
||||
buildings,
|
||||
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
|
||||
build.Landblock.LandblockId),
|
||||
_physics.BeginCollisionAdmission(
|
||||
build.Landblock.LandblockId));
|
||||
publication.SetupObjectIds = build.Collisions is { } collisions
|
||||
? [.. collisions.SetupIds]
|
||||
|
|
@ -332,18 +337,16 @@ public sealed class LandblockPhysicsPublisher
|
|||
}
|
||||
else if (!publication.BaseCommitted)
|
||||
{
|
||||
_physicsEngine.AddLandblock(
|
||||
landblock.LandblockId,
|
||||
publication.TerrainSurface,
|
||||
publication.CellSurfaces,
|
||||
publication.PortalPlanes,
|
||||
origin.X,
|
||||
origin.Y);
|
||||
if ((publication.CurrentCellId & 0xFFFF0000u)
|
||||
== (landblock.LandblockId & 0xFFFF0000u))
|
||||
{
|
||||
_physicsEngine.UpdatePlayerCurrCell(publication.CurrentCellId);
|
||||
}
|
||||
_physics.AdmitCollisionAssets(
|
||||
publication.CollisionAdmission,
|
||||
new RuntimeLandblockCollisionAssets(
|
||||
landblock.LandblockId,
|
||||
publication.TerrainSurface,
|
||||
publication.CellSurfaces.ToArray(),
|
||||
publication.PortalPlanes.ToArray(),
|
||||
origin.X,
|
||||
origin.Y,
|
||||
publication.CurrentCellId));
|
||||
publication.BaseCommitted = true;
|
||||
}
|
||||
else
|
||||
|
|
@ -481,6 +484,8 @@ public sealed class LandblockPhysicsPublisher
|
|||
}
|
||||
else
|
||||
{
|
||||
_physics.CompleteCollisionAdmission(
|
||||
publication.CollisionAdmission);
|
||||
_staticBspOwnerCount += publication.BspOwnerCount;
|
||||
_staticCylinderOwnerCount += publication.CylinderOwnerCount;
|
||||
publication.CompletionCommitted = true;
|
||||
|
|
@ -493,13 +498,13 @@ public sealed class LandblockPhysicsPublisher
|
|||
|
||||
public void DemoteToTerrain(uint landblockId)
|
||||
{
|
||||
_physicsEngine.DemoteLandblockToTerrain(landblockId);
|
||||
_physics.DemoteCollisionToTerrain(landblockId);
|
||||
_demotionCount++;
|
||||
}
|
||||
|
||||
public void RemoveLandblock(uint landblockId)
|
||||
{
|
||||
_physicsEngine.RemoveLandblock(landblockId);
|
||||
_physics.WithdrawCollision(landblockId);
|
||||
_fullRemovalCount++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,65 +20,6 @@ public interface ILiveEntityAnimationRuntime
|
|||
uint CurrentMotion { get; }
|
||||
}
|
||||
|
||||
/// <summary>Remote motion state owned by a live object.</summary>
|
||||
public interface ILiveEntityRemoteMotionRuntime
|
||||
{
|
||||
PhysicsBody Body { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional seam for a remote-motion component that reads the exact
|
||||
/// incarnation's canonical movement-manager host from
|
||||
/// <see cref="LiveEntityRecord"/>.
|
||||
/// </summary>
|
||||
public interface ILiveEntityPhysicsHostConsumer
|
||||
{
|
||||
void BindPhysicsHost(Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> read);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic composition seam for a component that consumes both canonical host
|
||||
/// and cell identity. Implementations validate every delegate before
|
||||
/// publishing any of them, so a failed bind leaves the component reusable.
|
||||
/// </summary>
|
||||
public interface ILiveEntityCanonicalRuntimeConsumer
|
||||
{
|
||||
void BindCanonicalRuntime(
|
||||
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> writeCell);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional seam for a remote-motion component that consumes the canonical
|
||||
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
|
||||
/// binds this for every production remote, regardless of whether projectile
|
||||
/// classification arrives before or after the MovementManager.
|
||||
/// </summary>
|
||||
public interface ILiveEntityCanonicalCellConsumer
|
||||
{
|
||||
void BindCanonicalCell(Func<uint> read, Action<uint> write);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
|
||||
/// placement. A same-incarnation runtime replacement may wrap the same body,
|
||||
/// but it must preserve this complete placement contract.
|
||||
/// </summary>
|
||||
public interface ILiveEntityRemotePlacementRuntime :
|
||||
ILiveEntityRemoteMotionRuntime,
|
||||
ILiveEntityCanonicalCellConsumer
|
||||
{
|
||||
uint CellId { get; set; }
|
||||
bool Airborne { get; set; }
|
||||
Vector3 LastServerPosition { get; set; }
|
||||
double LastServerPositionTime { get; set; }
|
||||
Vector3 LastShadowSyncPosition { get; set; }
|
||||
Quaternion LastShadowSyncOrientation { get; set; }
|
||||
void HitGround();
|
||||
void LeaveGround();
|
||||
}
|
||||
|
||||
/// <summary>Projectile physics state owned by a live object.</summary>
|
||||
public interface ILiveEntityProjectileRuntime
|
||||
{
|
||||
|
|
@ -296,14 +237,26 @@ public sealed class LiveEntityRecord
|
|||
set => _directory.SetPhysicsBodyAcquisitionInProgress(Canonical, value);
|
||||
}
|
||||
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
||||
internal bool RemoteMotionBindingInProgress { get; set; }
|
||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime
|
||||
{
|
||||
get => Canonical.RemoteMotion;
|
||||
internal set => _directory.SetRemoteMotion(Canonical, value);
|
||||
}
|
||||
internal bool RemoteMotionBindingInProgress
|
||||
{
|
||||
get => Canonical.RemoteMotionBindingInProgress;
|
||||
set => _directory.SetRemoteMotionBindingInProgress(Canonical, value);
|
||||
}
|
||||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost
|
||||
{
|
||||
get => Canonical.PhysicsHost;
|
||||
internal set => _directory.SetPhysicsHost(Canonical, value);
|
||||
}
|
||||
internal bool RequiresRemotePlacementRuntime { get; set; }
|
||||
internal bool RequiresRemotePlacementRuntime
|
||||
{
|
||||
get => Canonical.RequiresRemotePlacementRuntime;
|
||||
set => _directory.SetRequiresRemotePlacementRuntime(Canonical, value);
|
||||
}
|
||||
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
||||
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
||||
public bool ResourcesRegistered { get; internal set; }
|
||||
|
|
@ -435,6 +388,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
|
||||
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
||||
private readonly RuntimeEntityDirectory _directory;
|
||||
private readonly RuntimePhysicsState _physics;
|
||||
private readonly LiveEntityProjectionStore _projections;
|
||||
// Logical components survive retail's 25-second leave-visibility lifetime.
|
||||
// Frame loops must not scan those retained owners. These component-specific
|
||||
|
|
@ -442,12 +396,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
// projection. Hidden and Frozen remain members: their state controls what
|
||||
// each retail update path advances after the O(active) lookup.
|
||||
private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new();
|
||||
private readonly Dictionary<RuntimeEntityKey, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotion = new();
|
||||
private readonly Dictionary<RuntimeEntityKey, ILiveEntityProjectileRuntime> _spatialProjectiles = new();
|
||||
// Retail CPhysics::UseTime walks the ordinary object table, not a render-
|
||||
// animation component table. This index is the loaded/cell-backed root
|
||||
// workset; component-specific indexes remain lookup accelerators only.
|
||||
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _spatialRootObjects = new();
|
||||
private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new();
|
||||
private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new();
|
||||
private bool _isClearing;
|
||||
private bool _sessionClearPendingFinalization;
|
||||
private bool _isRegisteringResources;
|
||||
|
|
@ -492,8 +443,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
_entityObjects = entityObjects
|
||||
?? throw new ArgumentNullException(nameof(entityObjects));
|
||||
_directory = _entityObjects.Entities;
|
||||
_physics = _entityObjects.Physics;
|
||||
_projections = new LiveEntityProjectionStore(_directory);
|
||||
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
|
||||
_physics.CellCommitted += OnRuntimePhysicsCellCommitted;
|
||||
}
|
||||
|
||||
public int Count => _directory.Count;
|
||||
|
|
@ -506,6 +459,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
_projections.VisibleRecords;
|
||||
internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords =>
|
||||
_directory.ActiveRecords;
|
||||
internal RuntimePhysicsState Physics => _physics;
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots;
|
||||
internal int AnimationRuntimeCount
|
||||
{
|
||||
|
|
@ -522,9 +476,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
}
|
||||
}
|
||||
internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count;
|
||||
internal int SpatialRemoteMotionRuntimeCount => _spatialRemoteMotion.Count;
|
||||
internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount;
|
||||
internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count;
|
||||
internal int SpatialRootObjectCount => _spatialRootObjects.Count;
|
||||
internal int SpatialRootObjectCount => _physics.SpatialRootCount;
|
||||
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
|
||||
|
||||
internal bool TryGetCanonical(
|
||||
|
|
@ -798,10 +752,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
RuntimeEntityKey key = RequireProjectionKey(record);
|
||||
bool wasProjected = record.IsSpatiallyProjected;
|
||||
bool wasVisible = record.IsSpatiallyVisible;
|
||||
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
|
||||
key,
|
||||
out LiveEntityRecord? indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record);
|
||||
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
|
||||
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
||||
// GpuWorldState reports an intermediate false/true pair while moving
|
||||
// between two loaded buckets. Suppress those implementation details
|
||||
|
|
@ -936,10 +887,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
return false;
|
||||
|
||||
RuntimeEntityKey key = RequireProjectionKey(record);
|
||||
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
|
||||
key,
|
||||
out LiveEntityRecord? rootRecord)
|
||||
&& ReferenceEquals(rootRecord, record);
|
||||
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
|
||||
ulong objectClockEpoch = record.ObjectClockEpoch;
|
||||
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
||||
Exception? spatialNotificationFailure = null;
|
||||
|
|
@ -1436,43 +1384,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
throw new InvalidOperationException(
|
||||
$"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized.");
|
||||
}
|
||||
if (record.PhysicsBody is { } retained)
|
||||
return retained;
|
||||
if (record.PhysicsBodyAcquisitionInProgress)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} physics-body acquisition is already in progress.");
|
||||
}
|
||||
|
||||
record.PhysicsBodyAcquisitionInProgress = true;
|
||||
try
|
||||
{
|
||||
PhysicsBody candidate = factory(record)
|
||||
?? throw new InvalidOperationException("Physics-body factory returned null.");
|
||||
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|| current.WorldEntity is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-body acquisition.");
|
||||
}
|
||||
if (record.PhysicsBody is { } concurrentlyBound)
|
||||
{
|
||||
if (ReferenceEquals(concurrentlyBound, candidate))
|
||||
return concurrentlyBound;
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} acquired two physics bodies within one incarnation.");
|
||||
}
|
||||
|
||||
record.PhysicsBody = candidate;
|
||||
candidate.State = record.FinalPhysicsState;
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
return candidate;
|
||||
}
|
||||
finally
|
||||
{
|
||||
record.PhysicsBodyAcquisitionInProgress = false;
|
||||
}
|
||||
bool ProjectionIsCurrent() =>
|
||||
_projections.TryGetCurrent(
|
||||
serverGuid,
|
||||
out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& current.WorldEntity is not null;
|
||||
return _physics.GetOrCreatePhysicsBody(
|
||||
record.Canonical,
|
||||
_ => factory(record),
|
||||
ProjectionIsCurrent);
|
||||
}
|
||||
|
||||
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
|
||||
|
|
@ -1480,122 +1401,17 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record))
|
||||
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
||||
if (record.RemoteMotionBindingInProgress)
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress.");
|
||||
PhysicsBody candidateBody = runtime.Body
|
||||
?? throw new InvalidOperationException("A remote-motion runtime returned no physics body.");
|
||||
if (ReferenceEquals(record.RemoteMotionRuntime, runtime))
|
||||
{
|
||||
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body.");
|
||||
}
|
||||
// Idempotent publication is important when motion and vector
|
||||
// packets converge on the same runtime in one update turn. The
|
||||
// component seams are incarnation-bound and must never be rebound.
|
||||
candidateBody.State = record.FinalPhysicsState;
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
return;
|
||||
}
|
||||
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
|
||||
if (record.PhysicsBody is { } canonicalBody
|
||||
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
||||
}
|
||||
if (record.RequiresRemotePlacementRuntime
|
||||
&& runtime is not ILiveEntityRemotePlacementRuntime)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
|
||||
}
|
||||
// Bind every possibly-throwing exact-incarnation seam before
|
||||
// publishing any canonical record/index state. An already-bound
|
||||
// component from an older GUID generation must fail without poisoning
|
||||
// the replacement record. The callback is arbitrary App code, so the
|
||||
// exact record, authority epoch, and expected owners are revalidated
|
||||
// before commit just like resource/physics-body acquisition.
|
||||
LiveEntityRecord incarnation = record;
|
||||
ulong sessionVersion = _directory.SessionLifetimeVersion;
|
||||
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
|
||||
PhysicsBody? expectedBody = record.PhysicsBody;
|
||||
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime;
|
||||
bool expectedPlacementContract = record.RequiresRemotePlacementRuntime;
|
||||
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost = () =>
|
||||
_projections.TryGetCurrent(serverGuid, out var current)
|
||||
&& ReferenceEquals(current, incarnation)
|
||||
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
|
||||
? current.PhysicsHost
|
||||
: null;
|
||||
// Reads remain bound to the exact CPhysicsObj incarnation through its
|
||||
// exit_world notifications. Only writes require active ownership.
|
||||
Func<uint> readCell = () => incarnation.FullCellId;
|
||||
Action<uint> writeCell = cellId =>
|
||||
{
|
||||
if (cellId != 0
|
||||
&& _projections.TryGetCurrent(serverGuid, out var current)
|
||||
&& ReferenceEquals(current, incarnation)
|
||||
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
|
||||
{
|
||||
RebucketLiveEntity(serverGuid, cellId);
|
||||
}
|
||||
};
|
||||
|
||||
record.RemoteMotionBindingInProgress = true;
|
||||
try
|
||||
{
|
||||
if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer)
|
||||
{
|
||||
canonicalConsumer.BindCanonicalRuntime(
|
||||
readPhysicsHost,
|
||||
readCell,
|
||||
writeCell);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer;
|
||||
bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer;
|
||||
if (consumesHost && consumesCell)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
|
||||
}
|
||||
if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer)
|
||||
hostConsumer.BindPhysicsHost(readPhysicsHost);
|
||||
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
||||
cellConsumer.BindCanonicalCell(readCell, writeCell);
|
||||
}
|
||||
|
||||
if (_directory.SessionLifetimeVersion != sessionVersion
|
||||
|| CurrentLifetimeMutation(serverGuid) != lifetimeVersion
|
||||
|| !_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current)
|
||||
|| !ReferenceEquals(current, incarnation)
|
||||
|| !ReferenceEquals(current.PhysicsBody, expectedBody)
|
||||
|| !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime)
|
||||
|| current.RequiresRemotePlacementRuntime != expectedPlacementContract)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding.");
|
||||
}
|
||||
|
||||
record.RequiresRemotePlacementRuntime |=
|
||||
runtime is ILiveEntityRemotePlacementRuntime;
|
||||
record.RemoteMotionRuntime = runtime;
|
||||
record.PhysicsBody = candidateBody;
|
||||
candidateBody.State = record.FinalPhysicsState;
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
}
|
||||
finally
|
||||
{
|
||||
record.RemoteMotionBindingInProgress = false;
|
||||
}
|
||||
_physics.SetRemoteMotion(
|
||||
record.Canonical,
|
||||
runtime,
|
||||
() => CurrentLifetimeMutation(serverGuid) == lifetimeVersion
|
||||
&& _projections.TryGetCurrent(
|
||||
serverGuid,
|
||||
out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record));
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
}
|
||||
|
||||
public void InstallPhysicsHost(
|
||||
|
|
@ -1605,26 +1421,23 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (host.Id != serverGuid)
|
||||
throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host));
|
||||
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
||||
|| !ReferenceEquals(record, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation.");
|
||||
}
|
||||
if (record.PhysicsHost is not null)
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host.");
|
||||
record.PhysicsHost = host;
|
||||
bool ProjectionIsCurrent() =>
|
||||
_projections.TryGetCurrent(
|
||||
serverGuid,
|
||||
out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, expectedRecord);
|
||||
_physics.InstallPhysicsHost(
|
||||
expectedRecord.Canonical,
|
||||
host,
|
||||
ProjectionIsCurrent);
|
||||
}
|
||||
|
||||
public bool TryGetPhysicsHost(
|
||||
uint serverGuid,
|
||||
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
|
||||
{
|
||||
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.PhysicsHost is { } existing)
|
||||
if (_projections.TryGetCurrent(serverGuid, out _)
|
||||
&& _physics.TryGetPhysicsHost(serverGuid, out var existing))
|
||||
{
|
||||
host = existing;
|
||||
return true;
|
||||
|
|
@ -1656,7 +1469,8 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.RemoteMotionRuntime is null)
|
||||
return false;
|
||||
record.RemoteMotionRuntime = null;
|
||||
if (!_physics.ClearRemoteMotion(record.Canonical))
|
||||
return false;
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1977,10 +1791,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
destination.Clear();
|
||||
foreach ((RuntimeEntityKey key, LiveEntityRecord indexed) in _spatialRootObjects)
|
||||
_physics.CopySpatialRootsTo(_spatialRootCanonicalScratch);
|
||||
for (int index = 0;
|
||||
index < _spatialRootCanonicalScratch.Count;
|
||||
index++)
|
||||
{
|
||||
if (_projections.TryGet(key, out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, indexed)
|
||||
RuntimeEntityRecord canonical =
|
||||
_spatialRootCanonicalScratch[index];
|
||||
if (_projections.TryGet(
|
||||
canonical,
|
||||
out LiveEntityRecord? current)
|
||||
&& HasSpatialRuntimeProjection(current))
|
||||
{
|
||||
destination.Add(current);
|
||||
|
|
@ -1990,9 +1810,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
|
||||
internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) =>
|
||||
IsCurrentRecord(record)
|
||||
&& record.ProjectionKey is { } key
|
||||
&& _spatialRootObjects.TryGetValue(key, out var indexed)
|
||||
&& ReferenceEquals(indexed, record)
|
||||
&& _physics.IsSpatialRoot(record.Canonical)
|
||||
&& HasSpatialRuntimeProjection(record);
|
||||
|
||||
internal bool IsCurrentSpatialAnimation(
|
||||
|
|
@ -2169,12 +1987,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
destination.Clear();
|
||||
|
||||
foreach ((RuntimeEntityKey key, ILiveEntityRemoteMotionRuntime runtime)
|
||||
in _spatialRemoteMotion)
|
||||
_physics.CopySpatialRemotesTo(_spatialRemoteCanonicalScratch);
|
||||
for (int index = 0;
|
||||
index < _spatialRemoteCanonicalScratch.Count;
|
||||
index++)
|
||||
{
|
||||
if (_projections.TryGet(key, out LiveEntityRecord? record)
|
||||
&& ReferenceEquals(record.RemoteMotionRuntime, runtime)
|
||||
RuntimeEntityRecord canonical =
|
||||
_spatialRemoteCanonicalScratch[index];
|
||||
if (_projections.TryGet(
|
||||
canonical,
|
||||
out LiveEntityRecord? record)
|
||||
&& HasSpatialRuntimeProjection(record))
|
||||
{
|
||||
destination.Add(record);
|
||||
|
|
@ -2186,10 +2008,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
LiveEntityRecord record,
|
||||
ILiveEntityRemoteMotionRuntime runtime) =>
|
||||
IsCurrentRecord(record)
|
||||
&& ReferenceEquals(record.RemoteMotionRuntime, runtime)
|
||||
&& record.ProjectionKey is { } key
|
||||
&& _spatialRemoteMotion.TryGetValue(key, out var indexed)
|
||||
&& ReferenceEquals(indexed, runtime)
|
||||
&& _physics.IsSpatialRemote(record.Canonical, runtime)
|
||||
&& HasSpatialRuntimeProjection(record);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2623,16 +2442,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
return;
|
||||
}
|
||||
|
||||
if (spatial)
|
||||
{
|
||||
_spatialRootObjects[key] = record;
|
||||
}
|
||||
else if (current
|
||||
|| (_spatialRootObjects.TryGetValue(key, out var indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record)))
|
||||
{
|
||||
_spatialRootObjects.Remove(key);
|
||||
}
|
||||
_physics.AcknowledgeSpatialProjection(record.Canonical, spatial);
|
||||
|
||||
if (record.WorldEntity is not null)
|
||||
{
|
||||
|
|
@ -2649,18 +2459,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
}
|
||||
}
|
||||
|
||||
if (spatial && record.RemoteMotionRuntime is { } remote)
|
||||
{
|
||||
_spatialRemoteMotion[key] = remote;
|
||||
}
|
||||
else if (current
|
||||
|| (record.RemoteMotionRuntime is { } retainedRemote
|
||||
&& _spatialRemoteMotion.TryGetValue(key, out var indexedRemote)
|
||||
&& ReferenceEquals(indexedRemote, retainedRemote)))
|
||||
{
|
||||
_spatialRemoteMotion.Remove(key);
|
||||
}
|
||||
|
||||
if (spatial && record.ProjectileRuntime is { } projectile)
|
||||
{
|
||||
_spatialProjectiles[key] = projectile;
|
||||
|
|
@ -2679,11 +2477,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
if (record.ProjectionKey is not { } key)
|
||||
return;
|
||||
|
||||
if (_spatialRootObjects.TryGetValue(key, out var indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record))
|
||||
{
|
||||
_spatialRootObjects.Remove(key);
|
||||
}
|
||||
_physics.RemoveSpatialProjection(record.Canonical);
|
||||
|
||||
if (record.WorldEntity is not null
|
||||
&& _spatialAnimations.TryGetValue(key, out var indexedAnimation)
|
||||
|
|
@ -2692,12 +2486,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
_spatialAnimations.Remove(key);
|
||||
}
|
||||
|
||||
if (_spatialRemoteMotion.TryGetValue(key, out var indexedRemote)
|
||||
&& ReferenceEquals(indexedRemote, record.RemoteMotionRuntime))
|
||||
{
|
||||
_spatialRemoteMotion.Remove(key);
|
||||
}
|
||||
|
||||
if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile)
|
||||
&& ReferenceEquals(indexedProjectile, record.ProjectileRuntime))
|
||||
{
|
||||
|
|
@ -2725,10 +2513,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
bool wasVisible = record.IsSpatiallyVisible;
|
||||
if (RequireProjectionKey(record) != key)
|
||||
return;
|
||||
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
|
||||
key,
|
||||
out LiveEntityRecord? indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record);
|
||||
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
|
||||
record.IsSpatiallyVisible = visible;
|
||||
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
||||
&& record.IsSpatiallyProjected
|
||||
|
|
@ -2749,6 +2534,25 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
PublishProjectionVisibilityChanged(record, visible);
|
||||
}
|
||||
|
||||
private void OnRuntimePhysicsCellCommitted(
|
||||
RuntimePhysicsCellCommit commit)
|
||||
{
|
||||
if (commit.Record.SpatialAuthorityVersion
|
||||
!= commit.SpatialAuthorityVersion
|
||||
|| !_directory.IsCurrent(commit.Record)
|
||||
|| !_projections.TryGet(
|
||||
commit.Record,
|
||||
out LiveEntityRecord? projection)
|
||||
|| projection.WorldEntity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RebucketLiveEntity(
|
||||
commit.Record.ServerGuid,
|
||||
commit.FullCellId);
|
||||
}
|
||||
|
||||
private static void SynchronizePhysicsBodyActiveState(
|
||||
LiveEntityRecord record)
|
||||
{
|
||||
|
|
@ -2877,9 +2681,8 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
}
|
||||
|
||||
_spatialAnimations.Clear();
|
||||
_spatialRemoteMotion.Clear();
|
||||
_spatialProjectiles.Clear();
|
||||
_spatialRootObjects.Clear();
|
||||
_physics.ClearSpatialWorksets();
|
||||
_projections.ClearConverged();
|
||||
_spatial.ClearLiveEntityLifetimeState();
|
||||
_sessionClearPendingFinalization = false;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
|
|
@ -338,6 +339,30 @@ public sealed class RuntimeEntityDirectory
|
|||
record.PhysicsBodyAcquisitionInProgress = value;
|
||||
}
|
||||
|
||||
public void SetRemoteMotion(
|
||||
RuntimeEntityRecord record,
|
||||
IRuntimeRemoteMotion? remote)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.RemoteMotion = remote;
|
||||
}
|
||||
|
||||
public void SetRemoteMotionBindingInProgress(
|
||||
RuntimeEntityRecord record,
|
||||
bool value)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.RemoteMotionBindingInProgress = value;
|
||||
}
|
||||
|
||||
public void SetRequiresRemotePlacementRuntime(
|
||||
RuntimeEntityRecord record,
|
||||
bool value)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.RequiresRemotePlacementRuntime = value;
|
||||
}
|
||||
|
||||
public void SetPhysicsHost(
|
||||
RuntimeEntityRecord record,
|
||||
AcDream.Core.Physics.Motion.IPhysicsObjHost? host)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
|
|
@ -74,6 +75,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
|
||||
Physics = new RuntimePhysicsState(Entities);
|
||||
Objects = new ClientObjectTable();
|
||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||
EntityView = views.Entities;
|
||||
InventoryView = views.Inventory;
|
||||
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
|
||||
}
|
||||
|
||||
internal RuntimeEntityObjectLifetime(
|
||||
PhysicsDataCache physicsDataCache,
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsDataCache);
|
||||
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
|
||||
Physics = new RuntimePhysicsState(Entities, physicsDataCache);
|
||||
Objects = new ClientObjectTable();
|
||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||
EntityView = views.Entities;
|
||||
|
|
@ -82,6 +98,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
}
|
||||
|
||||
public RuntimeEntityDirectory Entities { get; }
|
||||
public RuntimePhysicsState Physics { get; }
|
||||
public ClientObjectTable Objects { get; }
|
||||
public IRuntimeEntityView EntityView { get; }
|
||||
public IRuntimeInventoryView InventoryView { get; }
|
||||
|
|
@ -324,6 +341,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
Entities.RetainTeardown(canonical);
|
||||
try
|
||||
{
|
||||
Physics.RemoveSpatialProjection(canonical);
|
||||
Entities.SetRemoteMotion(canonical, null);
|
||||
Entities.SetRemoteMotionBindingInProgress(canonical, false);
|
||||
Entities.SetRequiresRemotePlacementRuntime(canonical, false);
|
||||
Entities.SetPhysicsHost(canonical, null);
|
||||
Entities.SetPhysicsBody(canonical, null);
|
||||
Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false);
|
||||
|
|
@ -960,6 +981,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
{
|
||||
_disposed = true;
|
||||
Events.Dispose();
|
||||
Physics.Dispose();
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
|
|
@ -68,6 +69,9 @@ public sealed class RuntimeEntityRecord
|
|||
public bool HasPartArray { get; internal set; }
|
||||
public PhysicsBody? PhysicsBody { get; internal set; }
|
||||
public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
|
||||
public IRuntimeRemoteMotion? RemoteMotion { get; internal set; }
|
||||
public bool RemoteMotionBindingInProgress { get; internal set; }
|
||||
public bool RequiresRemotePlacementRuntime { get; internal set; }
|
||||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
|
||||
public ulong PositionAuthorityVersion { get; private set; }
|
||||
public ulong StateAuthorityVersion { get; private set; }
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@ using System;
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
||||
/// R5-V2 — the Runtime-owned <see cref="IPhysicsObjHost"/> per entity:
|
||||
/// acdream's
|
||||
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
||||
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
||||
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
||||
/// and stored on the exact <c>LiveEntityRecord</c>, so
|
||||
/// One is retained per accepted object incarnation on
|
||||
/// <c>RuntimeEntityRecord</c>, so
|
||||
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
|
||||
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
|
||||
/// needs.
|
||||
|
|
@ -22,8 +21,8 @@ namespace AcDream.App.Physics;
|
|||
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
|
||||
/// target seams are repointed here, replacing the AP-79 poll adapter. The
|
||||
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
|
||||
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by the graphical or
|
||||
/// no-window host while Runtime retains the host and manager identities.</para>
|
||||
///
|
||||
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
|
||||
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
|
||||
|
|
@ -135,7 +134,7 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
/// Applies a freshly composed delegate configuration while retaining this
|
||||
/// host's exact identity and manager instances.
|
||||
/// </summary>
|
||||
private void RebindFrom(EntityPhysicsHost configuration)
|
||||
internal void RebindFrom(EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (configuration.Id != Id)
|
||||
|
|
@ -156,105 +155,6 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
configuration._interruptCurrentMovement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the first host for an exact live-object incarnation, or
|
||||
/// enriches that incarnation's existing minimal host in place. This is the
|
||||
/// shared remote/player composition seam; it deliberately never replaces
|
||||
/// TargetManager or PositionManager ownership.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost InstallOrRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition.");
|
||||
}
|
||||
|
||||
if (current.PhysicsHost is null)
|
||||
{
|
||||
runtime.InstallPhysicsHost(current, configuration);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
if (current.PhysicsHost is not EntityPhysicsHost existing)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation.");
|
||||
}
|
||||
|
||||
existing.RebindFrom(configuration);
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an exact incarnation and returns the manager-stable host that
|
||||
/// a later <see cref="InstallOrRebind"/> will publish, without changing any
|
||||
/// live delegates. Player-mode construction uses this preparation edge so
|
||||
/// DAT/physics/camera failures cannot expose a half-rebound host.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation.");
|
||||
}
|
||||
|
||||
return current.PhysicsHost switch
|
||||
{
|
||||
null => configuration,
|
||||
EntityPhysicsHost existing => existing,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the position-only CPhysicsObj facade used before an animated or
|
||||
/// player movement owner exists. The delegates capture the exact live
|
||||
/// record rather than the visible-world projection, so exit_world
|
||||
/// notifications retain the object's last cell and frame after the picker
|
||||
/// view has already withdrawn it.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost CreateMinimal(
|
||||
LiveEntityRecord record,
|
||||
Func<uint, IPhysicsObjHost?> resolve,
|
||||
Func<double> now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
ArgumentNullException.ThrowIfNull(now);
|
||||
return new EntityPhysicsHost(
|
||||
record.ServerGuid,
|
||||
getPosition: () => new Position(
|
||||
record.FullCellId,
|
||||
record.WorldEntity?.Position ?? Vector3.Zero,
|
||||
record.WorldEntity?.Rotation ?? Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => null,
|
||||
curTime: now,
|
||||
physicsTimerTime: now,
|
||||
getObjectA: resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
}
|
||||
|
||||
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
|
||||
public uint Id { get; }
|
||||
public Position Position => _getPosition();
|
||||
|
|
@ -302,7 +202,7 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
|
||||
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
|
||||
|
||||
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
|
||||
// ── per-tick driver + Runtime-owned lifecycle ──────────────────────────
|
||||
|
||||
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
|
||||
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
|
||||
|
|
@ -321,8 +221,8 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// not ported yet, so the current host bridges the same unavailability edge
|
||||
/// through the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||||
|
|
@ -21,11 +19,11 @@ namespace AcDream.App.Physics;
|
|||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion :
|
||||
AcDream.App.World.ILiveEntityRemotePlacementRuntime,
|
||||
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer
|
||||
IRuntimeRemotePlacement,
|
||||
IRuntimeCanonicalPhysicsConsumer
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body { get; }
|
||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||
AcDream.Core.Physics.PhysicsBody IRuntimeRemoteMotion.Body => Body;
|
||||
|
||||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||||
/// ONE per-entity owner of the interp + moveto pair (acclient.h
|
||||
|
|
@ -82,7 +80,7 @@ internal sealed class RemoteMotion :
|
|||
// voyeur system (retail CPhysicsObj::target_manager). Replaces the
|
||||
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
|
||||
// clear_target/quantum seams route here, HandleTargetting ticks per
|
||||
// frame, and OTHER entities resolve this one through LiveEntityRuntime's
|
||||
// frame, and OTHER entities resolve this one through RuntimeEntityRecord's
|
||||
// exact-incarnation PhysicsHost slot.
|
||||
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
|
||||
private bool _fullPhysicsHostBound;
|
||||
|
|
@ -164,7 +162,7 @@ internal sealed class RemoteMotion :
|
|||
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
|
||||
/// retain the local backing value. A MovementManager attached to an
|
||||
/// existing projectile delegates reads and writes to
|
||||
/// <see cref="LiveEntityRuntime"/>, so projectile prediction,
|
||||
/// <see cref="RuntimePhysicsState"/>, so projectile prediction,
|
||||
/// Movement packets, and spatial rebucketing cannot develop competing
|
||||
/// cell identities.
|
||||
/// </summary>
|
||||
|
|
@ -178,40 +176,40 @@ internal sealed class RemoteMotion :
|
|||
}
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
|
||||
System.Numerics.Vector3 IRuntimeRemotePlacement.LastServerPosition
|
||||
{
|
||||
get => LastServerPos;
|
||||
set => LastServerPos = value;
|
||||
}
|
||||
|
||||
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
|
||||
double IRuntimeRemotePlacement.LastServerPositionTime
|
||||
{
|
||||
get => LastServerPosTime;
|
||||
set => LastServerPosTime = value;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
|
||||
System.Numerics.Vector3 IRuntimeRemotePlacement.LastShadowSyncPosition
|
||||
{
|
||||
get => LastShadowSyncPos;
|
||||
set => LastShadowSyncPos = value;
|
||||
}
|
||||
|
||||
System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation
|
||||
System.Numerics.Quaternion IRuntimeRemotePlacement.LastShadowSyncOrientation
|
||||
{
|
||||
get => LastShadowSyncOrientation;
|
||||
set => LastShadowSyncOrientation = value;
|
||||
}
|
||||
|
||||
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
|
||||
bool IRuntimeRemotePlacement.Airborne
|
||||
{
|
||||
get => Airborne;
|
||||
set => Airborne = value;
|
||||
}
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
|
||||
void IRuntimeRemotePlacement.HitGround() =>
|
||||
Movement.HitGround();
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
|
||||
void IRuntimeRemotePlacement.LeaveGround() =>
|
||||
Motion.LeaveGround();
|
||||
|
||||
/// <summary>
|
||||
272
src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs
Normal file
272
src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs
Normal 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;
|
||||
}
|
||||
787
src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
Normal file
787
src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/AcDream.Runtime/Physics/RuntimeRemoteMotionContracts.cs
Normal file
42
src/AcDream.Runtime/Physics/RuntimeRemoteMotionContracts.cs
Normal 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();
|
||||
}
|
||||
900
src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
Normal file
900
src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
Normal 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 && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||
/// && rm.LastServerPosTime > 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared host adapter for retail's moved-object shadow re-registration
|
||||
|
|
@ -1,4 +1,15 @@
|
|||
global using AcDream.Runtime.Gameplay;
|
||||
global using AcDream.Runtime.Physics;
|
||||
global using ILiveEntityRemoteMotionRuntime =
|
||||
AcDream.Runtime.Physics.IRuntimeRemoteMotion;
|
||||
global using ILiveEntityPhysicsHostConsumer =
|
||||
AcDream.Runtime.Physics.IRuntimePhysicsHostConsumer;
|
||||
global using ILiveEntityCanonicalRuntimeConsumer =
|
||||
AcDream.Runtime.Physics.IRuntimeCanonicalPhysicsConsumer;
|
||||
global using ILiveEntityCanonicalCellConsumer =
|
||||
AcDream.Runtime.Physics.IRuntimeCanonicalCellConsumer;
|
||||
global using ILiveEntityRemotePlacementRuntime =
|
||||
AcDream.Runtime.Physics.IRuntimeRemotePlacement;
|
||||
global using LocalPlayerControllerSlot =
|
||||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
|
||||
global using ILocalPlayerControllerSource =
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
[Fact]
|
||||
public void CompleteRootFrame_CrossesTransitionAndCommitsCanonicalCell()
|
||||
{
|
||||
PhysicsEngine physics = BuildBoundaryEngine();
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
|
||||
var live = LiveEntityRuntimeFixture.Create(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
PopulateBoundaryEngine(live.Physics.Engine);
|
||||
LiveEntityRecord record = live.RegisterAndMaterializeProjection(
|
||||
Spawn(),
|
||||
id => new WorldEntity
|
||||
|
|
@ -40,7 +40,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
});
|
||||
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
||||
|
||||
var remote = new AcDream.App.Physics.RemoteMotion
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
CellId = SourceCell,
|
||||
};
|
||||
|
|
@ -56,7 +56,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
ulong epoch = record.ObjectClockEpoch;
|
||||
|
||||
var updater = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
physics,
|
||||
live.Physics,
|
||||
(_, _) => (0.48f, 1.835f));
|
||||
var rootFrame = new Frame
|
||||
{
|
||||
|
|
@ -106,7 +106,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
ParentCellId = SourceCell,
|
||||
});
|
||||
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
||||
var remote = new AcDream.App.Physics.RemoteMotion
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
CellId = SourceCell,
|
||||
};
|
||||
|
|
@ -123,7 +123,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
new NullAnimationLoader());
|
||||
Vector3 retainedEntityPosition = entity.Position;
|
||||
var updater = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
live.Physics,
|
||||
(_, _) => (0.48f, 1.835f));
|
||||
|
||||
Assert.False(updater.Tick(
|
||||
|
|
@ -149,7 +149,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
Assert.Equal(retainedEntityPosition, entity.Position);
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildBoundaryEngine()
|
||||
private static void PopulateBoundaryEngine(PhysicsEngine engine)
|
||||
{
|
||||
static TerrainSurface FlatTerrain()
|
||||
{
|
||||
|
|
@ -159,7 +159,6 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
return new TerrainSurface(heights, table);
|
||||
}
|
||||
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
FlatTerrain(),
|
||||
|
|
@ -174,7 +173,6 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
Array.Empty<PortalPlane>(),
|
||||
192f,
|
||||
0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static LoadedLandblock EmptyLandblock(uint landblockId) =>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ public sealed class ProjectileControllerTests
|
|||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
Assert.Null(record.AnimationRuntime);
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||
remote.Body.Position = entity.Position;
|
||||
remote.Body.Orientation = entity.Rotation;
|
||||
|
|
@ -124,7 +124,7 @@ public sealed class ProjectileControllerTests
|
|||
isMovingTo: false,
|
||||
currentBodyPosition: remote.Body.Position);
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
fixture.Engine,
|
||||
fixture.Live.Physics,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
|
|
@ -685,7 +685,7 @@ public sealed class ProjectileControllerTests
|
|||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||
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;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
|
|
@ -709,7 +709,7 @@ public sealed class ProjectileControllerTests
|
|||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
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;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
|
|
@ -948,7 +948,7 @@ public sealed class ProjectileControllerTests
|
|||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||
fixture.Live.SetRemoteMotionRuntime(Guid, remote);
|
||||
|
||||
|
|
@ -1000,7 +1000,7 @@ public sealed class ProjectileControllerTests
|
|||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||
fixture.Live.SetRemoteMotionRuntime(Guid, remote);
|
||||
|
||||
|
|
@ -1072,7 +1072,7 @@ public sealed class ProjectileControllerTests
|
|||
seedCellId: startCell,
|
||||
isStatic: false);
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||
Assert.Equal(new Vector3(40f, 0f, 0f), remote.Body.Velocity);
|
||||
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));
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||
remote.Body.Position = entity.Position;
|
||||
remote.Body.Orientation = entity.Rotation;
|
||||
|
|
@ -1164,7 +1164,7 @@ public sealed class ProjectileControllerTests
|
|||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||
remote.Body.Position = entity.Position;
|
||||
remote.Body.Orientation = entity.Rotation;
|
||||
|
|
@ -1188,7 +1188,7 @@ public sealed class ProjectileControllerTests
|
|||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||
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.RebucketLiveEntity(Guid, CellB);
|
||||
|
|
@ -1200,7 +1200,7 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Same(body, record.RemoteMotionRuntime!.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);
|
||||
remote.CellId = CellB;
|
||||
Assert.Equal(CellA, record.FullCellId);
|
||||
|
|
|
|||
|
|
@ -57,10 +57,9 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Quaternion turn = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
var engine = new PhysicsEngine();
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 13u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 13u,
|
||||
ServerGuid = 0x80000013u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
|
|
@ -68,7 +67,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = cellId,
|
||||
};
|
||||
var motion = new AcDream.App.Physics.RemoteMotion
|
||||
var motion = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
LastShadowSyncPos = Vector3.Zero,
|
||||
LastShadowSyncOrientation = Quaternion.Identity,
|
||||
|
|
@ -76,7 +75,8 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
motion.Body.Position = Vector3.Zero;
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
motion.CellId = cellId;
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
RemoteBinding binding = BindRemote(entity, motion, cellId);
|
||||
binding.Engine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
|
|
@ -96,12 +96,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: cellId);
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
engine,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(
|
||||
binding.Updater.Tick(
|
||||
motion,
|
||||
entity,
|
||||
objectScale: 1f,
|
||||
|
|
@ -110,10 +105,13 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
dt: 0.1f,
|
||||
new MotionDeltaFrame { Orientation = turn },
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1);
|
||||
liveCenterY: 1,
|
||||
ownerRuntime: binding.Live,
|
||||
ownerRecord: binding.Record,
|
||||
ownerClockEpoch: binding.Record.ObjectClockEpoch);
|
||||
|
||||
ShadowEntry entry = Assert.Single(
|
||||
engine.ShadowObjects.AllEntriesForDebug(),
|
||||
binding.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
candidate => candidate.EntityId == entity.Id);
|
||||
Assert.Equal(0f, entry.Position.X, 3);
|
||||
Assert.Equal(1f, entry.Position.Y, 3);
|
||||
|
|
@ -130,7 +128,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 10u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 10u,
|
||||
ServerGuid = 0x80000010u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 20f, 30f),
|
||||
|
|
@ -151,7 +149,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
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.Orientation = entity.Rotation;
|
||||
motion.Body.TransientState = TransientStateFlags.Contact
|
||||
|
|
@ -164,18 +162,17 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Vector3.UnitZ,
|
||||
MathF.PI / 2f),
|
||||
};
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(
|
||||
RemoteBinding binding = BindRemote(entity, motion);
|
||||
binding.Updater.Tick(
|
||||
motion,
|
||||
animated,
|
||||
dt: 0.1f,
|
||||
sequenceFrame,
|
||||
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
|
||||
// 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);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 11u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 11u,
|
||||
ServerGuid = 0x80000011u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 20f, 30f),
|
||||
|
|
@ -217,7 +214,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new AcDream.App.Physics.RemoteMotion
|
||||
var motion = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
Airborne = true,
|
||||
};
|
||||
|
|
@ -229,12 +226,17 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Origin = new Vector3(0f, 10f, 0f),
|
||||
Orientation = rootTurn,
|
||||
};
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(motion, animated, 0.1f, root, 0, 0);
|
||||
RemoteBinding binding = BindRemote(entity, motion);
|
||||
binding.Updater.Tick(
|
||||
motion,
|
||||
animated,
|
||||
0.1f,
|
||||
root,
|
||||
0,
|
||||
0,
|
||||
ownerRuntime: binding.Live,
|
||||
ownerRecord: binding.Record,
|
||||
ownerClockEpoch: binding.Record.ObjectClockEpoch);
|
||||
|
||||
Assert.Equal(new Vector3(10f, 20f, 30f), motion.Body.Position);
|
||||
Quaternion expected = Quaternion.Normalize(initial * rootTurn);
|
||||
|
|
@ -253,7 +255,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 12u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 12u,
|
||||
ServerGuid = 0x80000012u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 20f, 30f),
|
||||
|
|
@ -272,7 +274,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
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.Orientation = initial;
|
||||
motion.Interp.Enqueue(
|
||||
|
|
@ -285,12 +287,17 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Origin = new Vector3(0f, 10f, 0f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f),
|
||||
};
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(motion, animated, 0.1f, root, 0, 0);
|
||||
RemoteBinding binding = BindRemote(entity, motion);
|
||||
binding.Updater.Tick(
|
||||
motion,
|
||||
animated,
|
||||
0.1f,
|
||||
root,
|
||||
0,
|
||||
0,
|
||||
ownerRuntime: binding.Live,
|
||||
ownerRecord: binding.Record,
|
||||
ownerClockEpoch: binding.Record.ObjectClockEpoch);
|
||||
|
||||
// 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
|
||||
|
|
@ -308,7 +315,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
[Fact]
|
||||
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.Orientation = Quaternion.Identity;
|
||||
motion.Body.Velocity = new Vector3(4f, 0f, 5f);
|
||||
|
|
@ -321,7 +328,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
currentBodyPosition: Vector3.Zero);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 1u,
|
||||
ServerGuid = 0x70000001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
|
|
@ -329,13 +336,19 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
RemoteBinding binding = BindRemote(
|
||||
entity,
|
||||
motion,
|
||||
motion.CellId);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
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.InRange(motion.Body.Position.X, 0.01f, 1f);
|
||||
|
|
@ -350,29 +363,29 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
[Fact]
|
||||
public void TickHidden_RunsPartArrayManagerTailWithoutAdvancingSequence()
|
||||
{
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var motion = new AcDream.App.Physics.RemoteMotion();
|
||||
var motion = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 2u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 2u,
|
||||
ServerGuid = 0x70000002u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
RemoteBinding binding = BindRemote(entity, motion);
|
||||
AnimationSequencer sequencer = CreateSequencer();
|
||||
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
|
||||
|
||||
updater.TickHidden(
|
||||
binding.Updater.TickHidden(
|
||||
motion,
|
||||
entity,
|
||||
0.1f,
|
||||
sequencer.Manager);
|
||||
sequencer.Manager,
|
||||
ownerRuntime: binding.Live,
|
||||
ownerRecord: binding.Record,
|
||||
ownerClockEpoch: binding.Record.ObjectClockEpoch);
|
||||
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
|
@ -380,26 +393,23 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
[Fact]
|
||||
public void TickHidden_ProcessesPendingHooksBeforeManagerTail()
|
||||
{
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var motion = new AcDream.App.Physics.RemoteMotion();
|
||||
var motion = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 3u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 3u,
|
||||
ServerGuid = 0x70000003u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
RemoteBinding binding = BindRemote(entity, motion);
|
||||
AnimationSequencer sequencer = CreateSequencer();
|
||||
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
|
||||
var order = new List<string>();
|
||||
|
||||
updater.TickHidden(
|
||||
binding.Updater.TickHidden(
|
||||
motion,
|
||||
entity,
|
||||
0.1f,
|
||||
|
|
@ -410,7 +420,10 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Assert.NotEmpty(sequencer.Manager.PendingAnimations);
|
||||
order.Add("hooks");
|
||||
},
|
||||
sequencer);
|
||||
sequencer,
|
||||
binding.Live,
|
||||
binding.Record,
|
||||
binding.Record.ObjectClockEpoch);
|
||||
|
||||
Assert.Equal(["hooks"], order);
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
|
|
@ -419,27 +432,13 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
[Fact]
|
||||
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
|
||||
{
|
||||
var engine = BuildBoundaryEngine();
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
engine,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var motion = new AcDream.App.Physics.RemoteMotion();
|
||||
var motion = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
motion.Body.Position = new Vector3(191f, 10f, 50f);
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
motion.Body.TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable;
|
||||
uint canonicalCell = 0xA9B40039u;
|
||||
int canonicalWrites = 0;
|
||||
motion.BindCanonicalCell(
|
||||
() => canonicalCell,
|
||||
value =>
|
||||
{
|
||||
canonicalCell = value;
|
||||
canonicalWrites++;
|
||||
});
|
||||
motion.CellId = canonicalCell;
|
||||
canonicalWrites = 0;
|
||||
motion.Interp.Enqueue(
|
||||
new Vector3(193f, 10f, 50f),
|
||||
heading: 0f,
|
||||
|
|
@ -447,31 +446,41 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
currentBodyPosition: motion.Body.Position);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 1u,
|
||||
ServerGuid = 0x70000002u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = motion.Body.Position,
|
||||
Rotation = Quaternion.Identity,
|
||||
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(canonicalCell, entity.ParentCellId);
|
||||
Assert.True(canonicalWrites > 0);
|
||||
Assert.Equal(0xAAB40001u, binding.Record.FullCellId);
|
||||
Assert.Equal(binding.Record.FullCellId, entity.ParentCellId);
|
||||
Assert.True(
|
||||
binding.Record.Canonical.SpatialAuthorityVersion
|
||||
> spatialAuthority);
|
||||
Assert.True(entity.Position.X > 192f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding()
|
||||
{
|
||||
var engine = BuildBoundaryEngine();
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
engine,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var motion = new AcDream.App.Physics.RemoteMotion
|
||||
var motion = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
Airborne = true,
|
||||
};
|
||||
|
|
@ -486,15 +495,26 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
currentBodyPosition: motion.Body.Position);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 3u,
|
||||
Id = LiveEntityRuntime.FirstLiveEntityId + 3u,
|
||||
ServerGuid = 0x70000003u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = motion.Body.Position,
|
||||
Rotation = Quaternion.Identity,
|
||||
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.OnWalkable);
|
||||
|
|
@ -591,7 +611,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
using var fixture = new BoundaryRemoteFixture();
|
||||
LiveEntityRecord oldRecord = fixture.Record;
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
AcDream.App.Physics.RemoteMotion oldRemote = fixture.Remote;
|
||||
AcDream.Runtime.Physics.RemoteMotion oldRemote = fixture.Remote;
|
||||
ulong oldAuthority = oldRecord.PositionAuthorityVersion;
|
||||
int publications = 0;
|
||||
|
||||
|
|
@ -628,9 +648,9 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
using var fixture = new BoundaryRemoteFixture();
|
||||
LiveEntityRecord oldRecord = fixture.Record;
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
AcDream.App.Physics.RemoteMotion oldRemote = fixture.Remote;
|
||||
AcDream.Runtime.Physics.RemoteMotion oldRemote = fixture.Remote;
|
||||
WorldEntity? replacementEntity = null;
|
||||
AcDream.App.Physics.RemoteMotion? replacementRemote = null;
|
||||
AcDream.Runtime.Physics.RemoteMotion? replacementRemote = null;
|
||||
ulong clockEpoch = oldRecord.ObjectClockEpoch;
|
||||
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
|
|
@ -700,7 +720,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Assert.Equal(2, live.SpatialRemoteMotionRuntimeCount);
|
||||
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
live.Physics,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var published = new List<uint>();
|
||||
|
|
@ -724,7 +744,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Assert.Equal(1, live.SpatialRemoteMotionRuntimeCount);
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildBoundaryEngine()
|
||||
private static void PopulateBoundaryEngine(PhysicsEngine engine)
|
||||
{
|
||||
static TerrainSurface FlatTerrain()
|
||||
{
|
||||
|
|
@ -734,7 +754,6 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
return new TerrainSurface(heights, table);
|
||||
}
|
||||
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
FlatTerrain(),
|
||||
|
|
@ -749,9 +768,119 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Array.Empty<PortalPlane>(),
|
||||
192f,
|
||||
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)
|
||||
{
|
||||
const uint cellId = 0x01010001u;
|
||||
|
|
@ -813,7 +942,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = cellId,
|
||||
})!;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
remote.Body.Position = entity.Position;
|
||||
remote.Body.Orientation = entity.Rotation;
|
||||
remote.CellId = cellId;
|
||||
|
|
@ -835,7 +964,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
|
||||
public BoundaryRemoteFixture()
|
||||
{
|
||||
Engine = BuildBoundaryEngine();
|
||||
Engine = null!;
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new LandBlock(),
|
||||
|
|
@ -849,6 +978,8 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Engine.ShadowObjects.Deregister(entity.Id);
|
||||
_presentation?.Forget(record);
|
||||
});
|
||||
Engine = Live.Physics.Engine;
|
||||
PopulateBoundaryEngine(Engine);
|
||||
(Record, Entity, Remote) = SpawnAndBind(
|
||||
instanceSequence: 1,
|
||||
new Vector3(191f, 10f, 50f),
|
||||
|
|
@ -862,7 +993,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
RegisterShadow(Entity, Remote);
|
||||
Assert.True(_presentation.OnLiveEntityReady(Guid));
|
||||
Updater = new RemotePhysicsUpdater(
|
||||
Engine,
|
||||
Live.Physics,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
}
|
||||
|
|
@ -873,7 +1004,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
public RemotePhysicsUpdater Updater { get; }
|
||||
public LiveEntityRecord Record { get; }
|
||||
public WorldEntity Entity { get; }
|
||||
public AcDream.App.Physics.RemoteMotion Remote { get; }
|
||||
public AcDream.Runtime.Physics.RemoteMotion Remote { get; }
|
||||
|
||||
public void HydrateDestination() =>
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
|
|
@ -882,7 +1013,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Array.Empty<WorldEntity>()));
|
||||
|
||||
public (LiveEntityRecord Record, WorldEntity Entity,
|
||||
AcDream.App.Physics.RemoteMotion Remote) ReplaceAtSource()
|
||||
AcDream.Runtime.Physics.RemoteMotion Remote) ReplaceAtSource()
|
||||
{
|
||||
Assert.True(Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 1),
|
||||
|
|
@ -903,7 +1034,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
}
|
||||
|
||||
private (LiveEntityRecord Record, WorldEntity Entity,
|
||||
AcDream.App.Physics.RemoteMotion Remote) SpawnAndBind(
|
||||
AcDream.Runtime.Physics.RemoteMotion Remote) SpawnAndBind(
|
||||
ushort instanceSequence,
|
||||
Vector3 position,
|
||||
bool enqueueDestination)
|
||||
|
|
@ -921,7 +1052,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
ParentCellId = SourceCell,
|
||||
});
|
||||
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.Orientation = Quaternion.Identity;
|
||||
remote.CellId = SourceCell;
|
||||
|
|
@ -941,7 +1072,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
|
||||
private void RegisterShadow(
|
||||
WorldEntity entity,
|
||||
AcDream.App.Physics.RemoteMotion remote)
|
||||
AcDream.Runtime.Physics.RemoteMotion remote)
|
||||
{
|
||||
Engine.ShadowObjects.Register(
|
||||
entity.Id,
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
})!;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion();
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
remote.Body.SnapToCell(
|
||||
sourceCell,
|
||||
entity.Position,
|
||||
|
|
@ -242,7 +242,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Rotation = Quaternion.Identity,
|
||||
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);
|
||||
live.SetRemoteMotionRuntime(guid, remote);
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
|
|
@ -333,7 +333,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Rotation = Quaternion.Identity,
|
||||
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.TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable;
|
||||
|
|
@ -774,13 +774,13 @@ public sealed class RemoteTeleportControllerTests
|
|||
Assert.Throws<InvalidOperationException>(() =>
|
||||
fixture.Live.SetRemoteMotionRuntime(
|
||||
RollbackFixture.Guid,
|
||||
new AcDream.App.Physics.RemoteMotion()));
|
||||
new AcDream.Runtime.Physics.RemoteMotion()));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
fixture.Live.SetRemoteMotionRuntime(
|
||||
RollbackFixture.Guid,
|
||||
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.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock));
|
||||
|
|
@ -811,7 +811,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Assert.Throws<InvalidOperationException>(() =>
|
||||
fixture.Live.SetRemoteMotionRuntime(
|
||||
RollbackFixture.Guid,
|
||||
new AcDream.App.Physics.RemoteMotion()));
|
||||
new AcDream.Runtime.Physics.RemoteMotion()));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
fixture.Live.SetRemoteMotionRuntime(
|
||||
RollbackFixture.Guid,
|
||||
|
|
@ -1115,7 +1115,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Rotation = Quaternion.Identity,
|
||||
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);
|
||||
live.SetRemoteMotionRuntime(guid, remote);
|
||||
Assert.True(live.TryGetRecord(guid, out owner));
|
||||
|
|
@ -1193,7 +1193,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Rotation = Quaternion.Identity,
|
||||
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);
|
||||
live.SetRemoteMotionRuntime(guid, remote);
|
||||
return new LoadedRemoteFixture(
|
||||
|
|
@ -1238,7 +1238,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
private readonly record struct LoadedRemoteFixture(
|
||||
LiveEntityRuntime Live,
|
||||
WorldEntity Entity,
|
||||
AcDream.App.Physics.RemoteMotion Remote,
|
||||
AcDream.Runtime.Physics.RemoteMotion Remote,
|
||||
PhysicsEngine Engine);
|
||||
|
||||
private static void AddDestination(PhysicsEngine engine) =>
|
||||
|
|
@ -1397,7 +1397,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
})!;
|
||||
Remote = new AcDream.App.Physics.RemoteMotion();
|
||||
Remote = new AcDream.Runtime.Physics.RemoteMotion();
|
||||
Remote.Body.SnapToCell(
|
||||
celllessSource ? 0u : SourceCell,
|
||||
SourcePosition,
|
||||
|
|
@ -1479,7 +1479,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
internal GpuWorldState Spatial { get; } = new();
|
||||
internal LiveEntityRuntime Live { 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 RemoteTeleportController Controller { get; }
|
||||
internal LiveEntityPresentationController Presentation { get; }
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class RemoteTeleportPlacementTests
|
|||
0x01010001u,
|
||||
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);
|
||||
|
||||
RemoteTeleportPlacement.Apply(
|
||||
|
|
@ -54,7 +54,7 @@ public sealed class RemoteTeleportPlacementTests
|
|||
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
|
@ -97,7 +97,7 @@ public sealed class RemoteTeleportPlacementTests
|
|||
// landblock is absent. SetPositionInternal must still receive the
|
||||
// grounded source edge captured before parking.
|
||||
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||
var remote = new AcDream.App.Physics.RemoteMotion(body)
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion(body)
|
||||
{
|
||||
Airborne = true,
|
||||
};
|
||||
|
|
@ -141,7 +141,7 @@ public sealed class RemoteTeleportPlacementTests
|
|||
// calc_acceleration, which clears grounded angular drift, before
|
||||
// set_on_walkable installs the steep destination's false value.
|
||||
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||
var remote = new AcDream.App.Physics.RemoteMotion(body);
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion(body);
|
||||
|
||||
RemoteTeleportPlacement.Apply(
|
||||
remote,
|
||||
|
|
@ -171,7 +171,7 @@ public sealed class RemoteTeleportPlacementTests
|
|||
{
|
||||
var original = new Vector3(7f, 8f, 9f);
|
||||
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>(() =>
|
||||
RemoteTeleportPlacement.Apply(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public sealed class LiveEntityAnimationSchedulerTests
|
|||
public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum()
|
||||
{
|
||||
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
|
||||
var remote = new AcDream.App.Physics.RemoteMotion
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
};
|
||||
|
|
@ -173,7 +173,7 @@ public sealed class LiveEntityAnimationSchedulerTests
|
|||
var (live, record, entity) = BuildMaterialized(
|
||||
RemoteGuid,
|
||||
PhysicsStateFlags.Gravity);
|
||||
var remote = new AcDream.App.Physics.RemoteMotion
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
Airborne = true,
|
||||
|
|
@ -290,7 +290,7 @@ public sealed class LiveEntityAnimationSchedulerTests
|
|||
var physics = new PhysicsEngine();
|
||||
var projectiles = new ProjectileController(live, physics);
|
||||
record.FinalPhysicsState = ProjectileState;
|
||||
var remote = new AcDream.App.Physics.RemoteMotion
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
Airborne = false,
|
||||
|
|
@ -602,9 +602,9 @@ public sealed class LiveEntityAnimationSchedulerTests
|
|||
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,
|
||||
};
|
||||
|
|
@ -625,13 +625,13 @@ public sealed class LiveEntityAnimationSchedulerTests
|
|||
PhysicsEngine? physics = null,
|
||||
ProjectileController? projectiles = null)
|
||||
{
|
||||
physics ??= new PhysicsEngine();
|
||||
physics ??= live.Physics.Engine;
|
||||
var remotePhysics = new RemotePhysicsUpdater(
|
||||
physics,
|
||||
live.Physics,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
physics,
|
||||
live.Physics,
|
||||
(_, _) => (0.48f, 1.835f));
|
||||
var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid };
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
|
|
|
|||
148
tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs
Normal file
148
tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -504,8 +504,11 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
var state = new GpuWorldState(
|
||||
new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)),
|
||||
entityScriptActivator: activator);
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
var entityObjects =
|
||||
new AcDream.Runtime.Entities.RuntimeEntityObjectLifetime(
|
||||
new PhysicsDataCache());
|
||||
PhysicsDataCache cache = entityObjects.Physics.DataCache;
|
||||
PhysicsEngine engine = entityObjects.Physics.Engine;
|
||||
var render = new LandblockRenderPublisher(
|
||||
(_, _, _) => calls.Add("terrain"),
|
||||
removeTerrain ?? (_ => calls.Add("terrain-remove")),
|
||||
|
|
@ -513,7 +516,9 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
state,
|
||||
commitEnvCells: commitEnvCells,
|
||||
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 lighting = new LightingHookSink(
|
||||
lights,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Reflection;
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Entities;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
|
|
@ -22,17 +23,16 @@ public sealed class LandblockPhysicsPublisherTests
|
|||
public void Constructor_ClonesHeightTableAndRejectsIncompleteInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher(
|
||||
new PhysicsEngine(),
|
||||
new RuntimeEntityObjectLifetime().Physics,
|
||||
new float[255]));
|
||||
Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher(
|
||||
new PhysicsEngine(),
|
||||
HeightTable));
|
||||
|
||||
float[] mutable = HeightTable.ToArray();
|
||||
var engine = new PhysicsEngine();
|
||||
var cache = new PhysicsDataCache();
|
||||
engine.DataCache = cache;
|
||||
var publisher = new LandblockPhysicsPublisher(engine, mutable);
|
||||
var lifetime = new RuntimeEntityObjectLifetime(
|
||||
new PhysicsDataCache());
|
||||
PhysicsEngine engine = lifetime.Physics.Engine;
|
||||
var publisher = new LandblockPhysicsPublisher(
|
||||
lifetime.Physics,
|
||||
mutable);
|
||||
mutable[0] = 999f;
|
||||
|
||||
LandblockPhysicsPublication receipt = Begin(
|
||||
|
|
@ -794,11 +794,12 @@ public sealed class LandblockPhysicsPublisherTests
|
|||
PhysicsEngine Engine,
|
||||
PhysicsDataCache Cache) Fixture()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var cache = new PhysicsDataCache();
|
||||
engine.DataCache = cache;
|
||||
var lifetime = new RuntimeEntityObjectLifetime(
|
||||
new PhysicsDataCache());
|
||||
PhysicsEngine engine = lifetime.Physics.Engine;
|
||||
PhysicsDataCache cache = lifetime.Physics.DataCache;
|
||||
return (
|
||||
new LandblockPhysicsPublisher(engine, HeightTable),
|
||||
new LandblockPhysicsPublisher(lifetime.Physics, HeightTable),
|
||||
engine,
|
||||
cache);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,15 +191,20 @@ public sealed class LandblockStaticPresentationPublisherTests
|
|||
|
||||
private static FixtureState Fixture()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
var entityObjects =
|
||||
new AcDream.Runtime.Entities.RuntimeEntityObjectLifetime(
|
||||
new PhysicsDataCache());
|
||||
PhysicsDataCache cache = entityObjects.Physics.DataCache;
|
||||
PhysicsEngine engine = entityObjects.Physics.Engine;
|
||||
var gpuState = new GpuWorldState();
|
||||
var render = new LandblockRenderPublisher(
|
||||
static (_, _, _) => { },
|
||||
static _ => { },
|
||||
new CellVisibility(),
|
||||
gpuState);
|
||||
var physics = new LandblockPhysicsPublisher(engine, HeightTable);
|
||||
var physics = new LandblockPhysicsPublisher(
|
||||
entityObjects.Physics,
|
||||
HeightTable);
|
||||
var lights = new LightManager();
|
||||
var lighting = new LightingHookSink(lights, new NullPoseSource());
|
||||
var translucency = new TranslucencyFadeManager();
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
watcherGuid,
|
||||
resolve: Resolve,
|
||||
position: new Vector3(9f, 0f, 0f));
|
||||
EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind(
|
||||
EntityPhysicsHost rebound = EntityPhysicsHostComposition.InstallOrRebind(
|
||||
runtime,
|
||||
watcherRecord,
|
||||
configured);
|
||||
|
|
@ -119,7 +119,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
position: new Vector3(12f, 0f, 0f));
|
||||
Assert.Same(
|
||||
watcher,
|
||||
EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
|
||||
EntityPhysicsHostComposition.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
|
||||
Assert.Same(targetManager, watcher.TargetManager);
|
||||
Assert.Same(positionManager, watcher.PositionManager);
|
||||
Assert.Equal(12f, watcher.Position.Frame.Origin.X);
|
||||
|
|
@ -139,7 +139,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
guid,
|
||||
position: new Vector3(9f, 0f, 0f));
|
||||
|
||||
EntityPhysicsHost stable = EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
EntityPhysicsHost stable = EntityPhysicsHostComposition.SelectStableHostWithoutRebind(
|
||||
runtime,
|
||||
record,
|
||||
configuration);
|
||||
|
|
@ -151,7 +151,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
Assert.Same(original, record.PhysicsHost);
|
||||
Assert.Equal(1f, original.Position.Frame.Origin.X);
|
||||
|
||||
EntityPhysicsHost committed = EntityPhysicsHost.InstallOrRebind(
|
||||
EntityPhysicsHost committed = EntityPhysicsHostComposition.InstallOrRebind(
|
||||
runtime,
|
||||
record,
|
||||
configuration);
|
||||
|
|
@ -209,7 +209,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f));
|
||||
Assert.Same(
|
||||
minimal,
|
||||
EntityPhysicsHost.InstallOrRebind(runtime, record, configured));
|
||||
EntityPhysicsHostComposition.InstallOrRebind(runtime, record, configured));
|
||||
Assert.Same(minimal, motion.Host);
|
||||
Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X);
|
||||
|
||||
|
|
@ -507,7 +507,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f),
|
||||
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.WorldEntity.Position, host.Position.Frame.Origin);
|
||||
|
|
@ -611,7 +611,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
const uint guid = 0x7000004Bu;
|
||||
var runtime = Runtime();
|
||||
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);
|
||||
|
||||
Assert.Empty(runtime.VisibleRecords);
|
||||
|
|
|
|||
|
|
@ -663,9 +663,9 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
playerModeSource,
|
||||
"controller.PreparePositionForCommit(",
|
||||
"_camera.EnterChaseMode(legacyCamera, retailCamera);",
|
||||
"EntityPhysicsHost.SelectStableHostWithoutRebind(",
|
||||
"EntityPhysicsHostComposition.SelectStableHostWithoutRebind(",
|
||||
"_shadow.SyncPose(",
|
||||
"EntityPhysicsHost.InstallOrRebind(",
|
||||
"EntityPhysicsHostComposition.InstallOrRebind(",
|
||||
"playerEntity.SetPosition(initial.Position);",
|
||||
"controller.CommitPreparedPosition();",
|
||||
"_controllerSlot.Controller = controller;",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.App.Physics;
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Runtime.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ internal sealed class RemoteChaseHarness
|
|||
public const float OwnRadius = 0.3f;
|
||||
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
|
||||
// R4-V5 door fix — without it the interp's detached-object guard strips
|
||||
// every dispatched transition link), and the R4-V5 #160 RemoteWeenie
|
||||
|
|
@ -98,7 +98,7 @@ internal sealed class RemoteChaseHarness
|
|||
public readonly AnimationSequencer Seq;
|
||||
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
|
||||
/// (retail CPhysicsObj::movement_manager).</summary>
|
||||
public readonly MovementManager Movement;
|
||||
|
|
@ -167,7 +167,7 @@ internal sealed class RemoteChaseHarness
|
|||
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
|
||||
|
||||
// ── 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;
|
||||
// MakeMoveToManager after the host/Pm exist). ──
|
||||
Movement = new MovementManager(Interp)
|
||||
|
|
|
|||
415
tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
Normal file
415
tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue