diff --git a/docs/research/2026-07-26-slice-j5-5-physics-remote-ownership.md b/docs/research/2026-07-26-slice-j5-5-physics-remote-ownership.md new file mode 100644 index 00000000..8c96d293 --- /dev/null +++ b/docs/research/2026-07-26-slice-j5-5-physics-remote-ownership.md @@ -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. diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index d48db564..50a4449c 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -463,7 +463,7 @@ internal sealed class LivePresentationCompositionPhase setupId => content.Dats.Get(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( diff --git a/src/AcDream.App/GlobalUsings.cs b/src/AcDream.App/GlobalUsings.cs index d2d17bec..d6122f76 100644 --- a/src/AcDream.App/GlobalUsings.cs +++ b/src/AcDream.App/GlobalUsings.cs @@ -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 = diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index b21073d8..bc9b9ba2 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -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); diff --git a/src/AcDream.App/Physics/EntityPhysicsHostComposition.cs b/src/AcDream.App/Physics/EntityPhysicsHostComposition.cs new file mode 100644 index 00000000..e3fd6602 --- /dev/null +++ b/src/AcDream.App/Physics/EntityPhysicsHostComposition.cs @@ -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; + +/// +/// Graphical configuration factory for Runtime's presentation-free +/// . Runtime owns exact-incarnation +/// installation, stable identity, manager lifetime, and lookup. +/// +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 resolve, + Func 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: () => { }); + } +} diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index 167c7e7a..b4d0bafe 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -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); diff --git a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs index a4059500..aad3ff57 100644 --- a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs +++ b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs @@ -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; /// -/// Owns the body-backed, manager-less branch of retail -/// CPhysicsObj::UpdateObjectInternal (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 +/// CPhysicsObj::UpdateObjectInternal (0x005156B0) into App's +/// and spatial buckets. /// internal sealed class LiveEntityOrdinaryPhysicsUpdater { - private readonly PhysicsEngine _physics; + private readonly RuntimeOrdinaryPhysicsUpdater _runtime; private readonly Func _getSetupCylinder; public LiveEntityOrdinaryPhysicsUpdater( - PhysicsEngine physics, + RuntimePhysicsState physics, Func 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; } diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index 6758d8c9..70c490c8 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -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; diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 7837f878..3a171c42 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -1,1016 +1,335 @@ -using RemoteMotion = AcDream.App.Physics.RemoteMotion; -using LiveEntityAnimationState = AcDream.App.Rendering.LiveEntityAnimationState; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using AcDream.Runtime.Physics; namespace AcDream.App.Physics; /// -/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted -/// verbatim from GameWindow.TickAnimations (Code Structure Rule 1: no -/// new feature bodies in the >10k-line GameWindow). One instance is -/// owned by GameWindow and called once per animated remote entity per -/// frame, from inside the same guard the body used to live under -/// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid -/// && rm.LastServerPosTime > 0). -/// Hidden remotes use a separate live-entity pass because retail keeps their -/// PositionManager alive even when the object has no render-animation owner. -/// -/// 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 — every remote now runs the SAME catch-up + -/// ResolveWithTransition sweep + shadow-follows-resolved, so packed PLAYER -/// remotes de-overlap exactly like NPCs (retail UpdateObjectInternal -/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is -/// the !IsPlayerGuid-gated stale-velocity animation-cycle stop. See -/// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. -/// -/// Shared policy arrives through focused Physics delegates: -/// GetSetupCylinder is owned by LiveEntityMotionRuntimeController, -/// while ApplyServerControlledVelocityCycle is stateless policy also -/// consumed by the UpdatePosition route. SyncRemoteShadowToBody -/// (remote-physics-specific) moved here and is called back from the UP-branch -/// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other -/// callers and moved here outright. +/// Graphical adapter for Runtime's presentation-free remote-object physics. +/// It supplies DAT-derived shape dimensions and animation callbacks, then +/// projects the committed root snapshot into the retained App entity. /// internal sealed class RemotePhysicsUpdater { - // Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller). - private const double ServerControlledVelocityStaleSeconds = 0.60; - - private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; - private readonly System.Func _getSetupCylinder; - private readonly System.Action _applyServerControlledVelocityCycle; - private readonly List _spatialRemoteSnapshot = new(); + private readonly RuntimeRemotePhysicsUpdater _runtime; + private readonly Func + _getSetupCylinder; + private readonly Action + _applyServerControlledVelocityCycle; + private readonly List _spatialRemoteSnapshot = new(); internal RemotePhysicsUpdater( - AcDream.Core.Physics.PhysicsEngine physicsEngine, - System.Func getSetupCylinder, - System.Action applyServerControlledVelocityCycle) + RuntimePhysicsState physics, + Func getSetupCylinder, + Action + applyServerControlledVelocityCycle) { - _physicsEngine = physicsEngine; - _getSetupCylinder = getSetupCylinder; - _applyServerControlledVelocityCycle = applyServerControlledVelocityCycle; + _runtime = new RuntimeRemotePhysicsUpdater( + physics ?? throw new ArgumentNullException(nameof(physics))); + _getSetupCylinder = getSetupCylinder + ?? throw new ArgumentNullException(nameof(getSetupCylinder)); + _applyServerControlledVelocityCycle = + applyServerControlledVelocityCycle + ?? throw new ArgumentNullException( + nameof(applyServerControlledVelocityCycle)); } - // Duplicated one-liner (GameWindow keeps its own copy — many callers there). - private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; - - /// - /// Advances the retained PositionManager path for every hidden remote, - /// including objects without an LiveEntityAnimationState (missiles commonly - /// have no PartArray). Retail gates this path on the live CPhysicsObj, not - /// on whether a render-animation owner exists. - /// public void TickHiddenEntities( - AcDream.App.World.LiveEntityRuntime liveEntities, + LiveEntityRuntime liveEntities, uint localPlayerServerGuid, float dt, - System.Action publishRootPose, - System.Action? - processAnimationHooks = null, - System.Action? markPartPoseDirty = null) + Action publishRootPose, + Action? processAnimationHooks = null, + Action? markPartPoseDirty = null) { ArgumentNullException.ThrowIfNull(liveEntities); ArgumentNullException.ThrowIfNull(publishRootPose); - System.Numerics.Vector3? playerPosition = null; + Vector3? playerPosition = null; if (localPlayerServerGuid != 0 && liveEntities.TryGetWorldEntity( localPlayerServerGuid, - out AcDream.Core.World.WorldEntity playerEntity)) + out WorldEntity playerEntity)) { playerPosition = playerEntity.Position; } - liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot); - foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot) + liveEntities.CopySpatialRemoteMotionRecordsTo( + _spatialRemoteSnapshot); + foreach (LiveEntityRecord record in _spatialRemoteSnapshot) { if (record.ServerGuid == localPlayerServerGuid - || (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0 + || (record.FinalPhysicsState & PhysicsStateFlags.Hidden) == 0 || record.RemoteMotionRuntime is not RemoteMotion remote || !liveEntities.IsCurrentSpatialRemoteMotion(record, remote) || record.WorldEntity is not { } entity) + { continue; + } - LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState; - AcDream.Core.Physics.RetailObjectActivityResult activity = - AcDream.Core.Physics.RetailObjectActivityGate.Evaluate( + LiveEntityAnimationState? animation = + record.AnimationRuntime as LiveEntityAnimationState; + RetailObjectActivityResult activity = + RetailObjectActivityGate.Evaluate( record.ObjectClock, remote.Body, - liveEntities.GetRootObjectClockDisposition(record.ServerGuid) - is AcDream.Core.Physics.RetailObjectClockDisposition.Advance, - hasPartArray: record.HasPartArray, - isStatic: (record.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0, + liveEntities.GetRootObjectClockDisposition( + record.ServerGuid) + is RetailObjectClockDisposition.Advance, + record.HasPartArray, + (record.FinalPhysicsState + & PhysicsStateFlags.Static) != 0, entity.Position, playerPosition, dt); - if (activity is not AcDream.Core.Physics.RetailObjectActivityResult.Active) + if (activity is not RetailObjectActivityResult.Active) continue; - AcDream.Core.Physics.AnimationSequencer? sequencer = animation?.Sequencer; + AnimationSequencer? sequencer = animation?.Sequencer; ulong objectClockEpoch = record.ObjectClockEpoch; - AcDream.Core.Physics.RetailObjectQuantumBatch batch = - record.ObjectClock.Advance(dt); + RetailObjectQuantumBatch batch = record.ObjectClock.Advance(dt); for (int qi = 0; qi < batch.Count; qi++) { - if (!liveEntities.IsCurrentSpatialRemoteMotion(record, remote) - || !ReferenceEquals(record.WorldEntity, entity) - || record.ObjectClockEpoch != objectClockEpoch) - break; - if (!TickHidden( - remote, - entity, - batch.GetQuantum(qi), - sequencer?.Manager, - processAnimationHooks, - sequencer, - liveEntities, - record, - objectClockEpoch)) + if (!IsCurrent( + liveEntities, + record, + entity, + remote, + objectClockEpoch) + || !TickHidden( + remote, + entity, + batch.GetQuantum(qi), + sequencer?.Manager, + processAnimationHooks, + sequencer, + liveEntities, + record, + objectClockEpoch)) { break; } } - // PositionManager callbacks can rebucket, delete, or replace this - // GUID. Publish only while the captured record/runtime pair still - // owns a loaded spatial projection. + if (batch.Count > 0 - && liveEntities.IsCurrentSpatialRemoteMotion(record, remote) - && ReferenceEquals(record.WorldEntity, entity) - && record.ObjectClockEpoch == objectClockEpoch) + && IsCurrent( + liveEntities, + record, + entity, + remote, + objectClockEpoch)) { - // Hidden suppresses CPartArray::Update, but HandleEnterWorld - // may already have replaced the current sequence pose. Tell - // the presentation pass to sample that retained pose once for - // this admitted object quantum rather than rebuilding it on - // every render frame. markPartPoseDirty?.Invoke(record.ServerGuid); publishRootPose(entity); } } } - /// - /// #184 Slice 2a — the per-remote DR tick (retail UpdateObjectInternal - /// shape), verbatim from the former GameWindow.TickAnimations guard - /// body. serverGuid + the entity id derive from - /// .Entity; / - /// are passed per-call (they change on streaming recentre — never snapshot - /// them in the constructor). is - /// the complete local Frame produced by this frame's preceding - /// CSequence::update, matching retail's - /// CPartArray::Update → PositionManager::adjust_offset order. - /// public bool Tick( - RemoteMotion rm, - LiveEntityAnimationState ae, + RemoteMotion remote, + LiveEntityAnimationState animation, float dt, - AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, + MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, int liveCenterY, - System.Action? - processAnimationHooks = null) - => Tick( - rm, - ae.Entity, - ae.Scale, - ae.Sequencer, - ae, + Action? processAnimationHooks = null, + LiveEntityRuntime? ownerRuntime = null, + LiveEntityRecord? ownerRecord = null, + ulong ownerClockEpoch = 0) + { + ArgumentNullException.ThrowIfNull(animation); + return Tick( + remote, + animation.Entity, + animation.Scale, + animation.Sequencer, + animation, dt, rootMotionLocalFrame, liveCenterX, liveCenterY, - processAnimationHooks); + processAnimationHooks, + ownerRuntime, + ownerRecord, + ownerClockEpoch); + } - /// - /// Canonical ordinary-object tick. Render animation is optional: retail - /// walks CPhysics::object_maint, so a live object with a - /// MovementManager or PositionManager must continue even when it has no - /// LiveEntityAnimationState presentation component. - /// public bool Tick( - RemoteMotion rm, - AcDream.Core.World.WorldEntity entity, + RemoteMotion remote, + WorldEntity entity, float objectScale, - AcDream.Core.Physics.AnimationSequencer? sequencer, + AnimationSequencer? sequencer, LiveEntityAnimationState? animationForVelocityCycle, float dt, - AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, + MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, int liveCenterY, - System.Action? - processAnimationHooks = null, - AcDream.App.World.LiveEntityRuntime? ownerRuntime = null, - AcDream.App.World.LiveEntityRecord? ownerRecord = null, + Action? processAnimationHooks = null, + LiveEntityRuntime? ownerRuntime = null, + LiveEntityRecord? ownerRecord = null, ulong ownerClockEpoch = 0) { - ArgumentNullException.ThrowIfNull(rm); + ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(entity); - ArgumentNullException.ThrowIfNull(rootMotionLocalFrame); - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) + if (ownerRuntime is null + || ownerRecord is null) { return false; } - uint serverGuid = entity.ServerGuid; - // #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; - if (animationForVelocityCycle is not null) - { - _applyServerControlledVelocityCycle( - serverGuid, - animationForVelocityCycle, - rm, - 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 - ? _physicsEngine.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 - ? _physicsEngine.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(entity.Id, hookSequencer); - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) - { - 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 && _physicsEngine.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. - var (deR, deH) = _getSetupCylinder(serverGuid, entity); - if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; } - var resolveResult = _physicsEngine.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: entity.Id); - - 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 = - ownerRecord?.StateAuthorityVersion ?? 0UL; - rm.Movement.HitGround(); - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) - { - return false; - } - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`): stops the per-tick gravity - // integration for the grounded body. - if (ownerRecord is null - || ownerRecord.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. - entity.SetPosition(rm.Body.Position); - entity.ParentCellId = committedCellId; - entity.Rotation = rm.Body.Orientation; - bool cellChanged = committedCellId != 0 - && committedCellId != rm.CellId; - if (cellChanged) - rm.CellId = committedCellId; - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) - { - 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(entity.Id, 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( + bool OwnerValid() => IsCurrent( ownerRuntime, ownerRecord, - rm, entity, + remote, ownerClockEpoch); - } + var (radius, height) = + _getSetupCylinder(ownerRecord.ServerGuid, entity); + Action? staleCycle = + animationForVelocityCycle is null + ? null + : velocity => _applyServerControlledVelocityCycle( + ownerRecord.ServerGuid, + animationForVelocityCycle, + remote, + velocity); - /// - /// Retail hidden-object slice of CPhysicsObj::UpdatePositionInternal - /// (0x00512C30) plus the manager tail of - /// UpdateObjectInternal (0x005156B0). Hidden skips - /// CPartArray::Update and UpdatePhysicsInternal, 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. - /// - public bool TickHidden( - RemoteMotion rm, - AcDream.Core.World.WorldEntity entity, - float dt, - AcDream.Core.Physics.Motion.MotionTableManager? - partArrayHandleMovement = null, - System.Action? - processAnimationHooks = null, - AcDream.Core.Physics.AnimationSequencer? sequencer = null, - AcDream.App.World.LiveEntityRuntime? ownerRuntime = null, - AcDream.App.World.LiveEntityRecord? ownerRecord = null, - ulong ownerClockEpoch = 0) - { - ArgumentNullException.ThrowIfNull(rm); - ArgumentNullException.ThrowIfNull(entity); - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) - { - return false; - } - - 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( + return _runtime.Tick( + ownerRecord.Canonical, + remote, + objectScale, + sequencer, 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(entity.Id, sequencer); - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) - { - return false; - } - - System.Numerics.Vector3 composedPosition = rm.Body.Position; - uint committedCellId = rm.CellId; - if (rm.CellId != 0 - && composedPosition != preComposePosition - && _physicsEngine.LandblockCount > 0) - { - var (radius, height) = _getSetupCylinder(entity.ServerGuid, entity); - if (radius < 0.05f) - { - radius = 0.48f; - height = 1.835f; - } - - bool previousContact = rm.Body.InContact; - bool previousOnWalkable = rm.Body.OnWalkable; - var resolved = _physicsEngine.ResolveWithTransition( - preComposePosition, - composedPosition, - rm.CellId, - radius, - height, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, - isOnGround: previousOnWalkable, - body: rm.Body, - moverFlags: IsPlayerGuid(entity.ServerGuid) - ? AcDream.Core.Physics.ObjectInfoState.IsPlayer - | AcDream.Core.Physics.ObjectInfoState.EdgeSlide - : AcDream.Core.Physics.ObjectInfoState.EdgeSlide, - movingEntityId: entity.Id); - 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( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch))) - { - 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. - entity.SetPosition(rm.Body.Position); - entity.ParentCellId = committedCellId; - entity.Rotation = rm.Body.Orientation; - if (committedCellId != 0 && committedCellId != rm.CellId) - rm.CellId = committedCellId; - if (!IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch)) - { - return false; - } - - AcDream.Core.Physics.RetailObjectManagerTail.Run( - rm.Host?.TargetManager, - rm.Movement, - partArrayHandleMovement, - rm.Host?.PositionManager); - return IsCurrentOwner( - ownerRuntime, - ownerRecord, - rm, - entity, - ownerClockEpoch); - } - - private static bool IsCurrentOwner( - AcDream.App.World.LiveEntityRuntime? ownerRuntime, - AcDream.App.World.LiveEntityRecord? ownerRecord, - RemoteMotion remote, - AcDream.Core.World.WorldEntity entity, - ulong ownerClockEpoch) => - ownerRuntime is null - || ownerRecord is null - || (ownerRecord.ObjectClockEpoch == ownerClockEpoch - && ownerRuntime.IsCurrentSpatialRemoteMotion(ownerRecord, remote) - && ReferenceEquals(ownerRecord.WorldEntity, entity)); - - /// - /// R5-V3 (#171): apply a - /// written by PositionManager.AdjustOffset onto a body — acdream's - /// stand-in for retail's Frame::combine in - /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine - /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes - /// globaltolocalvec output — 0x00555430), so combining rotates it - /// out by the body's current orientation and post-multiplies the complete - /// relative orientation. Moved from GameWindow (#184 Slice 2a); the - /// DR tick is its only caller. - /// - 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); - } - - /// - /// #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 (/) - /// rather than snapshotted, since it moves on recentre. Updates - /// and - /// so callers can - /// pose-gate. Rotation matters because Setup collision geometry may be - /// multipart or offset from the root. - /// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC - /// UP-branch tail. - /// - public void SyncRemoteShadowToBody( - uint entityId, - AcDream.App.World.ILiveEntityRemotePlacementRuntime rm, - int liveCenterX, - int liveCenterY, - uint? authoritativeCellId = null) - { - SyncRemoteShadowToBody( - entityId, - rm.Body, + ownerClockEpoch, + rootMotionLocalFrame, + radius, + height, liveCenterX, liveCenterY, - authoritativeCellId ?? rm.CellId); - rm.LastShadowSyncPosition = rm.Body.Position; - rm.LastShadowSyncOrientation = rm.Body.Orientation; + processAnimationHooks, + staleCycle, + snapshot => + { + if (!OwnerValid()) + return false; + entity.SetPosition(snapshot.Position); + entity.ParentCellId = snapshot.FullCellId; + entity.Rotation = snapshot.Orientation; + return OwnerValid(); + }, + OwnerValid); } + public bool TickHidden( + RemoteMotion remote, + WorldEntity entity, + float dt, + MotionTableManager? partArrayHandleMovement = null, + Action? processAnimationHooks = null, + AnimationSequencer? sequencer = null, + LiveEntityRuntime? ownerRuntime = null, + LiveEntityRecord? ownerRecord = null, + ulong ownerClockEpoch = 0) + { + ArgumentNullException.ThrowIfNull(remote); + ArgumentNullException.ThrowIfNull(entity); + if (ownerRuntime is null + || ownerRecord is null) + { + return false; + } + + bool OwnerValid() => IsCurrent( + ownerRuntime, + ownerRecord, + entity, + remote, + ownerClockEpoch); + var (radius, height) = + _getSetupCylinder(ownerRecord.ServerGuid, entity); + return _runtime.TickHidden( + ownerRecord.Canonical, + remote, + dt, + ownerClockEpoch, + radius, + height, + partArrayHandleMovement, + processAnimationHooks, + sequencer, + snapshot => + { + if (!OwnerValid()) + return false; + entity.SetPosition(snapshot.Position); + entity.ParentCellId = snapshot.FullCellId; + entity.Rotation = snapshot.Orientation; + return OwnerValid(); + }, + OwnerValid); + } + + public void SyncRemoteShadowToBody( + uint entityId, + IRuntimeRemotePlacement remote, + int liveCenterX, + int liveCenterY, + uint? authoritativeCellId = null) => + _runtime.SyncRemoteShadowToBody( + entityId, + remote, + liveCenterX, + liveCenterY, + authoritativeCellId); + + public void SyncRemoteShadowToBody( + uint entityId, + PhysicsBody body, + int liveCenterX, + int liveCenterY, + uint authoritativeCellId) => + _runtime.SyncRemoteShadowToBody( + entityId, + body, + liveCenterX, + liveCenterY, + authoritativeCellId); + 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( + Vector3 currentPosition, + Quaternion currentOrientation, + Vector3 lastPosition, + Quaternion lastOrientation) => + RuntimeRemotePhysicsUpdater.ShouldSynchronizeShadowPose( currentPosition, currentOrientation, lastPosition, lastOrientation); - public void SyncRemoteShadowToBody( - uint entityId, - AcDream.Core.Physics.PhysicsBody body, - int liveCenterX, - int liveCenterY, - uint authoritativeCellId) - { - ShadowPositionSynchronizer.Sync( - _physicsEngine.ShadowObjects, - entityId, - body.Position, - body.Orientation, - authoritativeCellId, - liveCenterX, - liveCenterY); - } + internal static bool ShouldSynchronizeShadow( + bool cellChanged, + Vector3 currentPosition, + Quaternion currentOrientation, + Vector3 lastPosition, + Quaternion lastOrientation) => + RuntimeRemotePhysicsUpdater.ShouldSynchronizeShadow( + cellChanged, + currentPosition, + currentOrientation, + lastPosition, + lastOrientation); + + private static bool IsCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + RemoteMotion remote, + ulong objectClockEpoch) => + record.ObjectClockEpoch == objectClockEpoch + && runtime.IsCurrentSpatialRemoteMotion(record, remote) + && ReferenceEquals(record.WorldEntity, entity); } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 21cc63aa..c6d238e3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 platform = AcquirePlatform(); diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index cef077f1..74e030c4 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -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; diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index c2725c79..169d912c 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -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 GfxObjectIdSet { get; } = new(); internal uint[] GfxObjectIds { get; set; } = Array.Empty(); 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++; } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 5abcb4ab..ae2b9d6a 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -20,65 +20,6 @@ public interface ILiveEntityAnimationRuntime uint CurrentMotion { get; } } -/// Remote motion state owned by a live object. -public interface ILiveEntityRemoteMotionRuntime -{ - PhysicsBody Body { get; } -} - -/// -/// Optional seam for a remote-motion component that reads the exact -/// incarnation's canonical movement-manager host from -/// . -/// -public interface ILiveEntityPhysicsHostConsumer -{ - void BindPhysicsHost(Func read); -} - -/// -/// 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. -/// -public interface ILiveEntityCanonicalRuntimeConsumer -{ - void BindCanonicalRuntime( - Func readPhysicsHost, - Func readCell, - Action writeCell); -} - -/// -/// Optional seam for a remote-motion component that consumes the canonical -/// full-cell identity owned by . The runtime -/// binds this for every production remote, regardless of whether projectile -/// classification arrives before or after the MovementManager. -/// -public interface ILiveEntityCanonicalCellConsumer -{ - void BindCanonicalCell(Func read, Action write); -} - -/// -/// 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. -/// -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(); -} - /// Projectile physics state owned by a live object. 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 _spatialAnimations = new(); - private readonly Dictionary _spatialRemoteMotion = new(); private readonly Dictionary _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 _spatialRootObjects = new(); + private readonly List _spatialRootCanonicalScratch = new(); + private readonly List _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 CanonicalRecords => _directory.ActiveRecords; + internal RuntimePhysicsState Physics => _physics; public IReadOnlyDictionary 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 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 readCell = () => incarnation.FullCellId; - Action 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); /// @@ -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; diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index d721be04..e5ffd086 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -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) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 63764a8e..0b2c1761 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -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) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index fb3381df..42bdd6b2 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -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; } diff --git a/src/AcDream.App/Physics/EntityPhysicsHost.cs b/src/AcDream.Runtime/Physics/EntityPhysicsHost.cs similarity index 67% rename from src/AcDream.App/Physics/EntityPhysicsHost.cs rename to src/AcDream.Runtime/Physics/EntityPhysicsHost.cs index 0f138f25..275451e5 100644 --- a/src/AcDream.App/Physics/EntityPhysicsHost.cs +++ b/src/AcDream.Runtime/Physics/EntityPhysicsHost.cs @@ -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; /// -/// R5-V2 — the App-side per entity: acdream's +/// R5-V2 — the Runtime-owned per entity: +/// acdream's /// stand-in for retail's CPhysicsObj as the movement managers see it. -/// One is built per entity (a remote RemoteMotion or the local player) -/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow -/// and stored on the exact LiveEntityRecord, so +/// One is retained per accepted object incarnation on +/// RuntimeEntityRecord, so /// can resolve OTHER entities' hosts — the /// cross-entity delivery path the 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 -/// fan-out are injected by GameWindow so this -/// class stays free of GameWindow's internals (code-structure rule #1). +/// fan-out are injected by the graphical or +/// no-window host while Runtime retains the host and manager identities. /// /// R5-V3: owns a too (retail /// CPhysicsObj::position_manager — 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. /// - 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); } - /// - /// 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. - /// - 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; - } - - /// - /// Validates an exact incarnation and returns the manager-stable host that - /// a later 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. - /// - 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."), - }; - } - - /// - /// 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. - /// - internal static EntityPhysicsHost CreateMinimal( - LiveEntityRecord record, - Func resolve, - Func 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 ────────────────────────── /// Retail TargetManager::HandleTargetting — 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 /// LeftDetection from CObjCell::hide_object; 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. diff --git a/src/AcDream.App/Physics/RemoteMotion.cs b/src/AcDream.Runtime/Physics/RemoteMotion.cs similarity index 93% rename from src/AcDream.App/Physics/RemoteMotion.cs rename to src/AcDream.Runtime/Physics/RemoteMotion.cs index e29ee21c..301a72bd 100644 --- a/src/AcDream.App/Physics/RemoteMotion.cs +++ b/src/AcDream.Runtime/Physics/RemoteMotion.cs @@ -1,6 +1,4 @@ -using AcDream.App.World; - -namespace AcDream.App.Physics; +namespace AcDream.Runtime.Physics; /// /// Per-remote-entity physics + motion stack — verbatim application of @@ -21,11 +19,11 @@ namespace AcDream.App.Physics; /// /// 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; /// R5-V5: retail CPhysicsObj::movement_manager — 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? _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 - /// , so projectile prediction, + /// , so projectile prediction, /// Movement packets, and spatial rebucketing cannot develop competing /// cell identities. /// @@ -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(); /// diff --git a/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs new file mode 100644 index 00000000..874fcba7 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs @@ -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? ExternalOwnerValid { get; init; } + internal bool Completed { get; set; } + internal RuntimePhysicsFrameSnapshot Snapshot { get; init; } +} + +/// +/// Presentation-free, body-backed branch of retail +/// CPhysicsObj::UpdateObjectInternal (`0x005156B0`). App supplies the +/// completed animation root frame and acknowledges the resulting projection; +/// Runtime owns integration, transition, canonical-body state, and shadows. +/// +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 captureAnimationHooks, + Func? 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 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? 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; +} diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs new file mode 100644 index 00000000..9ba514e4 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -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 CellSurfaces, + IReadOnlyList 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); + +/// +/// 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. +/// +public sealed class RuntimePhysicsState : IDisposable +{ + private readonly Dictionary + _spatialRoots = new(); + private readonly Dictionary + _spatialRemotes = new(); + private readonly Dictionary _collisionGenerations = new(); + private readonly Dictionary + _collisionAdmissions = new(); + private bool _disposed; + + public event Action? 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? 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 readPhysicsHost = + () => Entities.IsCurrent(record) + && ReferenceEquals(record.RemoteMotion, runtime) + ? record.PhysicsHost + : null; + Func readCell = () => record.FullCellId; + Action 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); + } + } + } + + /// + /// Acquires the one retail CPhysicsObj 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. + /// + public PhysicsBody GetOrCreatePhysicsBody( + RuntimeEntityRecord record, + Func factory, + Func? 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? 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? 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? 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 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 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? 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 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; + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteMotionContracts.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteMotionContracts.cs new file mode 100644 index 00000000..16cb8684 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteMotionContracts.cs @@ -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 read); +} + +public interface IRuntimeCanonicalPhysicsConsumer +{ + void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell); +} + +public interface IRuntimeCanonicalCellConsumer +{ + void BindCanonicalCell(Func read, Action 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(); +} diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs new file mode 100644 index 00000000..bc00468e --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs @@ -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); + +/// +/// #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 +/// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid +/// && rm.LastServerPosTime > 0). +/// Hidden remotes use a separate live-entity pass because retail keeps their +/// PositionManager alive even when the object has no render-animation owner. +/// +/// 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 — every remote now runs the SAME catch-up + +/// ResolveWithTransition sweep + shadow-follows-resolved, so packed PLAYER +/// remotes de-overlap exactly like NPCs (retail UpdateObjectInternal +/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is +/// the !IsPlayerGuid-gated stale-velocity animation-cycle stop. See +/// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. +/// +/// Shared policy arrives through focused Physics delegates: +/// DAT-derived shape dimensions and animation-cycle projection arrive through +/// the App adapter in a graphical host. SyncRemoteShadowToBody +/// (remote-physics-specific) moved here and is called back from the UP-branch +/// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other +/// callers and moved here outright. +/// +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; + + + /// + /// Canonical ordinary-object tick. Render animation is optional: retail + /// walks CPhysics::object_maint, so a live object with a + /// MovementManager or PositionManager must continue even when it has no + /// render-animation presentation component. + /// + 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? + processAnimationHooks = null, + System.Action? applyStaleVelocityCycle = null, + System.Func? + acknowledgeProjection = null, + System.Func? 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); + } + + /// + /// Retail hidden-object slice of CPhysicsObj::UpdatePositionInternal + /// (0x00512C30) plus the manager tail of + /// UpdateObjectInternal (0x005156B0). Hidden skips + /// CPartArray::Update and UpdatePhysicsInternal, 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. + /// + internal bool TickHidden( + RuntimeEntityRecord record, + RemoteMotion rm, + float dt, + ulong objectClockEpoch, + float radius, + float height, + AcDream.Core.Physics.Motion.MotionTableManager? + partArrayHandleMovement = null, + System.Action? + processAnimationHooks = null, + AcDream.Core.Physics.AnimationSequencer? sequencer = null, + System.Func? + acknowledgeProjection = null, + System.Func? 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? externalOwnerValid) => + _physics.IsSpatialRemote(record, remote) + && record.ObjectClockEpoch == objectClockEpoch + && ReferenceEquals(record.PhysicsBody, remote.Body) + && (externalOwnerValid?.Invoke() ?? true); + + /// + /// R5-V3 (#171): apply a + /// written by PositionManager.AdjustOffset onto a body — acdream's + /// stand-in for retail's Frame::combine in + /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine + /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes + /// globaltolocalvec 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. + /// + 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); + } + + /// + /// #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 (/) + /// rather than snapshotted, since it moves on recentre. Updates + /// and + /// 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. + /// + 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); + } +} diff --git a/src/AcDream.App/Physics/ShadowPositionSynchronizer.cs b/src/AcDream.Runtime/Physics/ShadowPositionSynchronizer.cs similarity index 96% rename from src/AcDream.App/Physics/ShadowPositionSynchronizer.cs rename to src/AcDream.Runtime/Physics/ShadowPositionSynchronizer.cs index e6122393..54d710d8 100644 --- a/src/AcDream.App/Physics/ShadowPositionSynchronizer.cs +++ b/src/AcDream.Runtime/Physics/ShadowPositionSynchronizer.cs @@ -1,7 +1,7 @@ using System.Numerics; using AcDream.Core.Physics; -namespace AcDream.App.Physics; +namespace AcDream.Runtime.Physics; /// /// Shared host adapter for retail's moved-object shadow re-registration diff --git a/tests/AcDream.App.Tests/GlobalUsings.cs b/tests/AcDream.App.Tests/GlobalUsings.cs index d2d17bec..d6122f76 100644 --- a/tests/AcDream.App.Tests/GlobalUsings.cs +++ b/tests/AcDream.App.Tests/GlobalUsings.cs @@ -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 = diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs index e878ab06..e7e61920 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs @@ -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(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(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(), 192f, 0f); - return engine; } private static LoadedLandblock EmptyLandblock(uint landblockId) => diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index a49444bb..bc55ffe6 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -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); diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index e96d5da3..034dea73 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -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(), 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(), PartAvailability = Array.Empty(), }; - 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(), PartAvailability = Array.Empty(), }; - 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(), PartAvailability = Array.Empty(), }; - 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(), }; - 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()); - 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(), }; + 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(), }; + RemoteBinding binding = BindRemote(entity, motion); AnimationSequencer sequencer = CreateSequencer(); sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0); var order = new List(); - 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(), }; + 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(), }; + 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(); @@ -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(), 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())); + 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(), + Array.Empty(), + Array.Empty(), + 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(), 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())); 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(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, diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs index a7a467f2..adc454af 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs @@ -101,7 +101,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - 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(), })!; - 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(), })!; - 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(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, - new AcDream.App.Physics.RemoteMotion())); + new AcDream.Runtime.Physics.RemoteMotion())); Assert.Throws(() => 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(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, - new AcDream.App.Physics.RemoteMotion())); + new AcDream.Runtime.Physics.RemoteMotion())); Assert.Throws(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, @@ -1115,7 +1115,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - 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(), })!; - 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(), })!; - 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; } diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs index 6caac319..731f5396 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs @@ -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(() => RemoteTeleportPlacement.Apply( diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index 3e76f176..99dc8a1c 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -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(); diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs new file mode 100644 index 00000000..b30e5d68 --- /dev/null +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -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."); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index 476bf5d2..ec709125 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -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, diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index 32154632..822bd86e 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -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(() => new LandblockPhysicsPublisher( - new PhysicsEngine(), + new RuntimeEntityObjectLifetime().Physics, new float[255])); - Assert.Throws(() => 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); } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs index bc9e1abe..9e62d357 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs @@ -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(); diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs index b77cfefa..f367a2c7 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -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(), }; - 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); diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 9a3b2161..4e186fcb 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -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;", diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index 04436ad3..f9b47ebc 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -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; diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index f75e054d..6d53856e 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -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; - /// R5-V5: AcDream.App.Physics.RemoteMotion.Movement twin — the ONE + /// R5-V5: AcDream.Runtime.Physics.RemoteMotion.Movement twin — the ONE /// per-entity MovementManager facade owning Interp + the MoveToManager /// (retail CPhysicsObj::movement_manager). 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) diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs new file mode 100644 index 00000000..c91870dc --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -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(); + var remotes = new List(); + 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(() => + first.Physics.AdmitCollisionAssets( + admission, + CollisionAssets(0xA9B4FFFFu))); + Assert.Throws(() => + 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(() => + 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(); + 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(() => + 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(), + Array.Empty(), + 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(), + Array.Empty(), + Array.Empty(), + 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(); + } +}